@sanity/workflow-mcp 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,105 @@
1
1
  # @sanity/workflow-mcp
2
2
 
3
+ ## 0.22.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [88ba4ba]
8
+ - @sanity/workflow-engine@0.22.0
9
+
10
+ ## 0.21.0
11
+
12
+ ### Minor Changes
13
+
14
+ - f4bc057: **BREAKING:** `list_workflow_instances` now returns one page with `has_more` and an optional `next_cursor`; callers that need every match must continue with the cursor. The optional `limit` accepts 1–100 rows and defaults to 25.
15
+
16
+ Bound instance and definition list reads with lake-side filtering and consumer-specific projections.
17
+
18
+ Document filtering now excludes exited-stage references and includes live unresolved child-workflow references. Definition discovery selects and model-gates only the latest deployed version of each workflow name; historical versions remain stored and available to history-oriented APIs.
19
+
20
+ The MCP eval suite now verifies that agents follow list cursors to find a target beyond the first default page, including in persisted Braintrust runs.
21
+
22
+ MCP tool telemetry reports a `cursorUsed` boolean so continued list-page adoption is measurable without sending cursor values, arguments, or results.
23
+
24
+ - bcc30fe: **BREAKING:** Definition deploys through the MCP tool acknowledge data model 4
25
+ (`expectedMinReaderModel: 4`): deploys assert a model-4-capable reader fleet
26
+ and require the model-4 engine.
27
+ - fa9c796: **BREAKING:** Replace the singular `start.allowed` and activity requirement record with ordered, named requirement arrays.
28
+
29
+ Start readiness now accepts polymorphic `groq` and `singleSubject` nodes:
30
+
31
+ ```ts
32
+ // Before
33
+ start: {allowed: '$fields.approved == true'}
34
+
35
+ // After
36
+ start: {
37
+ requirements: [
38
+ {type: 'groq', name: 'approved', title: 'Approval required', query: '$fields.approved == true'},
39
+ ],
40
+ }
41
+ ```
42
+
43
+ Use `singleSubject` instead of `$subjectHasInFlightInstance` to allow at most one in-flight run of the same definition for a subject. The requirement is definition-scoped and version-blind across deployments:
44
+
45
+ ```ts
46
+ // Before
47
+ start: {allowed: '!$subjectHasInFlightInstance'}
48
+
49
+ // After
50
+ start: {
51
+ requirements: [
52
+ {type: 'singleSubject', name: 'single-subject', description: 'Finish the existing run first.'},
53
+ ],
54
+ }
55
+ ```
56
+
57
+ Activity readiness uses the same ordered descriptor model with `groq` nodes:
58
+
59
+ ```ts
60
+ // Before
61
+ requirements: {
62
+ approved: 'defined($fields.approval)'
63
+ }
64
+
65
+ // After
66
+ requirements: [{type: 'groq', name: 'approved', query: 'defined($fields.approval)'}]
67
+ ```
68
+
69
+ Requirement names must be unique within their owning array. Evaluation preserves author order and reports every unmet requirement with its `name` and optional editor-facing `title` and `description`. Fresh standalone starts enforce the requirements after validating inputs. Resuming an unfinished start and parent-owned spawning continue to bypass start requirements.
70
+
71
+ `evaluateStart()` now returns ordered `requirements` entries containing each descriptor, outcome, and GROQ insight where applicable. Its singular top-level `insight` is removed. `StartNotAllowedError.insight` is replaced by `StartNotAllowedError.unmetRequirements`. Activity evaluation likewise reports unmet requirement descriptors instead of names alone.
72
+
73
+ Rename the public start-requirement analysis helpers and constants:
74
+
75
+ ```ts
76
+ // Before
77
+ explainStartAllowed(args)
78
+ unboundAllowedReads(query, fields)
79
+ START_ALLOWED_VARS
80
+
81
+ // After
82
+ explainStartRequirement(args)
83
+ unboundRequirementReads(query, fields)
84
+ START_REQUIREMENT_VARS
85
+ ```
86
+
87
+ `definitionsForDocument({document, subject})` becomes `definitionsForDocument({document})`. Start applicability no longer needs a separately supplied subject. The engine-owned start dataset projection exposes only `definition`, `subject`, and `completedAt`; authors no longer query raw instance storage for deduplication.
88
+
89
+ Studio pre-flights both requirement kinds against the live document session, disables starts whose requirements are unmet, and renders the authored description or title with a humanized requirement-name fallback. Controls re-enable on the same mount when a blocking run completes.
90
+
91
+ Persisted data model 4 unconditionally raises `minReaderModel` to 4 for new definitions and instances because older readers would ignore readiness arrays and could commit invalid transitions. Upgrade every reader and Function before deploying model-4 writers. Legacy deployed definitions are not normalized; prerelease environments with incompatible definitions may use `sanity-workflows nuke` before redeploying.
92
+
93
+ ### Patch Changes
94
+
95
+ - Updated dependencies [d9394e5]
96
+ - Updated dependencies [92e28bd]
97
+ - Updated dependencies [f4bc057]
98
+ - Updated dependencies [bcc30fe]
99
+ - Updated dependencies [e7392af]
100
+ - Updated dependencies [fa9c796]
101
+ - @sanity/workflow-engine@0.21.0
102
+
3
103
  ## 0.20.0
