PatchUpdateCheckpoint is what the HTTP backend uses today - it:
- Takes a full
UntypedDeployment(complete snapshot) - Writes it immediately to Pulumi Cloud
- Is idempotent (can retry safely)
- Requires an active
UpdateIdentifier
No session state - each MCP tool call is independent:
Tool Call Flow (completely stateless):
1. ExportDeployment() → get current state
2. Apply change using JournalReplayer (in-memory)
3. PatchUpdateCheckpoint() → write back immediately
4. Return
Since the server is stateless, each tool call needs:
{
"stack_ref": "org/proj/stack",
"update_id": "abc123", // UpdateIdentifier from active update
"update_token": "token456" // Auth token for the update
}create_update(stack_ref, operation_type="update")
→ client.CreateUpdate(kind=UpdateUpdate)
→ Returns: {update_id, token}
User keeps these for subsequent calls.
complete_update(stack_ref, update_id, token, status="succeeded")
→ client.CompleteUpdate(status)
→ Finalizes the update
cancel_update(stack_ref, update_id)
→ client.CancelUpdate()
→ Abandons the update
Each operation follows this pattern:
add_resource(context, urn, type, properties, ...)
// 1. Read current state
deployment := client.ExportDeployment(ctx, stack)
var dep apitype.DeploymentV3
json.Unmarshal(deployment.Deployment, &dep)
// 2. Apply change (in-memory)
replayer := backend.NewJournalReplayer(&dep)
entry := apitype.JournalEntry{
Kind: apitype.JournalEntryKindSuccess,
SequenceID: nextSeq(),
OperationID: nextOp(),
State: serializeResource(resource),
}
replayer.Add(entry)
finalDep, version, features := replayer.GenerateDeployment()
// 3. Write back immediately
finalData := json.Marshal(finalDep)
untypedDep := &apitype.UntypedDeployment{
Version: version,
Features: features,
Deployment: finalData,
}
client.PatchUpdateCheckpoint(ctx, updateID, untypedDep, token)
// State is now persisted!update_resource(context, urn, new_properties, ...)
1. ExportDeployment() → current state
2. Find resource by URN
3. Create JournalEntry with:
- State = new resource state
- RemoveOld = index of old resource
4. JournalReplayer.Add(entry) → generate new state
5. PatchUpdateCheckpoint() → persist immediately
delete_resource(context, urn)
1. ExportDeployment() → current state
2. Find resource by URN
3. Create JournalEntry with:
- RemoveOld = index of resource
- No State (deletion)
4. JournalReplayer.Add(entry) → generate new state
5. PatchUpdateCheckpoint() → persist immediately
set_pending_operation(context, urn, operation_type)
1. ExportDeployment()
2. Create JournalEntry with Kind=Begin
3. Sets PendingOperations in deployment
4. PatchUpdateCheckpoint() → persist
clear_pending_operation(context, urn, success=true)
1. ExportDeployment()
2. Remove from PendingOperations
3. PatchUpdateCheckpoint() → persist
list_resources(stack_ref)
→ ExportDeployment()
→ Return Resources array
get_resource(stack_ref, urn)
→ ExportDeployment()
→ Find and return specific resource
list_pending_operations(stack_ref)
→ ExportDeployment()
→ Return PendingOperations array
# 1. Create update (gets context)
create_update(stack_ref="org/proj/dev")
→ {update_id: "u-abc123", token: "tok-xyz"}
# 2. Add a resource (immediately persisted)
add_resource(
stack_ref="org/proj/dev",
update_id="u-abc123",
token="tok-xyz",
urn="urn:pulumi:dev::app::aws:s3/bucket:Bucket::my-bucket",
type="aws:s3/bucket:Bucket",
properties={...}
)
→ State written to Pulumi Cloud ✓
# 3. Update another resource (immediately persisted)
update_resource(
stack_ref="org/proj/dev",
update_id="u-abc123",
token="tok-xyz",
urn="urn:pulumi:dev::app::aws:s3/bucket:Bucket::existing",
properties={...}
)
→ State written to Pulumi Cloud ✓
# 4. Delete a resource (immediately persisted)
delete_resource(
stack_ref="org/proj/dev",
update_id="u-abc123",
token="tok-xyz",
urn="urn:pulumi:dev::app::aws:s3/bucket:Bucket::old"
)
→ State written to Pulumi Cloud ✓
# 5. Complete the update
complete_update(
stack_ref="org/proj/dev",
update_id="u-abc123",
token="tok-xyz",
status="succeeded"
)
→ Update finalizedfunc applyChange(
ctx context.Context,
client *client.Client,
stack StackIdentifier,
updateID UpdateIdentifier,
token UpdateTokenSource,
changeFn func(*apitype.DeploymentV3) error,
) error {
// 1. Read current
udep, err := client.ExportDeployment(ctx, stack, nil)
if err != nil {
return err
}
var dep apitype.DeploymentV3
json.Unmarshal(udep.Deployment, &dep)
// 2. Apply change
if err := changeFn(&dep); err != nil {
return err
}
// 3. Write back
finalData, _ := json.Marshal(&dep)
untypedDep := &apitype.UntypedDeployment{
Version: udep.Version,
Features: udep.Features,
Deployment: finalData,
}
return client.PatchUpdateCheckpoint(ctx, updateID, untypedDep, token)
}func addResourceChange(newResource *resource.State) func(*apitype.DeploymentV3) error {
return func(dep *apitype.DeploymentV3) error {
replayer := backend.NewJournalReplayer(dep)
entry := apitype.JournalEntry{
Version: 1,
Kind: apitype.JournalEntryKindSuccess,
SequenceID: 1, // Could track this
OperationID: 1,
State: serializeResource(newResource),
}
replayer.Add(entry)
newDep, _, _ := replayer.GenerateDeployment()
*dep = *newDep
return nil
}
}Option 1: Include in MCP context
{
"update_id": "abc",
"token": "xyz",
"next_sequence_id": 5,
"next_operation_id": 3
}Client tracks and increments.
Option 2: Server generates
// Server maintains simple counter per update_id
var sequenceCounters sync.Map // updateID -> counter✓ Stateless: No server-side session management ✓ Eager Persistence: Every operation writes immediately ✓ Crash-Safe: State always on server, never lost ✓ Simple: Each tool call is independent ✓ Auditable: Each PatchUpdateCheckpoint creates history ✓ Uses Current API: PatchUpdateCheckpoint is production-ready
Each operation does:
- 1 ExportDeployment (~1 API call)
- 1 PatchUpdateCheckpoint (~1 API call) = 2 API calls per operation
For 10 resources: ~20 API calls This is acceptable for interactive editing use case.
If batching is needed later, can add:
begin_batch() → stores operations locally
apply_batch() → applies all at once
Phase 1: Core Infrastructure
- MCP server setup with client initialization
- create_update, complete_update, cancel_update
- Basic applyChange helper
- Update token management
Phase 2: Resource Operations
- add_resource using JournalReplayer
- update_resource using JournalReplayer
- delete_resource using JournalReplayer
- Sequence/operation ID generation
Phase 3: Advanced Features
- Pending operations
- Resource flags (protect, delete, pendingReplacement)
- State inspection tools
Phase 4: Polish
- Error handling & retries
- Validation before writes
- Documentation & examples
| Aspect | Import (Option 1) | Stateless Journal (Option 2) |
|---|---|---|
| API Calls | N resources | 2 per operation |
| Server State | None | None |
| Engine Overhead | Full engine run | None (client-side) |
| Batching | Limited | Can add batch mode |
| Atomicity | Per-resource | Per-operation |
| Complexity | High (engine) | Low (JournalReplayer) |
| Crash Safety | After each import | After each operation |
This approach gives you:
- ✓ Stateless MCP server
- ✓ Eager persistence (writes immediately)
- ✓ Works with current Pulumi Cloud APIs
- ✓ Simple, predictable behavior
- ✓ Client-side journal replay
- ✓ No server-side journaling feature required
Ready to implement?