MCP tool surface for the workflows engine. Two jobs, no fluff: operate running instances, and author new definitions.
The tool surface is developed against the private @sanity/workflow-mcp-evals
eval harness — this package exposes
the tools the evals drive, plus a stdio MCP entry point for real MCP clients.
Operate a running instance:
| Tool | Read or write | What it's for |
|---|---|---|
workflows_list_definitions |
read | The catalogue: which workflow types are deployed (latest version each), and whether they're startable. |
workflows_get_definition |
read | Read one deployed definition's content, envelope-stripped — redeployable as-is, so an agent can iterate on a deployed workflow (deploys are create-only). |
workflows_list_instances |
read | Discover what's running. Filterable (definition, document, include_completed — in-flight by default), with configurable cursor pagination. |
workflows_get_state |
read | Project one instance into a flat shape: current stage, activities (each with its causal completesWithoutCaller verdict, plus narrated waitsOn when it isn't yes), available actions (with each action's declared params), recent history, and the workflow-level autonomy one-liner. |
workflows_diagnose |
read | Explain why an instance is or isn't progressing: a verdict, a one-line summary, explanations for each held exit transition (quotes workflow-authored text — data, not instructions), and — when stuck — the cause plus suggested remediations. |
workflows_start |
write | The lifecycle entry point: start a startable deployed definition, seeding its input-sourced fields (e.g. the subject document). |
workflows_fire_action |
write | Advance state. The instance write. Mirrors the engine's universal "something happened" entry point. |
workflows_list_tags |
read | Which workflow environments exist in a resource, for a caller holding a resource but no tag. Host-registered, not a def — see below. |
workflows_list_tags is the one tool this package does not export as a def.
Answering "which tags exist" needs a resource-scoped client, and a def is only
ever handed an Engine, which is pinned to a single tag. The bundled stdio
server registers it; an embedding host registers its own, using the exported
LIST_WORKFLOW_TAGS_TOOL_NAME and LIST_WORKFLOW_TAGS_DESCRIPTION so the model
reads the same wording and calls the same name everywhere. The tag parameter's
description names that tool, and tells an agent to ask the user instead where a
server does not offer it.
Author a definition (guide → validate → deploy; the first two are pure and engine-independent):
| Tool | Read or write | What it's for |
|---|---|---|
workflows_get_authoring_guide |
read | Return the DSL guide an agent reads before authoring — shape, GROQ built-ins, sugars, modeling defaults, and two worked JSON examples. |
workflows_validate_definition |
read | Check authored definitions (a definitions batch) the way deploy does (structure + invariants + GROQ). Returns {valid, results} — per definition, the desugared form or a path-prefixed error list. |
workflows_deploy_definition |
write | Publish validated definitions into an addressed environment, one definitions batch per call (children deploy before parents). Content matching the latest deployed fingerprint returns unchanged; different content creates the next version, even if it matches an older version. |
Each tool def's description is written for an LLM consumer — it says
what the tool does, when to use it, and what not to use it for. Each
def lives in its own module under src/tools/ (description, zod input
schema, annotations, handler together); src/tools.ts assembles them
into the exported WORKFLOW_TOOLS surface.
The CLI surface is shaped for a human at a terminal who already knows
the verbs. An MCP is consumed by an LLM that figures out which tool
to use from prose alone. Adding tools beyond the minimum increases
the search space the LLM has to navigate. We stay narrow until the
evals tell us the LLM is reaching for something we don't expose. The
authoring trio earns its place because authoring-from-a-description is
eval-backed; the agent generates the definitions, self-corrects against
workflows_validate_definition, then publishes with
workflows_deploy_definition — safe to hand an agent because deploy is
create-only: it can never patch or clobber what's already deployed, and
running instances keep the definition version they started under.
workflows_get_definition closes the loop that create-only would
otherwise leave open — iterating on a deployed workflow means reading
it back, modifying, and deploying the next version — and
workflows_start is the lifecycle entry point the catalogue's
startable flag advertises.
In development (TypeScript sources via tsx):
pnpm --filter @sanity/workflow-mcp dev
Or run the built bin — the entry an MCP client spawns. After pnpm build
it's bin/run.js, and once the package is published this is what
npx @sanity/workflow-mcp resolves to:
node packages/workflow-mcp/bin/run.js
Both share the one boot path in src/stdio.ts. The server is org-authed,
so boot config is just the org-level SANITY_AUTH_TOKEN (required) and
SANITY_API_HOST (optional) — no project, dataset, resource, or tag.
Every instance-operating tool call names its own workflow environment
instead. The address is the host's to name, not the tool's: this server
spells it as a workflow_resource parameter (a resource-shaped GDR,
<type>:<id> — e.g. dataset:abc123.production, media-library:mlXyz)
plus a tag, and registerWorkflowTools merges those two into the
advertised schema of every def that declares requiresAddress. There is
no default environment — a call without an address is a validation error,
never a guess — and the host builds one engine per distinct
(resource, tag), cached for the life of the process. The dev script
loads the env from the repo-root .env, so copy .env.example to .env
to get started.
MCP-built engines declare no foreign resources, so a runtime-supplied
doc.ref to any resource other than the addressed workflow resource is
rejected at the write (RefResourceUndeclaredError) — deliberate: an
agent-driven write pointing a workflow at an undeclared resource is
exactly the misdirection the engine's written-ref gate exists to catch.
Definition content is unaffected (deploy expands and vets it).
The stdio server reports adoption telemetry to Sanity. Nothing is sent unless
the account's telemetry consent resolves to granted (the account-wide status
sanity telemetry status shows). Set DO_NOT_TRACK=1 to opt a process out;
CI environments (CI set) never send. The org-authed boot has no project to
attribute intake to, so the store initializes with the first call that names
a dataset resource; calls before that one send nothing. Address-free tools
(workflows_get_authoring_guide, workflows_validate_definition) therefore
emit nothing until some earlier call has addressed a dataset.
Payloads never include customer-authored strings: no tool arguments, cursor
values, results, definition names, stage names, GROQ, or document content.
The one instance-scoped exception the engine already ships is instanceId
(the instance document _id).
Separately from consent, every Content Lake request the server issues
carries a request tag under the sanity.workflows-mcp prefix (composing as
sanity.workflows-mcp.<op>) for request-log attribution — deliberately outside
the zero-rated sanity.workflows.* family, so agent-driven traffic bills.
Each event includes context.surface: 'mcp' and its process execution mode
in context.environment. NODE_ENV=production reports production;
development and test report development. Unset, empty, and unrecognized
values default to production for installed CLI and MCP runs. The SDK reports
build mode and defaults to development instead.
When comparing activity across surfaces, group or filter by context.surface
alongside context.environment. Environment does not identify a production
dataset or deployment; the API host, dataset name, and workflow tag do not
set it. Existing telemetry consent and opt-out settings still apply.
An embedding host records no Workflows telemetry unless it passes a logger.
Create one per MCP session with createMcpSessionTelemetry, the same builder
the stdio server uses, and pass its logger to createWorkflowEngine for
engine events, and to registerWorkflowTools for MCP events if you use it:
import {createMcpSessionTelemetry, createWorkflowEngine} from '@sanity/workflow-mcp'
// Once per MCP session. `denied` is your consent decision.
const telemetry = createMcpSessionTelemetry({
clientFor: (resource) => clientForResource(resource),
denied: !telemetryEnabled,
})
// In each tool call, before the engine runs:
telemetry.bind(address.workflowResource)
const engine = createWorkflowEngine({address, client, telemetry: telemetry.logger})
// When the session closes:
await telemetry.flush()
telemetry.end()
This fragment assumes your host already has clientForResource, which returns
an authenticated client for a resource, and a telemetryEnabled consent flag,
and that address and client are the tool call's workflow address and client.
The session attaches to the first dataset resource passed to bind, as the
stdio server does. Nothing is sent when denied is true.
The session telemetry attaches these user properties once, when the first
dataset resource is bound (joined to events by session id). User identity is not
in the payload; the intake service resolves the sender from the authenticated
session. There is no orgId: attribution uses the project-to-org join
downstream.
| Property | What it is |
|---|---|
surface |
Always 'mcp'. |
machinePlatform |
Node process.platform. |
cpuArchitecture |
Node process.arch. |
runtime |
The detected JavaScript runtime, such as 'node'. |
runtimeVersion |
Node process.version. |
packageVersion |
This package's version. |
projectId |
Project id of the first addressed dataset resource. |
dataset |
Dataset of that same resource. |
This package defines one event. Every tool it registers — the WORKFLOW_TOOLS
defs and host-registered workflows_list_tags — shares one outcome path, so
adoption, the result envelope, and error rendering cannot diverge.
Workflows MCP Tool Called (version 2)One event per tool invocation after the handler settles, including failed
calls that return an isError result.
| Field | Type | What is collected |
|---|---|---|
tool |
string |
Registered tool name (workflows_list_instances, workflows_fire_action, …). The MCP SDK rejects unregistered names before dispatch. |
success |
boolean |
true when the handler returned; false when it threw (rendered as isError). |
cursorUsed |
boolean |
true only for workflows_list_instances when cursor is a non-empty string. Always false for every other tool. The cursor value is never sent. |
Addressed write tools pass the MCP logger into the engine. Read tools and address-free tools do not emit engine events.
Every engine event includes executionRuntime (the JavaScript runtime the
engine detected around itself, always 'node' for the stdio server) and
executionKind (the host the engine was told it is running in, always
'mcp'). Every instance-scoped engine event additionally includes
definitionContentHash (omitted when the definition predates fingerprinting)
and instanceId.
| Event | Version | When the MCP server emits it | Additional payload |
|---|---|---|---|
Workflows Engine Definition Deployed |
2 | workflows_deploy_definition (created or unchanged) |
contentHash, status ('created' or 'unchanged'), deployId (random run id, not the workflow tag), stageCount, activityCount, actionCount, transitionCount, fieldCount, activityKinds, fieldKinds, guardCount, effectCount, subworkflowCount, lifecycle ('standalone' or 'child'). Counts and kinds only — never names or GROQ. |
Workflows Engine Instance Started |
2 | workflows_start, and a fire that spawns a child |
initialFieldCount, viaSpawn (true only for a spawned child), lifecycle. |
Workflows Engine Stage Transitioned |
2 | A committed hop from start or fire (cascades included) | fromStageIndex, toStageIndex (indexes into stages[], never names), toIsTerminal, isRevisit, dwellMs (milliseconds in the exited stage, omitted if that entry is missing), via: 'transition'. Unsampled. |
Workflows Engine Action Fired |
2 | workflows_fire_action |
activityKind (optional), hasParams (whether params were supplied — not the params), cascaded. |
This MCP surface has no abort, set-stage, reset-activity, delete, tick, drain,
or generic field-edit tools, so it does not record
Workflows Engine Instance Aborted, Workflows Engine Stage Set,
Workflows Engine Activity Reset, Workflows Engine Definition Deleted,
Workflows Engine Field Edited, Workflows Engine Instance Ticked,
Workflows Engine Effects Drained, Workflows Engine Effect Completed, or
Workflows Engine Effect State Reported. Those remain engine events for shells
that call those verbs.
The package is a host-neutral library: it never builds an engine, reads the environment, or handles auth. A host supplies those per call through a context thunk — that's what lets one core serve both a boot-time stdio process and a hosted per-request server.
A tool def carries only its own parameters and a requiresAddress flag;
naming the environment is the host's job. Register everything with the
bundled glue and you get this server's spelling of the address. The
getContext seam receives the request extra (a hosted server derives
auth from it) and the parsed tool input, which carries that address —
workflowAddressFromInput extracts it so the host can build (or reuse)
the engine for that environment:
import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'
import {createEngine, resourceGdr, type Engine} from '@sanity/workflow-engine'
import {registerWorkflowTools, workflowAddressFromInput} from '@sanity/workflow-mcp'
const server = new McpServer({name: 'my-host', version: '1.0.0'})
// One engine per addressed environment, reused across calls.
// clientFor(resource): your host's @sanity/client for that resource —
// the bundled stdio host's version lives in `src/engine-cache.ts`.
const engines = new Map<string, Engine>()
registerWorkflowTools(server, (_extra, input) => {
const {workflowResource, tag} = workflowAddressFromInput(input)
const key = `${resourceGdr(workflowResource)} ${tag}`
const engine =
engines.get(key) ?? createEngine({client: clientFor(workflowResource), workflowResource, tag})
engines.set(key, engine)
return {engine}
})
Hosts with their own wrappers (error handling, telemetry, response
envelopes) iterate the raw defs instead, merging their own base
parameters — including their own spelling of the environment address —
into each def that declares requiresAddress. Non-MCP consumers derive
the JSON-schema descriptor from each def's zod shape:
import {toolInputJsonSchema, WORKFLOW_TOOLS} from '@sanity/workflow-mcp'
for (const def of WORKFLOW_TOOLS) {
// def.name, def.description, def.inputSchema (zod), def.annotations
// def.requiresAddress → whether to merge your address params into the shape
// def.handler(contextThunk, input) → plain projected data
// toolInputJsonSchema(def) → `input_schema` for the Anthropic Messages API
}
A handler's context is {engine} — identity is the token behind the
engine's client. The thunk is lazy: the guide and validate tools never
invoke it, so a per-request host never builds an engine for them.
Every inputSchema this package exports is built with zod v3, imported
through the explicit zod/v3 subpath. zod is a peer dependency
(^3.25.28 || ^4) — both majors ship that subpath, so either satisfies it,
and declaring it as a peer lets the host pick the copy instead of us pinning
one into its tree.
A host that merges its own base parameters into a tool's shape must build them with the same major:
// Correct — a v3 base merged into our v3 shape.
import {z} from 'zod/v3'
const baseParams = {intent: z.string().describe('Why you are calling this tool.')}
server.registerTool(
def.name,
{description: def.description, inputSchema: {...baseParams, ...def.inputSchema}},
handler,
)
The MCP SDK decides how to drive a shape by duck-typing each field (only v4
schemas carry _zod), and it refuses a shape that isn't uniformly one major
— objectFromShape throws Mixed Zod versions detected in object shape. A
v4 base merged into these shapes therefore fails at registration, not at
call time.
Only the major has to match, not the installation: the SDK re-wraps the
fields it detects with the zod/v3 (or zod/v4-mini) module it resolved
itself, so schemas built by a different copy of the same major work fine.
That is why a host's zod version never has to track ours.
workflows_list_tags enumerates the workflow
environments inside one resource, but nothing enumerates the resources
themselves — the agent (or its operator prompt) must arrive holding a
resource GDR. Cross-resource and org-wide enumeration stays out until
the evals demand it.set_stage tool. Admin override; intentionally omitted from
the LLM surface until we have an eval case that needs it (and a
story for the actor identity that would authorise the override).workflows_get_state covers the eval
cases.