4
104
 
5
105
  ### Minor Changes
package/README.md CHANGED
@@ -15,7 +15,7 @@ Operate a running instance:
15
15
  | --------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
16
16
  | `list_workflow_definitions` | read | The catalogue: which workflow types are deployed (latest version each), and whether they're startable. |
17
17
  | `get_workflow_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). |
18
- | `list_workflow_instances` | read | Discover what's running. Filterable (`definition`, `document`, `include_completed` — in-flight by default), capped. |
18
+ | `list_workflow_instances` | read | Discover what's running. Filterable (`definition`, `document`, `include_completed` — in-flight by default), with configurable cursor pagination. |
19
19
  | `get_workflow_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. |
20
20
  | `diagnose_workflow` | 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. |
21
21
  | `start_workflow` | write | The lifecycle entry point: start a startable deployed definition, seeding its input-sourced fields (e.g. the subject document). |
@@ -94,8 +94,10 @@ Definition content is unaffected (deploy expands and vets it).
94
94
 
95
95
  The stdio server reports adoption telemetry to Sanity: one
96
96
  `Editorial Workflows MCP Tool Called` event per tool invocation carrying the
97
- tool name and a success
98
- flag — never tool arguments or results — plus the engine's own adoption
97
+ tool name, a success flag, and whether a list cursor was supplied. Cursor
98
+ values, other tool arguments, and results are never sent. This makes continued
99
+ page usage measurable without collecting customer content. The server also
100
+ reports the engine's own adoption
99
101
  events. Nothing is sent unless the account's telemetry consent resolves to
100
102
  granted (the account-wide status `sanity telemetry status` shows). Set
101
103
  `DO_NOT_TRACK=1` to opt a process out; CI environments (`CI` set) never
@@ -1,4 +1,4 @@
1
- import { createTelemetryIntake, isTelemetryEnvDenied, processShellUserProperties, errorMessage, validateTag, parseResourceGdr, parseDefinitionInput, extractDocumentId, parseDefinitionSnapshot, displayTitle, autonomySummary, actionRendering, isTerminalStage, describeCondition, actionVerdict, narrateAutonomyWaits, subjectDenialLabels, deniedGuardLabels, definitionLookupGroq, assertReadableModel, unsatisfiedTransitionSummaries, describeSite, definitionsListGroq, latestDeployedDefinitions, startKindOf, isStartableDefinition, instancesQuery, instanceWatchesDocument, parseGdr, startRefusal, buildInitialFields, StartNotPrimedError, StartNotSettledError, validateDefinition, WorkflowError } from "@sanity/workflow-engine";
1
+ import { createTelemetryIntake, isTelemetryEnvDenied, processShellUserProperties, errorMessage, validateTag, parseResourceGdr, parseDefinitionInput, extractDocumentId, parseDefinitionSnapshot, displayTitle, autonomySummary, actionRendering, isTerminalStage, describeCondition, actionVerdict, narrateAutonomyWaits, subjectDenialLabels, deniedGuardLabels, definitionLookupGroq, assertReadableModel, unsatisfiedTransitionSummaries, describeSite, latestDefinitionsGroq, startKindOf, isStartableDefinition, instancesQuery, instanceWatchesDocument, parseGdr, startRefusal, buildInitialFields, StartNotPrimedError, StartNotSettledError, validateDefinition, WorkflowError } from "@sanity/workflow-engine";
2
2
 
