Skip to content

Instantly share code, notes, and snippets.

@ggoodman
Last active September 19, 2025 20:07
Show Gist options
  • Save ggoodman/f814a181155c5a81706905ec08432870 to your computer and use it in GitHub Desktop.
Save ggoodman/f814a181155c5a81706905ec08432870 to your computer and use it in GitHub Desktop.
MCP Tool calling as re-entrant sagas

Design Rationale

The primary goal is to allow a server to execute complex tool logic that requires multiple interactions with the client (like elicitation or LLM sampling) without maintaining any state between requests. This is achieved by serializing the saga's state into an opaque token that is passed back and forth.

  • Server Benefits: Simplifies server implementation, enhances scalability, and improves resilience. A server can be restarted without losing the progress of an in-flight tool call.
  • Client Responsibilities: The client acts as the state holder, responsible for driving the saga forward by responding to server requests and passing the state token back.

New Protocol Operations

Two new methods are proposed: tools/callSaga to initiate the process and tools/resumeSaga to continue it.

tools/callSaga (Request)

This is the entry point for a stateless tool call saga. It is sent from the client to the server and is analogous to the existing tools/call request.

  • Method: tools/callSaga
  • Params:
    • name: string - The name of the tool to invoke.
    • arguments: object - The initial arguments for the tool.

tools/resumeSaga (Request)

Sent by the client to continue a suspended saga. It provides the results of the steps requested by the server in the previous turn.

  • Method: tools/resumeSaga
  • Params:
    • sagaToken: string - The opaque state token received from the server in the last ToolCallSagaResult.
    • completedSteps: SagaStepResult[] - An array containing the results for all steps that have been completed in the saga thus far. This ensures the server receives the full context on every resume call.

New Data Structures

To support this flow, several new data structures are required.

ToolCallSagaResult

This is a new type of result, returned by the server when a tool call cannot be completed immediately and requires further action from the client.

  • state: 'suspended' - A literal string indicating the saga is paused.
  • sagaToken: string - An opaque, server-generated token. This token encapsulates the entire state of the server-side saga logic. The client MUST return this token verbatim in the subsequent tools/resumeSaga request. For security, servers SHOULD sign or encrypt this token to prevent tampering.
  • steps: SagaStep[] - An array of one or more actions the client needs to perform.

SagaStep (Discriminated Union)

Represents a single action the client must take. Each step has a unique stepId for correlation.

  1. ElicitationStep:

    • type: 'elicitation'
    • stepId: string
    • params: The parameters for an elicitation/create request (e.g., message, requestedSchema).
  2. SamplingStep:

    • type: 'sampling'
    • stepId: string
    • params: The parameters for a sampling/createMessage request (e.g., messages, modelPreferences).

SagaStepResult

Sent by the client within the tools/resumeSaga request to provide the outcome of a specific step.

  • stepId: string - The ID of the step this result corresponds to.
  • result: The result object from the completed step (e.g., an ElicitResult or a CreateMessageResult).
  • error: An error object if the step failed on the client side (e.g., the user rejected the elicitation).

Detailed Message Flow Example

Here is the step-by-step flow based on your example, using the proposed new structures.

1. Client Initiates the Tool Call

The client starts the saga.

// Client -> Server
{
  "jsonrpc": "2.0",
  "id": "client-req-1",
  "method": "tools/callSaga",
  "params": {
    "name": "complex_tool",
    "arguments": { "initial_arg": "value" }
  }
}

2. Server Suspends for Elicitation

The server's logic determines it needs user input. It suspends the saga and sends back an elicitation request.

// Server -> Client
{
  "jsonrpc": "2.0",
  "id": "client-req-1",
  "result": {
    "state": "suspended",
    "sagaToken": "opaque-server-state-token-v1",
    "steps": [
      {
        "type": "elicitation",
        "stepId": "step-elicitation-A",
        "params": {
          "message": "Please provide the deployment target:",
          "requestedSchema": {
            "type": "object",
            "properties": { "target": { "type": "string" } },
            "required": ["target"]
          }
        }
      }
    ]
  }
}

3. Client Resumes with Elicitation Result

The client performs the elicitation, gets the data from the user, and resumes the saga, providing the result for the first step.

// Client -> Server
{
  "jsonrpc": "2.0",
  "id": "client-req-2",
  "method": "tools/resumeSaga",
  "params": {
    "sagaToken": "opaque-server-state-token-v1",
    "completedSteps": [
      {
        "stepId": "step-elicitation-A",
        "result": {
          "action": "accept",
          "content": { "target": "production" }
        }
      }
    ]
  }
}

4. Server Suspends for LLM Sampling

The server processes the elicitation result and now needs to sample an LLM. It generates a new state token and requests the sampling step.

// Server -> Client
{
  "jsonrpc": "2.0",
  "id": "client-req-2",
  "result": {
    "state": "suspended",
    "sagaToken": "opaque-server-state-token-v2",
    "steps": [
      {
        "type": "sampling",
        "stepId": "step-sampling-B",
        "params": {
          "messages": [
            {
              "role": "user",
              "content": { "type": "text", "text": "Is deploying to 'production' safe right now?" }
            }
          ],
          "maxTokens": 100
        }
      }
    ]
  }
}

5. Client Resumes with All Completed Step Results

The client performs the sampling and resumes the saga again. This time, it includes the results from both the elicitation and the sampling steps to provide the server with full context.

// Client -> Server
{
  "jsonrpc": "2.0",
  "id": "client-req-3",
  "method": "tools/resumeSaga",
  "params": {
    "sagaToken": "opaque-server-state-token-v2",
    "completedSteps": [
      {
        "stepId": "step-elicitation-A",
        "result": {
          "action": "accept",
          "content": { "target": "production" }
        }
      },
      {
        "stepId": "step-sampling-B",
        "result": {
          "role": "assistant",
          "content": { "type": "text", "text": "Yes, all systems are green." },
          "model": "client-side-llm-v2"
        }
      }
    ]
  }
}

6. Server Completes the Tool Call

The server now has all the information it needs. It completes the business logic and returns a final CallToolResult. The saga is now concluded.

// Server -> Client
{
  "jsonrpc": "2.0",
  "id": "client-req-3",
  "result": {
    "content": [
      { "type": "text", "text": "Deployment to production initiated successfully based on confirmation." }
    ]
  }
}

Schema Definitions (TypeScript-style)

// In mcp-schema.ts

// New Request types
export interface CallToolSagaRequest extends Request {
  method: 'tools/callSaga';
  params: {
    name: string;
    arguments?: { [key: string]: unknown };
  };
}

export interface ResumeToolSagaRequest extends Request {
  method: 'tools/resumeSaga';
  params: {
    sagaToken: string;
    completedSteps: SagaStepResult[];
  };
}

// New Result type for suspended sagas
export interface ToolCallSagaResult extends Result {
  state: 'suspended';
  sagaToken: string;
  steps: SagaStep[];
}

// Union type for different saga steps
export type SagaStep = ElicitationStep | SamplingStep;

export interface ElicitationStep {
  type: 'elicitation';
  stepId: string;
  params: ElicitRequest['params']; // Reuse existing ElicitRequest params
}

export interface SamplingStep {
  type: 'sampling';
  stepId: string;
  params: CreateMessageRequest['params']; // Reuse existing CreateMessageRequest params
}

// Structure for returning step results
export interface SagaStepResult {
  stepId: string;
  result?: ElicitResult | CreateMessageResult;
  error?: { code: number; message: string; data?: unknown };
}

// Final result remains the same
// type CallToolResult = ...
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment