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 the parents that spawn them). Create-only and content-addressed: identical content is an unchanged no-op, any change mints the next 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: one
Workflows MCP Tool Called event per tool invocation carrying the
tool name, a success flag, and whether a list cursor was supplied — from every
tool it registers, workflows_list_tags included, through one shared outcome
path. Cursor
values, other tool arguments, and results are never sent. This makes continued
page usage measurable without collecting customer content. 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. The
server also reports the engine's own adoption
events. 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. 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.
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.