Skip to content

Instantly share code, notes, and snippets.

@EronWright
Created October 27, 2025 20:36
Show Gist options
  • Select an option

  • Save EronWright/f37c711f3813c686445c7333bce822b6 to your computer and use it in GitHub Desktop.

Select an option

Save EronWright/f37c711f3813c686445c7333bce822b6 to your computer and use it in GitHub Desktop.
Pulumi MCP Server - Option 2: Stateless with Eager Persistence (PatchUpdateCheckpoint)

Stateless MCP Server with Eager Persistence - Option 2

Using PatchUpdateCheckpoint for Immediate State Writes

Key Insight

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

Stateless Architecture

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

Required MCP Context Parameters

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
}

MCP Tools

Update Lifecycle

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

Stateless Resource Operations

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

Pending Operations

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

State Inspection (Read-Only)

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

Example Usage Session

# 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 finalized

Implementation Details

Helper: Apply Change Function

func 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)
}

Helper: Add Resource via Journal

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
    }
}

Sequence/Operation ID Tracking

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

Advantages

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

Performance Considerations

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

Implementation Phases

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

Comparison with Option 1

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

Next Steps

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment