Guide · AWS IoT · Device Shadow · Desired vs Reported · Delta Events · Offline Patterns
AWS IoT Device Shadow for MCP Servers — Desired vs Reported State, Delta Events, and Offline Patterns
The IoT Device Shadow service solves a fundamental problem in device management: devices go offline, reconnect, and must reconcile their state with commands that arrived while they were unreachable. For MCP server tools, the Shadow service provides a persistent, queryable state store for each device — your MCP tool writes desired state to the shadow, the device reads it on reconnect and reports back, and IoT Core manages the delta. This guide covers the shadow document schema, classic vs named shadows, all four shadow API operations, delta reconciliation mechanics, optimistic locking with the version field, MQTT topic integration, and practical patterns for offline device management from an MCP server.
TL;DR
The Device Shadow is a JSON document with desired and reported sub-objects inside a state key. Your MCP server writes to desired via UpdateThingShadowCommand; the device writes to reported after executing the change. IoT Core computes a delta (fields in desired that differ from reported) and delivers it to the device via an MQTT topic. Use named shadows (up to 10 per Thing) to separate concerns — one shadow for config, one for telemetry, one for OTA state. Optimistic locking with the shadow version field prevents conflicting concurrent updates. Catch ResourceNotFoundException on GetThingShadow — a shadow does not exist until the first UpdateThingShadow call.
Shadow document schema
Every shadow is a JSON document with a fixed top-level structure. Understanding each field is necessary to avoid confusing the Device Shadow with a simple key-value store.
{
"state": {
// desired — what you WANT the device to do / be
// Written by the MCP server (or any cloud-side caller)
"desired": {
"powerState": "on",
"targetTemperature": 22.5,
"reportingIntervalSeconds": 60
},
// reported — what the device says its current state IS
// Written by the device itself; never written by the server
"reported": {
"powerState": "on",
"targetTemperature": 20.1,
"reportingIntervalSeconds": 60,
"firmwareVersion": "2.1.4" // device can report fields server never sets
},
// delta — COMPUTED by IoT Core; never write to it
// Contains desired fields where desired[key] !== reported[key]
// Absent if desired and reported are in sync
"delta": {
"targetTemperature": 22.5 // device has not yet reached the desired temp
}
},
// metadata — timestamps for each leaf field (epoch milliseconds)
// IoT Core manages this; you never write to it
"metadata": {
"desired": {
"powerState": { "timestamp": 1731147600000 },
"targetTemperature": { "timestamp": 1731147600000 },
"reportingIntervalSeconds": { "timestamp": 1731147600000 }
},
"reported": {
"powerState": { "timestamp": 1731147590000 },
"targetTemperature": { "timestamp": 1731147590000 },
"reportingIntervalSeconds": { "timestamp": 1731147590000 },
"firmwareVersion": { "timestamp": 1731000000000 }
}
},
// version — integer incremented on every successful update
// Use for optimistic locking; read before update, pass as expectedVersion
"version": 47,
// timestamp — epoch seconds of the last modification
"timestamp": 1731147600
}
Delta computation: IoT Core computes the delta by comparing corresponding fields in desired and reported. Only fields that exist in desired and differ from reported appear in the delta. Fields that exist only in reported (like firmwareVersion above) are never in the delta — the device manages them independently and the server does not need to react to them. Fields where desired and reported match are excluded from the delta.
Null to delete: setting a field to null in an UpdateThingShadow call removes that field from the shadow. This is how you retract a desired state — not by setting it to an empty string or zero, but by explicitly setting it to null. If you want to clear the entire desired sub-object, set state.desired to null.
Classic shadow vs named shadows
Each Thing has one classic shadow (accessed without a shadow name) and up to 10 named shadows. Named shadows are independent documents — each has its own desired, reported, delta, version, and metadata.
| Shadow type | Access pattern | Limit | Best for |
|---|---|---|---|
| Classic shadow | GetThingShadowCommand({ thingName }) — no shadowName |
1 per Thing | Simple devices with a single state concept; backward compatibility with older IoT Core integrations |
Named shadow: config |
GetThingShadowCommand({ thingName, shadowName: 'config' }) |
Up to 10 named shadows per Thing | Device configuration (reporting interval, thresholds, feature flags) — written by server, read by device |
Named shadow: telemetry |
GetThingShadowCommand({ thingName, shadowName: 'telemetry' }) |
Up to 10 named shadows per Thing | Latest sensor readings reported by device — written by device, read by server; desired section unused |
Named shadow: ota |
GetThingShadowCommand({ thingName, shadowName: 'ota' }) |
Up to 10 named shadows per Thing | OTA firmware update state — desired: { targetVersion, downloadUrl }; reported: { currentVersion, updateStatus } |
Named shadows prevent the classic shadow from becoming a large, mixed-concern document. When a device reports 15 sensor readings and the MCP server manages 8 configuration parameters, putting everything in one document creates version conflicts — every device telemetry update increments the version, forcing the server to re-read before every config write.
Enumerate all shadows for a Thing with ListNamedShadowsForThingCommand:
import { ListNamedShadowsForThingCommand } from '@aws-sdk/client-iot-data-plane';
const { results } = await iotData.send(
new ListNamedShadowsForThingCommand({ thingName: 'sensor-floor-3-rack-7' })
);
// results: ['config', 'telemetry', 'ota'] — classic shadow not included in this list
Reading and writing shadows from an MCP server
import {
IoTDataPlaneClient,
GetThingShadowCommand,
UpdateThingShadowCommand,
DeleteThingShadowCommand,
} from '@aws-sdk/client-iot-data-plane';
const iotData = new IoTDataPlaneClient({
region: 'us-east-1',
endpoint: `https://${process.env.IOT_ENDPOINT}`,
});
// Read the current shadow — handle missing shadow gracefully
async function getDeviceConfig(thingName: string): Promise | null> {
try {
const response = await iotData.send(
new GetThingShadowCommand({ thingName, shadowName: 'config' })
);
// payload is a Uint8Array; decode to string then parse
const shadow = JSON.parse(new TextDecoder().decode(response.payload));
return shadow.state?.reported ?? null;
} catch (err: any) {
if (err.name === 'ResourceNotFoundException') {
// Shadow does not exist yet — no UpdateThingShadow has been called for this Thing
return null;
}
throw err;
}
}
// Write desired state — merge semantics (only fields you send are changed)
async function setDeviceConfig(
thingName: string,
config: Record
): Promise {
const payload = JSON.stringify({
state: {
desired: config,
// Do NOT include 'reported' in server-side updates
// The device owns the reported section
},
});
await iotData.send(new UpdateThingShadowCommand({
thingName,
shadowName: 'config',
payload: Buffer.from(payload),
}));
}
// Optimistic-locking update — read version first, pass in update
async function setDeviceConfigVersioned(
thingName: string,
config: Record
): Promise {
const response = await iotData.send(
new GetThingShadowCommand({ thingName, shadowName: 'config' })
);
const current = JSON.parse(new TextDecoder().decode(response.payload));
const currentVersion = current.version as number;
const payload = JSON.stringify({
state: { desired: config },
// Passing version causes IoT Core to reject the update if the shadow
// was modified by another caller between our read and this write
// Returns HTTP 409 Conflict if version does not match
version: currentVersion,
});
await iotData.send(new UpdateThingShadowCommand({
thingName,
shadowName: 'config',
payload: Buffer.from(payload),
}));
}
Merge semantics: UpdateThingShadowCommand merges your payload with the existing shadow document. If the shadow currently has desired: { a: 1, b: 2 } and you update with desired: { b: 3 }, the result is desired: { a: 1, b: 3 }. Field a is preserved. To replace the entire desired section, you would need to null out every field you are removing.
Conflict handling (HTTP 409): when you pass a version in the update payload and the shadow's current version does not match, IoT Core returns an HTTP 409. Retry the operation: re-read the shadow, re-apply your intent, and re-submit with the new version. This prevents two MCP tool instances or concurrent user actions from overwriting each other's config changes.
Delta events and device-side reconciliation
The shadow delta is IoT Core's mechanism for notifying a device that its current state does not match the desired state. Devices subscribe to the shadow delta MQTT topic and receive a push notification whenever the delta changes — no polling required.
MQTT topics for the classic shadow:
// Device subscribes to these topics (replace {thingName} with actual name)
$aws/things/{thingName}/shadow/update/delta // notified when delta changes
$aws/things/{thingName}/shadow/update/accepted // confirmed: update was accepted
$aws/things/{thingName}/shadow/update/rejected // version conflict or malformed payload
$aws/things/{thingName}/shadow/get/accepted // response to shadow fetch request
$aws/things/{thingName}/shadow/get/rejected // shadow does not exist
// For named shadows — same pattern with /name/{shadowName}/ prefix:
$aws/things/{thingName}/shadow/name/{shadowName}/update/delta
When the MCP server updates desired.targetTemperature to 22.5 and the device has reported.targetTemperature of 20.1, IoT Core publishes to the delta topic:
{
"version": 48,
"timestamp": 1731147700,
"state": {
"targetTemperature": 22.5 // only the differing field(s)
},
"metadata": {
"targetTemperature": { "timestamp": 1731147700000 }
}
}
The device processes the delta, adjusts its physical state, then reports back:
// Device-side pseudocode (runs on the device, not in the MCP server)
mqttClient.subscribe(`$aws/things/${thingName}/shadow/name/config/update/delta`);
mqttClient.on('message', (topic, payload) => {
const delta = JSON.parse(payload.toString());
if (delta.state.targetTemperature) {
setThermostat(delta.state.targetTemperature);
// After physically applying the change, report the new state
mqttClient.publish(
`$aws/things/${thingName}/shadow/name/config/update`,
JSON.stringify({
state: {
reported: { targetTemperature: delta.state.targetTemperature }
}
})
);
// After this publish, IoT Core recomputes delta — targetTemperature disappears
// from delta because desired === reported
}
});
Offline device buffering pattern
The shadow's key advantage over direct MQTT publish is that the shadow persists while the device is offline. When the device reconnects, it fetches its shadow and processes any accumulated desired-state changes.
// MCP server — write desired state at any time, even when device is offline
// The shadow persists and waits for the device to come back
async function scheduleConfigChange(
thingName: string,
newConfig: Record
): Promise {
// This write succeeds regardless of whether the device is currently connected
await setDeviceConfig(thingName, newConfig);
// When the device reconnects and subscribes to the delta topic,
// IoT Core will immediately deliver the pending delta
}
// Device-side reconnect handler (pseudocode)
async function onReconnect() {
// Option 1: subscribe to delta topic; IoT Core pushes current delta immediately
mqttClient.subscribe(`$aws/things/${thingName}/shadow/name/config/update/delta`);
// Option 2: explicitly request the shadow to get full desired + reported + delta
mqttClient.publish(
`$aws/things/${thingName}/shadow/name/config/get`,
'' // empty payload triggers a shadow fetch
);
// Response arrives on /shadow/name/config/get/accepted with full shadow document
}
This pattern is powerful for fleet management from MCP tools: you can apply a configuration update to 1,000 devices, and each device picks it up the next time it connects — whether that is 10 seconds or 3 days from now. The shadow tracks the pending delta until the device acknowledges it by updating reported.
Detecting unacknowledged changes: compare desired and reported in the shadow document. If desired.targetTemperature !== reported.targetTemperature, the device has not yet applied the change. Add a desired.requestedAt timestamp and an MCP tool check_pending_changes that queries the shadow and surfaces deltas older than a threshold as alerts.
Reading telemetry via the shadow
Devices can write sensor readings to their shadow's reported section. This gives the MCP server a queryable, persisted view of the last-known device state without subscribing to MQTT topics.
// Device writes latest telemetry to its reported shadow section (device-side)
// MCP server reads it without needing an active MQTT subscription
mqttClient.publish(
`$aws/things/${thingName}/shadow/name/telemetry/update`,
JSON.stringify({
state: {
reported: {
temperature: 23.4,
humidity: 61.2,
batteryVoltage: 3.72,
rssi: -67,
timestamp: Date.now(),
}
}
})
);
// MCP server reads the latest telemetry at any time
async function getLatestTelemetry(thingName: string) {
const response = await iotData.send(
new GetThingShadowCommand({ thingName, shadowName: 'telemetry' })
);
const shadow = JSON.parse(new TextDecoder().decode(response.payload));
return {
readings: shadow.state.reported,
// metadata gives you the timestamp of each individual field's last update
lastUpdated: shadow.metadata.reported.temperature.timestamp,
// If the device is offline, this reading may be hours or days old
// Compare timestamp to Date.now() to detect staleness
};
}
The telemetry shadow pattern works best for low-frequency updates (every 30 seconds to minutes). For high-frequency sensor data (sub-second, thousands of readings/day), stream to Kinesis Firehose via an IoT Rule instead — the shadow service adds per-update overhead and is not designed for high-frequency writes.
Common errors and their causes
| Error / symptom | Cause | Fix |
|---|---|---|
ResourceNotFoundException on GetThingShadowCommand |
No shadow document exists; a shadow is created only on the first UpdateThingShadow call, not when the Thing is registered |
Catch this error and treat it as an empty/unknown state; create the shadow by calling UpdateThingShadow with the initial desired values |
HTTP 409 Conflict on UpdateThingShadowCommand |
The version field in the payload does not match the current shadow version (optimistic locking conflict) |
Re-read the shadow to get the current version, re-apply your changes, and retry the update with the new version number |
| Delta never clears even after device reports back | Device reports a different field name or nesting than what was set in desired; delta comparison is by exact key path |
Ensure the device reports exactly the same field names as were written to desired; check for typos or camelCase vs snake_case mismatches in device firmware |
| Shadow payload decode error | response.payload is a Uint8Array and must be decoded before JSON.parse |
Use JSON.parse(new TextDecoder().decode(response.payload)) or Buffer.from(response.payload).toString() before parsing |
Named shadow returns ResourceNotFoundException even though classic shadow exists |
Named shadows and the classic shadow are independent documents; creating the classic shadow does not create any named shadows | Call UpdateThingShadowCommand with the shadowName parameter to create each named shadow on first use |
InvalidStateTransitionException on shadow update |
Attempting to write to the delta section of the shadow; delta is computed by IoT Core and is read-only |
Shadow updates must only write to state.desired (from server) or state.reported (from device); never include a delta key in your update payload |