Sanity Library Reference Docs
    Preparing search index...

    Module @sanity/workflow-mcp - v0.36.0

    @sanity/workflow-mcp

    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.

    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.

    • No resource discovery. 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.
    • No 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).
    • No effect completion. Effects are queued by the engine and drained by the runtime, not by a human or LLM.
    • No subscription / streaming. MCP supports it; we haven't needed it yet. Pull-based polling via workflows_get_state covers the eval cases.

    Modules

    index
    stdio