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
- IoT Core fundamentals — two SDK clients with separate endpoints (
IoTClientfrom@aws-sdk/client-iotfor control plane operations atiot.{region}.amazonaws.comandIoTDataPlaneClientfrom@aws-sdk/client-iot-data-planefor data plane operations at the account-specific ATS endpoint), always retrieve the ATS endpoint withDescribeEndpointCommand({ endpointType: 'iot:Data-ATS' })and cache it (never use legacyiot:Data), MCP servers use IAM + SigV4 over HTTPS (not X.509 certificates — that is for embedded devices), IoT policy documents are a separate system from IAM policies and govern what MQTT devices can do (your MCP server never needs an IoT policy), register devices as Things withCreateThingCommand, organize into groups withCreateThingGroupCommand+AddThingToThingGroupCommand, enable Fleet Indexing for attribute-based queries across large fleets: The two-client architecture is the first thing that trips developers up. The same account, same region — but two different packages and two different base URLs. The control plane (IoTClient) handles everything that modifies IoT Core's configuration: creating Things, managing certificates, writing rules, creating jobs. The data plane (IoTDataPlaneClient) handles real-time operations: publishing MQTT messages, reading and writing Device Shadows, querying retained messages. The data plane endpoint is account-specific — it looks likea1b2c3d4e5f678-ats.iot.us-east-1.amazonaws.com— and it must be fetched withDescribeEndpointCommandand passed explicitly toIoTDataPlaneClientat construction. Using the wrong endpoint (the standardiot.{region}.amazonaws.comcontrol plane URL) for data plane calls returns a confusingUnauthorizedExceptionthat looks like an IAM problem but is actually a routing problem. The other critical distinction: your MCP server never needs an IoT policy. IoT policies govern MQTT devices — embedded hardware, microcontrollers, mobile apps connecting over MQTT. MCP servers connect via HTTPS and authenticate with standard IAM credentials. Add the required IAM actions (iot:Publish,iot:GetThingShadow,iot:UpdateThingShadow, etc.) to your execution role and you are done. Attempting to attach IoT policies to a Lambda or ECS task role will fail — IoT policies only attach to X.509 certificates and Cognito identities. - Device Shadow — persistent JSON document per Thing with
desired(written by MCP server),reported(written by device), anddelta(computed by IoT Core — never write to it) sub-objects insidestate, shadows created on firstUpdateThingShadowCommandnot on Thing registration (catchResourceNotFoundExceptionon reads and treat as empty state), payload is aUint8Array— decode withnew TextDecoder().decode(response.payload)beforeJSON.parse, up to 10 named shadows per Thing (use them to separate config / telemetry / OTA state to prevent version conflicts),nullin an update payload deletes the field (not empty string or zero), optimistic locking viaversionfield (HTTP 409 on conflict — re-read and retry), device subscribes to$aws/things/{name}/shadow/update/deltaMQTT topic for push notification of pending changes,UpdateThingShadowCommandsucceeds even when device is offline (shadow persists until device reconnects and processes the delta): The Device Shadow is simultaneously simpler and more subtle than developers expect. Simple because the core contract is just two JSON objects: desired (what you want) and reported (what is). Subtle because the merge semantics are pervasive and never behave like overwrite. When you callUpdateThingShadowCommandwithstate: { desired: { fanSpeed: 3 } }and the shadow already hasdesired: { targetTemp: 22.5, fanSpeed: 2 }, the result is{ targetTemp: 22.5, fanSpeed: 3 }— both fields survive. If you want to clear a field, you must explicitly null it:desired: { fanSpeed: null }. The other subtle behavior is the delta computation: IoT Core publishes to the delta MQTT topic only the fields wheredesiredandreporteddiffer. When the device processes a delta and publishes its updatedreportedvalues, IoT Core recomputes the delta and publishes an updated (or empty) delta message. If the device reports the exact values set in desired, the delta disappears and no further notification is sent. This publish-only-on-change behavior makes the delta topic efficient for battery-limited devices — no polling, no duplicate messages, no noise when nothing has changed. The offline buffering pattern is the shadow's most powerful feature: write desired state to the shadow at any time, the shadow persists, and when the device reconnects and subscribes to the delta topic, IoT Core immediately delivers the accumulated delta. For fleet management tools that need to push configuration to thousands of devices without coordinating connection timing, this is the right primitive. - Rules Engine — SQL query over MQTT topics (
SELECTfieldsFROMtopic filterWHEREcondition), always useawsIotSqlVersion: '2016-03-23', rule names are alphanumeric+underscore only (no hyphens), topic wildcards+(one level) and#(all remaining levels, end-only),topic(n)function extracts the nth topic segment from a wildcard topic for use in SELECT and substitution templates, multiple actions run in parallel (max 10), error action fires on action failure not on WHERE=false (always configure one — silent failures without it), Lambda action uses resource-based policy on the function (not a role ARN), all other actions (SQS/SNS/Firehose/DynamoDB/S3/Republish) require a role ARN withiot.amazonaws.comtrust, substitution templates (${timestamp()}/${topic(n)}/${fieldName}) in action parameters reference the original message payload not the SELECT-transformed payload (use original field names in templates even if you aliased them in SELECT): The substitution template gotcha catches nearly every team on their first production rule. You writeSELECT temperature AS temp FROM 'sensors/+/data'because you want the downstream Lambda to seetempnottemperature. Then you configure the S3 action key as"raw/${temp}/${timestamp()}.json"expecting the aliased name. But substitution templates evaluate against the original message payload, not the SELECT output. The S3 key gets"raw/undefined/..."because there is no field namedtempin the original payload. Use${temperature}in the template, or addtempas a field in the original device payload. This matters most for DynamoDB partition keys and S3 object keys derived from device identifiers — those silently produce incorrect routing without an error. The other non-obvious rule: the error action fires when an action fails (Lambda returns an error, SQS is throttled), but it does not fire when the WHERE clause evaluates to false. A message that does not match the WHERE condition does not trigger either the main actions or the error action — it is simply dropped. This means a misconfigured WHERE clause (wrong field name, wrong threshold) causes zero observable signal. Test rules with the IoT Core rule test feature before production deployment. - IoT Jobs — create with
CreateJobCommand(jobId is globally unique even after deletion — always append timestamp or UUID), targets are Thing ARNs and/or Thing Group ARNs, inlinedocument(≤32 KB JSON) ordocumentSource(S3 URI fetched once at creation),presignedUrlConfigwith roleArn (s3:GetObjecttrustiot.amazonaws.com) andexpiresInSecfor firmware delivery (always pin S3 object versionId to prevent firmware-replacement safety hazard), execution state machine: QUEUED (IoT Core) → IN_PROGRESS (device viaUpdateJobExecutionCommand) → SUCCEEDED/FAILED/REJECTED (device reports) or TIMED_OUT (IoT Core), device never auto-transitions QUEUED→IN_PROGRESS, monitor withDescribeJobCommand(overall counts) andListJobExecutionsForJobCommand(per-device status),DeleteJobCommandrequiresforce: truefor non-terminal executions, setabortConfig.criteriaList[].minNumberOfExecutedThingsto prevent premature abort on first few failures, prefer exponentialjobExecutionsRolloutConfigfor firmware over fixedmaximumPerMinute: The most common IoT Jobs operational incident is an abort that fires too early. A team deploys a firmware update to 10,000 devices withabortConfig: { criteriaList: [{ failureType: 'FAILED', action: 'CANCEL', thresholdPercentage: 10 }] }and nominNumberOfExecutedThings. The first device in the first batch is offline and reports its execution as FAILED immediately. One failure out of one execution is 100% — above the 10% threshold. The job cancels before a single device has successfully updated. SettingminNumberOfExecutedThings: 100ensures the abort criterion only evaluates after at least 100 devices have attempted the update, giving the rollout a statistically meaningful sample. The other Jobs failure mode is presigned URL expiry: firmware binaries can be 50-200 MB, and on a poor cellular connection a device may need 30-45 minutes to complete the download. IfexpiresInSec: 900(15 minutes), the URL expires mid-download and the device has no way to resume without requesting a new URL. SetexpiresInSec: 7200(the maximum) for large firmware binaries, and pair it with the step timeout pattern — have the device callUpdateJobExecutionCommandwith progress updates every 10 minutes to reset the in-progress timer, so a device that crashes mid-download is detected and its execution times out rather than staying QUEUED forever.
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 |