Guide · Workflow Orchestration
MCP Tools for Temporal — workflow execution queries, signals, and namespace health
Temporal exposes its state to external tools through two interfaces: the gRPC-based Temporal Client SDK (available in TypeScript, Go, Python, Java) and the Temporal HTTP/gRPC API that the SDK wraps. When you build MCP tools for Temporal — listing open workflows, checking if a long-running process is stuck, sending a signal to wake a sleeping workflow, or querying a workflow's current state via its Query handler — three distinctions determine correctness: workflow execution status vs workflow health (RUNNING status does not mean the workflow is making progress), queries vs signals vs updates (each has different consistency and side-effect semantics), and search attributes vs execution data (Visibility index is eventually consistent and scoped to indexed fields; actual workflow state requires a synchronous Query RPC to the worker).
TL;DR
Use client.workflow.getHandle(workflowId) to get a handle, then handle.describe() for execution metadata and handle.query(queryFn) for real-time state. Use client.workflow.list() with Temporal Visibility Query Language for bulk listing — it queries the Visibility store (eventually consistent, indexed fields only). Detect stuck workflows by combining status: RUNNING with a StartTime search attribute filter and a last-state-change timestamp from describe(). Never confuse status: RUNNING with "making progress" — a workflow waiting on a Sleep or an Activity can be RUNNING for hours.
Temporal architecture for MCP tool authors
Temporal separates workflow orchestration (run by workers that host your Workflow and Activity code) from workflow state persistence (managed by the Temporal Server). As an MCP tool author, you connect to the Temporal Server — not to the workers — using the Temporal Client SDK. The Server stores the entire event history of every workflow execution; workers replay this history to reconstruct workflow state. This means the Server can answer metadata questions (status, start time, history size) without worker involvement, but state questions (what is the current value of this workflow variable?) require a synchronous gRPC Query that routes through a worker.
Temporal Cloud uses the same SDK and API as self-hosted Temporal, but authentication differs. Self-hosted Temporal typically uses mTLS or no auth in development. Temporal Cloud requires a client certificate chain (or API key in newer SDK versions) and a namespace address in the form <namespace>.<accountId>.tmprl.cloud:7233. The TypeScript SDK accepts these via the NativeConnection configuration block.
import { Client, Connection } from '@temporalio/client';
import * as fs from 'node:fs';
async function createTemporalClient(config) {
let connection;
if (config.type === 'cloud') {
// Temporal Cloud: mTLS connection to namespace endpoint
connection = await Connection.connect({
address: `${config.namespace}.${config.accountId}.tmprl.cloud:7233`,
tls: {
clientCertPair: {
crt: fs.readFileSync(config.certPath),
key: fs.readFileSync(config.keyPath),
},
},
});
} else {
// Self-hosted: plain gRPC (add TLS config if needed)
connection = await Connection.connect({
address: config.address ?? 'localhost:7233',
});
}
return new Client({
connection,
namespace: config.namespace,
});
}
// Create once and reuse — Connection pools gRPC channels
const client = await createTemporalClient({
type: 'cloud',
namespace: 'my-namespace',
accountId: 'abc123',
certPath: '/secrets/temporal.pem',
keyPath: '/secrets/temporal.key',
});
Workflow execution status and describe()
The WorkflowExecutionStatus enum has six values: RUNNING, COMPLETED, FAILED, CANCELED, TERMINATED, and CONTINUED_AS_NEW. The last value is specific to Temporal: when a long-running workflow uses workflow.continueAsNew() to reset its event history (Temporal enforces a 50,000-event hard limit per execution), the old execution enters CONTINUED_AS_NEW state and a new execution starts with the same Workflow ID. To follow a workflow across continue-as-new boundaries, you must track by Workflow ID and re-fetch the latest execution rather than pinning to a Run ID.
handle.describe() returns the WorkflowExecutionDescription which includes status, start time, close time (null if still running), workflow type name, task queue, history length (event count), and the memo and search attributes attached at start time. The historyLength field is useful for detecting workflows approaching the 50,000-event limit — a workflow at 40,000+ events should trigger a CONTINUED_AS_NEW check.
async function describeWorkflow(client, workflowId, runId) {
// runId is optional — omitting it resolves to the latest run for this workflowId
const handle = client.workflow.getHandle(workflowId, runId);
const desc = await handle.describe();
return {
workflowId: desc.workflowId,
runId: desc.runId,
type: desc.type, // workflow function name
taskQueue: desc.taskQueue,
status: desc.status.name, // 'RUNNING' | 'COMPLETED' | 'FAILED' | 'CANCELED' | 'TERMINATED' | 'CONTINUED_AS_NEW'
startTime: desc.startTime,
closeTime: desc.closeTime, // null if RUNNING or CONTINUED_AS_NEW
historyLength: desc.historyLength,
// Memo: arbitrary key-value attached at workflow start, not indexed
memo: desc.memo,
// Search attributes: indexed key-value for Visibility queries
searchAttributes: desc.searchAttributes,
// Useful derived metrics:
runDurationMs: desc.closeTime
? desc.closeTime.getTime() - desc.startTime.getTime()
: Date.now() - desc.startTime.getTime(),
isApproachingHistoryLimit: desc.historyLength > 40_000,
};
}
// Detect workflows stuck in RUNNING state for more than a threshold
async function detectStuckWorkflows(client, workflowType, stuckThresholdMs = 3_600_000) {
const cutoff = new Date(Date.now() - stuckThresholdMs);
const stuckCandidates = [];
// Visibility query: list RUNNING workflows of this type started before cutoff
// Note: StartTime in Visibility is indexed but eventually consistent (~1-2s lag)
const iter = client.workflow.list({
query: `WorkflowType = '${workflowType}' AND ExecutionStatus = 'Running' AND StartTime < '${cutoff.toISOString()}'`,
});
for await (const exec of iter) {
// Visibility only gives us indexed fields — fetch full description for detail
const handle = client.workflow.getHandle(exec.workflowId, exec.runId);
const desc = await handle.describe();
stuckCandidates.push({
workflowId: desc.workflowId,
runId: desc.runId,
startTime: desc.startTime,
historyLength: desc.historyLength,
runDurationMs: Date.now() - desc.startTime.getTime(),
});
}
return stuckCandidates;
}
Visibility queries: listing and searching executions
Temporal's Visibility system indexes workflow execution metadata into a searchable store (Elasticsearch in production Temporal, SQLite in development). The Temporal Visibility Query Language is a SQL-like DSL that filters on indexed fields: ExecutionStatus, WorkflowType, WorkflowId, RunId, TaskQueue, StartTime, CloseTime, and any custom search attributes you define on the namespace.
Custom search attributes allow you to attach queryable business context to workflows at start time. For example, an AI agent pipeline might define a CustomStringField1 attribute named AgentRunId and populate it when starting workflows. You can then list all workflow executions for a specific agent run. Custom search attributes are typed (Text, Keyword, Int, Double, Bool, Datetime, KeywordList) and must be registered on the namespace before use.
import { WorkflowExecutionStatus } from '@temporalio/common';
async function listWorkflowsByStatus(client, workflowType, statuses) {
// Build a Visibility query with multiple status values
const statusClauses = statuses
.map((s) => `ExecutionStatus = '${s}'`)
.join(' OR ');
const query = `WorkflowType = '${workflowType}' AND (${statusClauses}) ORDER BY StartTime DESC`;
const results = [];
const iter = client.workflow.list({ query, pageSize: 100 });
for await (const exec of iter) {
results.push({
workflowId: exec.workflowId,
runId: exec.runId,
type: exec.type,
status: exec.status.name,
startTime: exec.startTime,
closeTime: exec.closeTime,
// searchAttributes contains indexed custom fields
searchAttributes: exec.searchAttributes,
});
}
return results;
}
// Count executions by status for a dashboard overview
async function getNamespaceExecutionCounts(client, workflowType) {
const statuses = ['Running', 'Completed', 'Failed', 'Canceled', 'Terminated'];
const counts = {};
await Promise.all(
statuses.map(async (status) => {
let count = 0;
const query = workflowType
? `WorkflowType = '${workflowType}' AND ExecutionStatus = '${status}'`
: `ExecutionStatus = '${status}'`;
const iter = client.workflow.list({ query, pageSize: 1000 });
for await (const _ of iter) count++;
counts[status.toLowerCase()] = count;
})
);
return counts;
}
Queries vs signals vs updates: when to use each
Temporal provides three mechanisms to interact with a running workflow from outside, and they differ fundamentally in semantics. Understanding the difference is essential for building correct MCP tools.
Queries are read-only and synchronous. A Query is routed to a live worker, which replays the workflow's event history to reconstruct state and then executes the Query handler function. Queries cannot modify workflow state and cannot block — they must return immediately. If no worker is available to handle the query, the SDK throws a WorkflowNotFoundError or times out. Use Queries to retrieve the current value of a workflow variable: "what step is this order at?", "how many retries have occurred?", "what is the current queue depth?"
Signals are asynchronous and append to the workflow's event history immediately. Sending a signal does not wait for the workflow to process it — the signal is durable in the event history and will be processed on the next workflow task regardless of whether a worker is currently running. Signals can modify workflow state by unblocking a workflow.condition() wait or adding to a queue that the workflow polls. Use Signals for one-way commands: "cancel this order", "add this item to the pending queue", "wake up and check for new work".
Updates (added in Temporal SDK 1.8+) are the hybrid: they deliver a message to the workflow, wait for a validator function to accept or reject it, wait for the workflow to process it, and return a result — all synchronously from the caller's perspective. Updates combine the durability of Signals (the update is persisted in history) with the response semantics of Queries. Use Updates when you need confirmation: "add this item to the cart and tell me the new total", "pause this workflow and confirm it's paused".
// Query: read current state synchronously (worker must be alive)
async function queryWorkflowState(client, workflowId, queryName) {
const handle = client.workflow.getHandle(workflowId);
try {
// queryName must match a workflow.defineQuery() in the workflow code
const state = await handle.query(queryName);
return { type: 'query', data: state };
} catch (err) {
if (err.name === 'QueryNotRegisteredError') {
throw new Error(`Workflow does not expose query '${queryName}'`);
}
throw err;
}
}
// Signal: send an async command (durable, no response)
async function signalWorkflow(client, workflowId, signalName, payload) {
const handle = client.workflow.getHandle(workflowId);
// Signal is written to event history immediately; processing is async
await handle.signal(signalName, payload);
return { type: 'signal', workflowId, signalName, delivered: true };
}
// Update: send a command and wait for the workflow to process it (Temporal SDK 1.8+)
async function updateWorkflow(client, workflowId, updateName, payload) {
const handle = client.workflow.getHandle(workflowId);
try {
// Blocks until the workflow's Update handler returns a result
const result = await handle.executeUpdate(updateName, { args: [payload] });
return { type: 'update', workflowId, updateName, result };
} catch (err) {
// UpdateRejectedError: the update validator function rejected it
if (err.name === 'UpdateRejectedError') {
return { type: 'update', rejected: true, reason: err.message };
}
throw err;
}
}
Terminating and canceling workflows
Temporal distinguishes between Cancellation and Termination, and the distinction matters for workflow cleanup code. Cancellation is cooperative: Temporal schedules a CancelRequested event in the workflow's history, and the workflow decides how to respond — it can handle cleanup, run compensating activities, and choose when to return. The workflow code receives this via Context.current().cancelled and can catch CancelledFailure. Cancellation is appropriate when you want the workflow to clean up gracefully.
Termination is forceful: the workflow execution is immediately marked TERMINATED with no chance for cleanup code to run. The termination event is appended to history and any in-progress Activity tasks are abandoned. Use Termination only for workflows that are stuck, unresponsive, or that you cannot wait for graceful shutdown — for example, a workflow whose worker has died and will never process a CancelRequested event.
async function cancelWorkflow(client, workflowId, reason) {
const handle = client.workflow.getHandle(workflowId);
const desc = await handle.describe();
if (desc.status.name !== 'RUNNING') {
return { status: 'no_op', reason: `Workflow is already ${desc.status.name}` };
}
// Sends CancelRequested event — workflow decides how/when to honor it
await handle.cancel();
return { status: 'cancel_requested', workflowId, reason };
}
async function terminateWorkflow(client, workflowId, reason) {
const handle = client.workflow.getHandle(workflowId);
// Immediate hard stop — no cleanup code runs
await handle.terminate(reason);
return { status: 'terminated', workflowId, reason };
}
// Gracefully cancel with fallback to terminate after timeout
async function cancelWithFallback(client, workflowId, gracePeriodMs = 30_000) {
const handle = client.workflow.getHandle(workflowId);
await handle.cancel();
const deadline = Date.now() + gracePeriodMs;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 2000));
const desc = await handle.describe();
if (desc.status.name !== 'RUNNING') {
return { status: 'gracefully_cancelled', finalStatus: desc.status.name };
}
}
// Still running after grace period — terminate forcefully
await handle.terminate(`Grace period of ${gracePeriodMs}ms expired after cancel request`);
return { status: 'force_terminated', workflowId };
}
Namespace health and AliveMCP integration
A Temporal namespace can be "healthy" (Server reachable, gRPC connection succeeds) while being operationally degraded (no active workers polling the task queue, so new workflow executions pile up without making progress). These are fundamentally different conditions and both matter for production monitoring.
To detect worker absence, poll the task queue for a pollers list. The Temporal Cloud Operations API (or temporalctl on self-hosted) exposes task queue poller information. Via the TypeScript SDK, you can use client.workflowService.describeTaskQueue() to get the list of active pollers. Zero pollers for a task queue means no workers are processing it — any RUNNING workflows on that task queue will make no progress and any new workflow starts will be stuck in SCHEDULED activity task state.
async function checkNamespaceHealth(client, namespace, taskQueues) {
const healthReport = {
namespace,
serverReachable: false,
taskQueuePollers: {},
openWorkflowCount: 0,
failedWorkflowLast1h: 0,
};
// Check server reachability via a lightweight RPC
try {
await client.workflowService.getSystemInfo({});
healthReport.serverReachable = true;
} catch {
return healthReport; // Server unreachable — stop here
}
// Check pollers per task queue
for (const tq of taskQueues) {
try {
const response = await client.workflowService.describeTaskQueue({
namespace,
taskQueue: { name: tq },
taskQueueType: 1, // WORKFLOW task queue type
});
healthReport.taskQueuePollers[tq] = {
pollerCount: response.pollers?.length ?? 0,
pollers: (response.pollers ?? []).map((p) => ({
identity: p.identity,
lastAccessTime: p.lastAccessTime,
ratePerSecond: p.ratePerSecond,
})),
};
} catch (err) {
healthReport.taskQueuePollers[tq] = { error: err.message };
}
}
// Count open executions via Visibility
try {
let openCount = 0;
const iter = client.workflow.list({ query: `ExecutionStatus = 'Running'` });
for await (const _ of iter) openCount++;
healthReport.openWorkflowCount = openCount;
} catch {}
// Count failures in last hour
try {
const oneHourAgo = new Date(Date.now() - 3_600_000).toISOString();
let failCount = 0;
const iter = client.workflow.list({
query: `ExecutionStatus = 'Failed' AND CloseTime > '${oneHourAgo}'`,
});
for await (const _ of iter) failCount++;
healthReport.failedWorkflowLast1h = failCount;
} catch {}
return healthReport;
}
Register this health check with AliveMCP as a custom HTTP probe on a /health/temporal endpoint. Use a 30-second probe interval — the Temporal gRPC call and Visibility query together typically complete in under 500ms for small namespaces, but can take 2-3 seconds for namespaces with millions of executions, so set an AliveMCP timeout of 10 seconds. Alert on pollerCount === 0 for any task queue and on failedWorkflowLast1h above your baseline threshold, not just on serverReachable === false.
Related guides
- MCP tools for AWS Step Functions — STANDARD vs EXPRESS, execution history, and waitForTaskToken
- MCP tools for Apache Airflow — DAG run states, task instance hierarchy, and REST API v1
- MCP tools for Prefect — flow run states, work pool health, and deployment triggers
- MCP tools for Dagster — asset materializations, run status, and sensor management
- MCP server health check patterns — composite endpoints and failure classification
- MCP server error handling — retries, timeouts, and structured error responses