Sanity Library Reference Docs
    Preparing search index...

    Module @sanity/workflow-mcp - v0.32.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 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.

    • 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