3
3
  import { defineEvent, createBatchedStore, createSessionId } from "@sanity/telemetry";
4
4
 
@@ -6,10 +6,12 @@ import { z } from "zod";
6
6
 
7
7
  import { defineWorkflow } from "@sanity/workflow-engine/define";
8
8
 
9
+ import { Buffer } from "node:buffer";
10
+
9
11
  const WorkflowMcpToolCalled = defineEvent({
10
12
  name: "Editorial Workflows MCP Tool Called",
11
- version: 1,
12
- description: "An MCP tool was invoked — payload is the tool name and a success flag only, never tool arguments or results"
13
+ version: 2,
14
+ description: "An MCP tool was invoked — payload is the tool name, success, and list-cursor usage; never arguments, cursor values, or results"
13
15
  });
14
16
 
15
17
  function createMcpTelemetry(args) {
@@ -120,7 +122,7 @@ const deployWorkflowDefinitionTool = defineWorkflowTool({
120
122
  if (problems.length > 0) throw new Error(problems.join(`\n`));
121
123
  const {engine: engine} = await context();
122
124
  return engine.deployDefinitions({
123
- expectedMinReaderModel: 2,
125
+ expectedMinReaderModel: 4,
124
126
  definitions: stored
125
127
  });
126
128
  }
@@ -332,7 +334,7 @@ async function fetchDeployedDefinition({engine: engine, definition: definition,
332
334
  }
333
335
 
334
336
  function isInstanceDone(instance) {
335
- if (instance.completedAt !== void 0) return !0;
337
+ if (instance.completedAt != null) return !0;
336
338
  try {
337
339
  const stage = parseDefinitionSnapshot(instance).stages.find(s => s.name === instance.currentStage);
338
340
  return stage !== void 0 && isTerminalStage(stage);
@@ -717,11 +719,11 @@ const instanceIdField = z.string().min(1).describe("The workflow instance id."),
717
719
  readOnlyHint: !0
718
720
  },
719
721
  run: async context => {
720
- const {engine: engine} = await context(), deployed = (await engine.query({
721
- groq: definitionsListGroq("desc")
722
- })).map(assertReadableModel);
722
+ const {engine: engine} = await context();
723
723
  return {
724
- definitions: latestDeployedDefinitions(deployed).map(def => ({
724
+ definitions: (await engine.query({
725
+ groq: latestDefinitionsGroq()
726
+ })).map(assertReadableModel).map(def => ({
725
727
  name: def.name,
726
728
  title: def.title,
727
729
  ...def.description !== void 0 ? {
@@ -733,14 +735,44 @@ const instanceIdField = z.string().min(1).describe("The workflow instance id."),
733
735
  }))
734
736
  };
735
737
  }
736
- }), LIST_CAP = 25, listWorkflowInstancesTool = defineWorkflowTool({
738
+ }), DEFAULT_LIST_LIMIT = 25, MAX_LIST_LIMIT = 100, cursorPayloadSchema = z.object({
739
+ version: z.literal(1),
740
+ lastChangedAt: z.string(),
741
+ instanceId: z.string(),
742
+ scope: z.string()
743
+ });
744
+
745
+ function cursorScope(input) {
746
+ return JSON.stringify([ input.workflow_resource, input.tag, input.definition ?? null, input.document ?? null, input.include_completed ]);
747
+ }
748
+
749
+ function encodeCursor(payload) {
750
+ return Buffer.from(JSON.stringify(payload)).toString("base64url");
751
+ }
752
+
753
+ function decodeCursor(cursor, expectedScope) {
754
+ let decoded;
755
+ try {
756
+ decoded = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
757
+ } catch {
758
+ throw new Error("list_workflow_instances: invalid cursor");
759
+ }
760
+ const parsed = cursorPayloadSchema.safeParse(decoded);
761
+ if (!parsed.success) throw new Error("list_workflow_instances: invalid cursor");
762
+ if (parsed.data.scope !== expectedScope) throw new Error("list_workflow_instances: cursor does not match the current filters");
763
+ return parsed.data;
764
+ }
765
+
766
+ const SUMMARY_PROJECTION = `{\n _type,\n _id,\n modelVersion,\n minReaderModel,\n workflowResource,\n definition,\n definitionSnapshot,\n fields,\n ancestors,\n subworkflows,\n stages,\n currentStage,\n lastChangedAt,\n completedAt,\n abortedAt\n}`, listWorkflowInstancesTool = defineWorkflowTool({
737
767
  name: "list_workflow_instances",
738
- description: `List workflow instances in one workflow environment. Use this when you need to find a workflow but don't already know its instance id, or to survey what's in flight. Returns a compact summary — id, \`definition\` (the workflow definition's \`name\`) and human-readable \`workflowTitle\`, current stage, whether the instance is done, and (when the workflow declares a subject document) a \`subject\` field with the subject doc's ref and title. Use \`workflowTitle\` when the user names the workflow by type (e.g. "article reviews") and \`subject.title\` when they name a specific in-flight instance by what it's about (e.g. "the article-review about pricing"). Capped at ${LIST_CAP} results — the most recently changed matches; every filter applies before the cap. Do NOT use this to inspect a single known instance — use get_workflow_state for that, the response will be richer. ` + UNTRUSTED_AUTHORED_DATA_NOTE,
768
+ description: `List workflow instances in one workflow environment. Use this when you need to find a workflow but don't already know its instance id, or to survey what's in flight. Returns a compact summary — id, \`definition\` (the workflow definition's \`name\`) and human-readable \`workflowTitle\`, current stage, whether the instance is done, and (when the workflow declares a subject document) a \`subject\` field with the subject doc's ref and title. Use \`workflowTitle\` when the user names the workflow by type (e.g. "article reviews") and \`subject.title\` when they name a specific in-flight instance by what it's about (e.g. "the article-review about pricing"). Returns up to ${DEFAULT_LIST_LIMIT} results per page by default, ordered by most recently changed; set \`limit\` up to ${MAX_LIST_LIMIT}. Defensive document verification can leave a page underfilled. When \`has_more\` is true, call this tool again with the same filters and \`next_cursor\` as \`cursor\`; never claim the list is complete until \`has_more\` is false. Do NOT use this to inspect a single known instance — use get_workflow_state for that, the response will be richer. ` + UNTRUSTED_AUTHORED_DATA_NOTE,
739
769
  inputSchema: {
740
770
  ...workflowAddressFields,
741
771
  definition: z.string().describe("Optional. Restrict to instances of this workflow definition, by its `name` (e.g. 'article-review').").optional(),
742
772
  document: z.string().describe(`Optional. Only instances that reference this document — the workflow's subject or any other doc its fields point at — as a resource-qualified GDR URI (e.g. "dataset:proj:ds:article-1"). Use this to answer "which workflows are about this document?".`).superRefine(zodCheck(parseGdr)).optional(),
743
- include_completed: z.boolean().describe("Optional. `true` includes completed/aborted instances. Defaults to false — in-flight instances only.").default(!1)
773
+ include_completed: z.boolean().describe("Optional. `true` includes completed/aborted instances. Defaults to false — in-flight instances only.").default(!1),
774
+ limit: z.number().int().min(1).max(MAX_LIST_LIMIT).describe(`Optional. Results per page, from 1 to ${MAX_LIST_LIMIT}. Defaults to ${DEFAULT_LIST_LIMIT}.`).default(DEFAULT_LIST_LIMIT),
775
+ cursor: z.string().describe("Optional. Opaque `next_cursor` from the previous response. Reuse the same address and filters when continuing.").optional()
744
776
  },
745
777
  annotations: {
746
778
  readOnlyHint: !0
@@ -757,12 +789,28 @@ const instanceIdField = z.string().min(1).describe("The workflow instance id."),
757
789
  }, {query: query, params: params} = instancesQuery({
758
790
  tag: engine.tag,
759
791
  filter: filter
760
- }), recency = `${query} | order(lastChangedAt desc)`, docs = (await engine.query({
761
- groq: document === void 0 ? `${recency}[0...${LIST_CAP}]` : recency,
762
- params: params
763
- })).map(assertReadableModel), capped = (document === void 0 ? docs : docs.filter(doc => instanceWatchesDocument(doc, document))).slice(0, LIST_CAP), subjectTitles = await fetchSubjectTitles(engine, capped);
792
+ }), scope = cursorScope(input), cursor = input.cursor === void 0 ? void 0 : decodeCursor(input.cursor, scope), recency = `${query}${cursor === void 0 ? "" : "[lastChangedAt < $cursorLastChangedAt || (lastChangedAt == $cursorLastChangedAt && _id > $cursorInstanceId)]"} | order(lastChangedAt desc, _id asc)`, docs = (await engine.query({
793
+ groq: `${recency}[0...$pageSize]${SUMMARY_PROJECTION}`,
794
+ params: {
795
+ ...params,
796
+ pageSize: input.limit + 1,
797
+ ...cursor === void 0 ? {} : {
798
+ cursorLastChangedAt: cursor.lastChangedAt,
799
+ cursorInstanceId: cursor.instanceId
800
+ }
801
+ }
802
+ })).map(assertReadableModel), rawPage = docs.slice(0, input.limit), page = document === void 0 ? rawPage : rawPage.filter(doc => instanceWatchesDocument(doc, document)), hasMore = docs.length > input.limit, subjectTitles = await fetchSubjectTitles(engine, page), boundary = rawPage.at(-1);
764
803
  return {
765
- instances: capped.map(doc => projectSummary(doc, subjectTitles))
804
+ instances: page.map(doc => projectSummary(doc, subjectTitles)),
805
+ has_more: hasMore,
806
+ ...hasMore && boundary !== void 0 ? {
807
+ next_cursor: encodeCursor({
808
+ version: 1,
809
+ lastChangedAt: boundary.lastChangedAt,
810
+ instanceId: boundary._id,
811
+ scope: scope
812
+ })
813
+ } : {}
766
814
  };
767
815
  }
768
816
  });
@@ -884,10 +932,11 @@ function registerWorkflowTools(server, getContext, options) {
884
932
  }
885
933
 
886
934
  async function runToolHandler({def: def, context: context, input: input, telemetry: telemetry}) {
887
- const logCalled = success => {
935
+ const cursorUsed = listCursorWasSupplied(def.name, input), logCalled = success => {
888
936
  telemetry?.log(WorkflowMcpToolCalled, {
889
937
  tool: def.name,
890
- success: success
938
+ success: success,
939
+ cursorUsed: cursorUsed
891
940
  });
892
941
  };
893
942
  try {
@@ -911,4 +960,8 @@ async function runToolHandler({def: def, context: context, input: input, telemet
911
960
  }
912
961
  }
913
962
 
963
+ function listCursorWasSupplied(toolName, input) {
964
+ return toolName !== "list_workflow_instances" || typeof input != "object" || input === null ? !1 : "cursor" in input && typeof input.cursor == "string" && input.cursor.length > 0;
965
+ }
966
+
914
967
  export { WORKFLOW_TOOLS, createMcpTelemetry, registerWorkflowTools, toolInputJsonSchema, workflowAddressFromInput };
package/dist/index.cjs CHANGED
@@ -4,12 +4,12 @@ Object.defineProperty(exports, "__esModule", {
4
4
  value: !0
5
5
  });
6
6
 
7
- var workflowEngine = require("@sanity/workflow-engine"), telemetry = require("@sanity/telemetry"), zod = require("zod"), define = require("@sanity/workflow-engine/define");
7
+ var workflowEngine = require("@sanity/workflow-engine"), telemetry = require("@sanity/telemetry"), zod = require("zod"), define = require("@sanity/workflow-engine/define"), node_buffer = require("node:buffer");
8
8
 
9
9
  const WorkflowMcpToolCalled = telemetry.defineEvent({
10
10
  name: "Editorial Workflows MCP Tool Called",
11
- version: 1,
12
- description: "An MCP tool was invoked — payload is the tool name and a success flag only, never tool arguments or results"
11
+ version: 2,
12
+ description: "An MCP tool was invoked — payload is the tool name, success, and list-cursor usage; never arguments, cursor values, or results"
13
13
  });
14
14
 
15
15
  function zodCheck(validate) {
@@ -103,7 +103,7 @@ const deployWorkflowDefinitionTool = defineWorkflowTool({
103
103
  if (problems.length > 0) throw new Error(problems.join(`\n`));
104
104
  const {engine: engine} = await context();
105
105
  return engine.deployDefinitions({
106
- expectedMinReaderModel: 2,
106
+ expectedMinReaderModel: 4,
107
107
  definitions: stored
108
108
  });
109
109
  }
@@ -315,7 +315,7 @@ async function fetchDeployedDefinition({engine: engine, definition: definition,
315
315
  }
316
316
 
317
317
  function isInstanceDone(instance) {
318
- if (instance.completedAt !== void 0) return !0;
318
+ if (instance.completedAt != null) return !0;
319
319
  try {
320
320
  const stage = workflowEngine.parseDefinitionSnapshot(instance).stages.find(s => s.name === instance.currentStage);
321
321
  return stage !== void 0 && workflowEngine.isTerminalStage(stage);
@@ -700,11 +700,11 @@ const instanceIdField = zod.z.string().min(1).describe("The workflow instance id
700
700
  readOnlyHint: !0
701
701
  },
702
702
  run: async context => {
703
- const {engine: engine} = await context(), deployed = (await engine.query({
704
- groq: workflowEngine.definitionsListGroq("desc")
705
- })).map(workflowEngine.assertReadableModel);
703
+ const {engine: engine} = await context();
706
704
  return {
707
- definitions: workflowEngine.latestDeployedDefinitions(deployed).map(def => ({
705
+ definitions: (await engine.query({
706
+ groq: workflowEngine.latestDefinitionsGroq()
707
+ })).map(workflowEngine.assertReadableModel).map(def => ({
708
708
  name: def.name,
709
709
  title: def.title,
710
710
  ...def.description !== void 0 ? {
@@ -716,14 +716,44 @@ const instanceIdField = zod.z.string().min(1).describe("The workflow instance id
716
716
  }))
717
717
  };
718
718
  }
719
- }), LIST_CAP = 25, listWorkflowInstancesTool = defineWorkflowTool({
719
+ }), DEFAULT_LIST_LIMIT = 25, MAX_LIST_LIMIT = 100, cursorPayloadSchema = zod.z.object({
720
+ version: zod.z.literal(1),
721
+ lastChangedAt: zod.z.string(),
722
+ instanceId: zod.z.string(),
723
+ scope: zod.z.string()
724
+ });
725
+
726
+ function cursorScope(input) {
727
+ return JSON.stringify([ input.workflow_resource, input.tag, input.definition ?? null, input.document ?? null, input.include_completed ]);
728
+ }
729
+
730
+ function encodeCursor(payload) {
731
+ return node_buffer.Buffer.from(JSON.stringify(payload)).toString("base64url");
732
+ }
733
+
734
+ function decodeCursor(cursor, expectedScope) {
735
+ let decoded;
736
+ try {
737
+ decoded = JSON.parse(node_buffer.Buffer.from(cursor, "base64url").toString("utf8"));
738
+ } catch {
739
+ throw new Error("list_workflow_instances: invalid cursor");
740
+ }
741
+ const parsed = cursorPayloadSchema.safeParse(decoded);
742
+ if (!parsed.success) throw new Error("list_workflow_instances: invalid cursor");
743
+ if (parsed.data.scope !== expectedScope) throw new Error("list_workflow_instances: cursor does not match the current filters");
744
+ return parsed.data;
745
+ }
746
+
747
+ const SUMMARY_PROJECTION = `{\n _type,\n _id,\n modelVersion,\n minReaderModel,\n workflowResource,\n definition,\n definitionSnapshot,\n fields,\n ancestors,\n subworkflows,\n stages,\n currentStage,\n lastChangedAt,\n completedAt,\n abortedAt\n}`, listWorkflowInstancesTool = defineWorkflowTool({
720
748
  name: "list_workflow_instances",
721
- description: `List workflow instances in one workflow environment. Use this when you need to find a workflow but don't already know its instance id, or to survey what's in flight. Returns a compact summary — id, \`definition\` (the workflow definition's \`name\`) and human-readable \`workflowTitle\`, current stage, whether the instance is done, and (when the workflow declares a subject document) a \`subject\` field with the subject doc's ref and title. Use \`workflowTitle\` when the user names the workflow by type (e.g. "article reviews") and \`subject.title\` when they name a specific in-flight instance by what it's about (e.g. "the article-review about pricing"). Capped at ${LIST_CAP} results — the most recently changed matches; every filter applies before the cap. Do NOT use this to inspect a single known instance — use get_workflow_state for that, the response will be richer. ` + UNTRUSTED_AUTHORED_DATA_NOTE,
749
+ description: `List workflow instances in one workflow environment. Use this when you need to find a workflow but don't already know its instance id, or to survey what's in flight. Returns a compact summary — id, \`definition\` (the workflow definition's \`name\`) and human-readable \`workflowTitle\`, current stage, whether the instance is done, and (when the workflow declares a subject document) a \`subject\` field with the subject doc's ref and title. Use \`workflowTitle\` when the user names the workflow by type (e.g. "article reviews") and \`subject.title\` when they name a specific in-flight instance by what it's about (e.g. "the article-review about pricing"). Returns up to ${DEFAULT_LIST_LIMIT} results per page by default, ordered by most recently changed; set \`limit\` up to ${MAX_LIST_LIMIT}. Defensive document verification can leave a page underfilled. When \`has_more\` is true, call this tool again with the same filters and \`next_cursor\` as \`cursor\`; never claim the list is complete until \`has_more\` is false. Do NOT use this to inspect a single known instance — use get_workflow_state for that, the response will be richer. ` + UNTRUSTED_AUTHORED_DATA_NOTE,
722
750
  inputSchema: {
723
751
  ...workflowAddressFields,
724
752
  definition: zod.z.string().describe("Optional. Restrict to instances of this workflow definition, by its `name` (e.g. 'article-review').").optional(),
725
753
  document: zod.z.string().describe(`Optional. Only instances that reference this document — the workflow's subject or any other doc its fields point at — as a resource-qualified GDR URI (e.g. "dataset:proj:ds:article-1"). Use this to answer "which workflows are about this document?".`).superRefine(zodCheck(workflowEngine.parseGdr)).optional(),
726
- include_completed: zod.z.boolean().describe("Optional. `true` includes completed/aborted instances. Defaults to false — in-flight instances only.").default(!1)
754
+ include_completed: zod.z.boolean().describe("Optional. `true` includes completed/aborted instances. Defaults to false — in-flight instances only.").default(!1),
755
+ limit: zod.z.number().int().min(1).max(MAX_LIST_LIMIT).describe(`Optional. Results per page, from 1 to ${MAX_LIST_LIMIT}. Defaults to ${DEFAULT_LIST_LIMIT}.`).default(DEFAULT_LIST_LIMIT),
756
+ cursor: zod.z.string().describe("Optional. Opaque `next_cursor` from the previous response. Reuse the same address and filters when continuing.").optional()
727
757
  },
728
758
  annotations: {
729
759
  readOnlyHint: !0
@@ -740,12 +770,28 @@ const instanceIdField = zod.z.string().min(1).describe("The workflow instance id
740
770
  }, {query: query, params: params} = workflowEngine.instancesQuery({
741
771
  tag: engine.tag,
742
772
  filter: filter
743
- }), recency = `${query} | order(lastChangedAt desc)`, docs = (await engine.query({
744
- groq: document === void 0 ? `${recency}[0...${LIST_CAP}]` : recency,
745
- params: params
746
- })).map(workflowEngine.assertReadableModel), capped = (document === void 0 ? docs : docs.filter(doc => workflowEngine.instanceWatchesDocument(doc, document))).slice(0, LIST_CAP), subjectTitles = await fetchSubjectTitles(engine, capped);
773
+ }), scope = cursorScope(input), cursor = input.cursor === void 0 ? void 0 : decodeCursor(input.cursor, scope), recency = `${query}${cursor === void 0 ? "" : "[lastChangedAt < $cursorLastChangedAt || (lastChangedAt == $cursorLastChangedAt && _id > $cursorInstanceId)]"} | order(lastChangedAt desc, _id asc)`, docs = (await engine.query({
774
+ groq: `${recency}[0...$pageSize]${SUMMARY_PROJECTION}`,
775
+ params: {
776
+ ...params,
777
+ pageSize: input.limit + 1,
778
+ ...cursor === void 0 ? {} : {
779
+ cursorLastChangedAt: cursor.lastChangedAt,
780
+ cursorInstanceId: cursor.instanceId
781
+ }
782
+ }
783
+ })).map(workflowEngine.assertReadableModel), rawPage = docs.slice(0, input.limit), page = document === void 0 ? rawPage : rawPage.filter(doc => workflowEngine.instanceWatchesDocument(doc, document)), hasMore = docs.length > input.limit, subjectTitles = await fetchSubjectTitles(engine, page), boundary = rawPage.at(-1);
747
784
  return {
748
- instances: capped.map(doc => projectSummary(doc, subjectTitles))
785
+ instances: page.map(doc => projectSummary(doc, subjectTitles)),
786
+ has_more: hasMore,
787
+ ...hasMore && boundary !== void 0 ? {
788
+ next_cursor: encodeCursor({
789
+ version: 1,
790
+ lastChangedAt: boundary.lastChangedAt,
791
+ instanceId: boundary._id,
792
+ scope: scope
793
+ })
794
+ } : {}
749
795
  };
750
796
  }
751
797
  });
@@ -867,10 +913,11 @@ function registerWorkflowTools(server, getContext, options) {
867
913
  }
868
914
 
869
915
  async function runToolHandler({def: def, context: context, input: input, telemetry: telemetry2}) {
870
- const logCalled = success => {
916
+ const cursorUsed = listCursorWasSupplied(def.name, input), logCalled = success => {
871
917
  telemetry2?.log(WorkflowMcpToolCalled, {
872
918
  tool: def.name,
873
- success: success
919
+ success: success,
920
+ cursorUsed: cursorUsed
874
921
  });
875
922
  };
876
923
  try {
@@ -894,6 +941,10 @@ async function runToolHandler({def: def, context: context, input: input, telemet
894
941
  }
895
942
  }
896
943
 
944
+ function listCursorWasSupplied(toolName, input) {
945
+ return toolName !== "list_workflow_instances" || typeof input != "object" || input === null ? !1 : "cursor" in input && typeof input.cursor == "string" && input.cursor.length > 0;
946
+ }
947
+
897
948
  exports.WORKFLOW_TOOLS = WORKFLOW_TOOLS;
898
949
 
899
950
  exports.registerWorkflowTools = registerWorkflowTools;
package/dist/stdio.js CHANGED
@@ -8,7 +8,7 @@ import { registerWorkflowTools, workflowAddressFromInput, createMcpTelemetry } f
8
8
 
9
9
  import { createClient } from "@sanity/client";
10
10
 
11
- var version = "0.20.0", packageJson = {
11
+ var version = "0.22.0", packageJson = {
12
12
  version: version
13
13
  };
14
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/workflow-mcp",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "MCP server exposing Sanity workflow tools to agents — operate running workflow instances and author new definitions.",
5
5
  "keywords": [
6
6
  "agent",
@@ -61,12 +61,12 @@
61
61
  "@sanity/pkg-utils": "^10.5.2",
62
62
  "@types/node": "^24.12.4",
63
63
  "vitest": "^4.1.8",
64
- "@sanity/workflow-engine": "0.20.0",
65
- "@sanity/workflow-engine-test": "0.20.0",
66
- "@sanity/workflow-examples": "0.9.0"
64
+ "@sanity/workflow-engine": "0.22.0",
65
+ "@sanity/workflow-engine-test": "0.22.0",
66
+ "@sanity/workflow-examples": "0.10.1"
67
67
  },
68
68
  "peerDependencies": {
69
- "@sanity/workflow-engine": "0.20.0"
69
+ "@sanity/workflow-engine": "0.22.0"
70
70
  },
71
71
  "engines": {
72
72
  "node": ">=20"