@sanity/workflow-mcp 0.3.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/LICENSE +21 -0
- package/README.md +97 -0
- package/bin/run.js +9 -0
- package/dist/index.cjs +455 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +248 -0
- package/dist/index.d.ts +248 -0
- package/dist/index.js +456 -0
- package/dist/index.js.map +1 -0
- package/dist/stdio.d.ts +24 -0
- package/dist/stdio.js +70 -0
- package/dist/stdio.js.map +1 -0
- package/package.json +75 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sanity, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# @sanity/workflow-mcp
|
|
2
|
+
|
|
3
|
+
MCP tool surface for the workflows engine. Two jobs, no fluff: operate running
|
|
4
|
+
instances, and author new definitions.
|
|
5
|
+
|
|
6
|
+
> [!WARNING]
|
|
7
|
+
> POC. The eval harness in [`@sanity/workflow-mcp-evals`](../workflow-mcp-evals)
|
|
8
|
+
> is the load-bearing artifact for now — this package exposes the tools
|
|
9
|
+
> the evals drive, plus a stdio MCP entry point for real MCP clients.
|
|
10
|
+
|
|
11
|
+
## What the tools do
|
|
12
|
+
|
|
13
|
+
Operate a running instance:
|
|
14
|
+
|
|
15
|
+
| Tool | Read or write | What it's for |
|
|
16
|
+
| ------------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
|
17
|
+
| `list_workflow_instances` | read | Discover what's running. Filterable, capped. |
|
|
18
|
+
| `get_workflow_state` | read | Project one instance into a flat shape: current stage, tasks, available actions, recent history. |
|
|
19
|
+
| `diagnose_workflow` | read | Explain why an instance is or isn't progressing: a verdict, a one-line summary, and — when stuck — the cause plus suggested remediations. |
|
|
20
|
+
| `fire_action` | write | Advance state. The only write. Mirrors the engine's universal "something happened" entry point. |
|
|
21
|
+
|
|
22
|
+
Author a definition (both pure, engine-independent, neither deploys):
|
|
23
|
+
|
|
24
|
+
| Tool | Read or write | What it's for |
|
|
25
|
+
| ------------------------------ | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
26
|
+
| `get_workflow_authoring_guide` | read | Return the DSL guide an agent reads before authoring — shape, GROQ built-ins, sugars, modeling defaults, and two worked JSON examples. |
|
|
27
|
+
| `validate_workflow_definition` | read | Check an authored definition the way deploy does (structure + invariants + GROQ). Returns the desugared definition or a path-prefixed error list. |
|
|
28
|
+
|
|
29
|
+
Each descriptor's `description` field is written for an LLM consumer
|
|
30
|
+
— it says what the tool does, when to use it, and what _not_ to use
|
|
31
|
+
it for. See `src/tools.ts`.
|
|
32
|
+
|
|
33
|
+
## Why this surface, why no more
|
|
34
|
+
|
|
35
|
+
The CLI surface is shaped for a human at a terminal who already knows
|
|
36
|
+
the verbs. An MCP is consumed by an LLM that figures out which tool
|
|
37
|
+
to use from prose alone. Adding tools beyond the minimum increases
|
|
38
|
+
the search space the LLM has to navigate. We stay narrow until the
|
|
39
|
+
evals tell us the LLM is reaching for something we don't expose. The
|
|
40
|
+
authoring pair earns its place because authoring-from-a-description is
|
|
41
|
+
eval-backed; the agent generates the definition and self-corrects against
|
|
42
|
+
`validate_workflow_definition` (deploy stays a deliberate human step).
|
|
43
|
+
|
|
44
|
+
## Run the stdio server
|
|
45
|
+
|
|
46
|
+
In development (TypeScript sources via `tsx`):
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pnpm --filter @sanity/workflow-mcp dev
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Or run the built bin — the entry an MCP client spawns. After `pnpm build`
|
|
53
|
+
it's `bin/run.js`, and once the package is published this is what
|
|
54
|
+
`npx @sanity/workflow-mcp` resolves to:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
node packages/workflow-mcp/bin/run.js
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Both share the one boot path in `src/stdio.ts` and read the same
|
|
61
|
+
`SANITY_*` and `WORKFLOW_*` env vars as
|
|
62
|
+
[`@sanity/workflow-cli`](../workflow-cli) (which loads them
|
|
63
|
+
from the root `.env`). Copy `.env.example` to `.env` at the repo root
|
|
64
|
+
to get started — note the MCP _requires_ `SANITY_DATASET`.
|
|
65
|
+
|
|
66
|
+
## API
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import {createEngine} from '@sanity/workflow-engine'
|
|
70
|
+
import {buildTools} from '@sanity/workflow-mcp'
|
|
71
|
+
|
|
72
|
+
const engine = createEngine({client, workflowResource, tags})
|
|
73
|
+
const {descriptors, impls} = buildTools(engine)
|
|
74
|
+
|
|
75
|
+
// descriptors → feed to Anthropic API's `tools:` param, or to an MCP
|
|
76
|
+
// `tools/list` handler.
|
|
77
|
+
// impls → call from a `tools/call` handler, or from the eval
|
|
78
|
+
// harness directly (in-process, no transport).
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Open gaps
|
|
82
|
+
|
|
83
|
+
- **No `set_stage` tool.** Admin override; intentionally omitted from
|
|
84
|
+
the LLM surface until we have an eval case that needs it (and a
|
|
85
|
+
story for the actor identity that would authorise the override).
|
|
86
|
+
- **No `start_instance` tool.** Starting an instance is an operator
|
|
87
|
+
action that needs context the LLM doesn't have (subject doc id,
|
|
88
|
+
initial state). Out of scope for v0.1.
|
|
89
|
+
- **No effect completion.** Same logic — effects are queued by the
|
|
90
|
+
engine and drained by the runtime, not by a human or LLM.
|
|
91
|
+
- **No subscription / streaming.** MCP supports it; we haven't needed
|
|
92
|
+
it yet. Pull-based polling via `get_workflow_state` covers the eval
|
|
93
|
+
cases.
|
|
94
|
+
- **No `deploy_workflow_definition`.** `validate_workflow_definition` is
|
|
95
|
+
the authoring tool; a human deploys the validated definition via the
|
|
96
|
+
CLI. Deploy is a Content Lake write that inherits the actor-identity
|
|
97
|
+
story, so it stays out of the LLM surface for now.
|
package/bin/run.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Production entry for the published package: loads the compiled boot
|
|
3
|
+
// path from ./dist/stdio.js (built by pkg-utils via the `./stdio`
|
|
4
|
+
// export). The dev counterpart, bin/workflow-mcp.ts, loads the TS
|
|
5
|
+
// source through tsx instead.
|
|
6
|
+
|
|
7
|
+
import {runStdioServer} from '../dist/stdio.js'
|
|
8
|
+
|
|
9
|
+
await runStdioServer()
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: !0 });
|
|
3
|
+
var workflowEngine = require("@sanity/workflow-engine"), define = require("@sanity/workflow-engine/define");
|
|
4
|
+
const minimalExample = {
|
|
5
|
+
name: "quick-approval",
|
|
6
|
+
version: 1,
|
|
7
|
+
title: "Quick approval",
|
|
8
|
+
description: "One review stage with a single approve action, then a terminal stage.",
|
|
9
|
+
initialStage: "review",
|
|
10
|
+
fields: [{ type: "doc.ref", name: "subject", title: "Document", source: { type: "init" } }],
|
|
11
|
+
stages: [
|
|
12
|
+
{
|
|
13
|
+
name: "review",
|
|
14
|
+
title: "Review",
|
|
15
|
+
tasks: [
|
|
16
|
+
{
|
|
17
|
+
name: "decide",
|
|
18
|
+
title: "Decide",
|
|
19
|
+
activation: "auto",
|
|
20
|
+
actions: [{ name: "approve", title: "Approve", status: "done" }]
|
|
21
|
+
}
|
|
22
|
+
],
|
|
23
|
+
transitions: [{ name: "to-approved", title: "Approve and finish", to: "approved" }]
|
|
24
|
+
},
|
|
25
|
+
{ name: "approved", title: "Approved", description: "Terminal \u2014 no transitions out." }
|
|
26
|
+
]
|
|
27
|
+
}, reviewLoopExample = {
|
|
28
|
+
name: "doc-review",
|
|
29
|
+
version: 1,
|
|
30
|
+
title: "Document review",
|
|
31
|
+
description: "Draft, then editorial review that approves or rejects back to drafting.",
|
|
32
|
+
initialStage: "drafting",
|
|
33
|
+
fields: [{ type: "doc.ref", name: "subject", title: "Document", source: { type: "init" } }],
|
|
34
|
+
stages: [
|
|
35
|
+
{
|
|
36
|
+
name: "drafting",
|
|
37
|
+
title: "Drafting",
|
|
38
|
+
tasks: [
|
|
39
|
+
{
|
|
40
|
+
name: "write",
|
|
41
|
+
title: "Write the draft",
|
|
42
|
+
activation: "auto",
|
|
43
|
+
actions: [{ name: "submit", title: "Submit for review", status: "done" }]
|
|
44
|
+
}
|
|
45
|
+
],
|
|
46
|
+
transitions: [{ name: "to-review", title: "Send to review", to: "review" }]
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "review",
|
|
50
|
+
title: "Editorial review",
|
|
51
|
+
tasks: [
|
|
52
|
+
{
|
|
53
|
+
name: "review",
|
|
54
|
+
title: "Review the draft",
|
|
55
|
+
activation: "auto",
|
|
56
|
+
actions: [
|
|
57
|
+
{ name: "approve", title: "Approve", status: "done" },
|
|
58
|
+
{ name: "reject", title: "Reject", status: "failed" }
|
|
59
|
+
]
|
|
60
|
+
}
|
|
61
|
+
],
|
|
62
|
+
transitions: [
|
|
63
|
+
{ name: "to-approved", title: "Approve and finish", to: "approved" },
|
|
64
|
+
// Reject flips the task to `failed`; this routes the instance back.
|
|
65
|
+
{
|
|
66
|
+
name: "to-drafting",
|
|
67
|
+
title: "Send back to drafting",
|
|
68
|
+
to: "drafting",
|
|
69
|
+
filter: "$anyTaskFailed"
|
|
70
|
+
}
|
|
71
|
+
]
|
|
72
|
+
},
|
|
73
|
+
{ name: "approved", title: "Approved", description: "Terminal \u2014 no transitions out." }
|
|
74
|
+
]
|
|
75
|
+
}, ORIENTATION = "# Authoring a workflow definition\n\nA workflow definition is a plain JSON object. There is no code in it \u2014 every\ncondition (filters, predicates, guards) is a GROQ *string*. Generate the JSON,\nthen call `validate_workflow_definition` to check it; fix the reported errors\nand re-validate until it returns `valid: true`. The validator returns the\n*desugared* definition under `definition` \u2014 that is exactly what would deploy.\n\n## Shape\n\n- **Workflow**: `{ name, version (positive int), title, description?, initialStage, fields?, stages[], predicates? }`.\n `initialStage` must be the `name` of a declared stage.\n- **Stage**: `{ name, title?, description?, tasks?, transitions? }`. A stage with\n no transitions is terminal. Reaching a terminal stage ends the workflow.\n- **Task**: `{ name, title?, activation?, actions? }`. `activation` is `'auto'`\n (starts when the stage is entered) or `'manual'` (default \u2014 a human starts it).\n- **Action**: `{ name, title?, status?, params?, ops? }`.\n `status: 'done' | 'skipped' | 'failed'` is sugar that flips the *firing task* to\n that status when the action fires. `ops` are mutations applied when the action\n fires (e.g. `{type:'field.set', target:{field:'x'}, value:{type:'param', param:'p'}}`).\n `params` are values the action collects from the caller (referenced by\n `{type:'param', param:'<name>'}` sources).\n- **Transition**: `{ name, title?, to, filter? }`. `to` must name a declared\n stage. `filter` is a GROQ condition gating the exit; omit it and it defaults\n to `$allTasksDone`.\n- **Field** (workflow- or stage-scoped persistent state): `{ type, name, title?, source }`.\n Common `type`s: `doc.ref`, `doc.refs`, `value.string`, `value.url`,\n `value.number`, `value.boolean`. `source` is where the value comes from:\n `{type:'init'}` (supplied when the instance starts), `{type:'write'}` (a user\n edits it), `{type:'param', param:'<actionParamName>'}`, `{type:'now'}`, `{type:'actor'}`.\n By convention a workflow has an `init`-sourced `doc.ref` named `subject` \u2014 the\n headline document it is about.\n\n## GROQ in conditions\n\nBuilt-in variables available in filters/predicates: `$allTasksDone`,\n`$anyTaskFailed` (booleans over the current stage's tasks), `$tasks` (the task\nlist), `$fields` (field values), `$now`, and the caller-scoped vars `$actor`\n(the acting user), `$assigned` (whether the caller is the task's assignee \u2014 the\nidiomatic permission gate, used as `filter: '$assigned'`), and `$can`. Define\nreusable named conditions under top-level `predicates: { name: '<groq>' }` and\nreference them as `$name` (e.g.\n`predicates: { ready: \"count($tasks[status != 'done']) == 0\" }` \u2192 `$ready`).\n\nConditions evaluate against an in-memory snapshot (the instance + its subject +\nfield-declared docs) \u2014 **never** scan by `_type` (e.g. `*[_type==\"article\"]`);\nthat is a discovery query and the validator rejects it. To bring a document into\nscope, declare a `doc.ref` field for it.\n\n## Sugars worth knowing\n\n- Action `status: 'done' | 'skipped' | 'failed'` \u2014 flips the firing task (shown above).\n- Omitted transition `filter` \u2014 defaults to `$allTasksDone`.\n- Omitted task `activation` \u2014 defaults to `'manual'`.\n\n## Modeling defaults\n\nValid is not the same as good. Prefer these unless the request says otherwise:\n\n- **A decline/reject loops back.** Route a rejected / changes-requested\n transition to an *earlier* stage for revision (e.g. `review \u2192 drafting` gated\n on `$anyTaskFailed`), not to a terminal stage. Reserve terminal stages for\n completion and for explicit cancellation/abandonment \u2014 a workflow should not\n dead-end just because something was declined.\n- **Prefer draft \u2192 review.** Model an author working in a drafting stage who\n submits, then a review stage that gates. Don't add more review stages unless\n the request asks for multiple approvers or rounds.\n- **When the shape is ambiguous, pick the conventional one and confirm** with the\n user rather than inventing extra stages.\n\n## Rules the validator enforces\n\n- Stage names, task names (per stage) and transition names are unique.\n- Every transition `to` and `initialStage` names a declared stage.\n- Custom `predicates` must not shadow a built-in (e.g. `allTasksDone`).\n- Every GROQ string must parse and must not be a `_type` discovery scan.", AUTHORING_GUIDE = `${ORIENTATION}
|
|
76
|
+
|
|
77
|
+
## Examples
|
|
78
|
+
|
|
79
|
+
### Example 1 \u2014 ${minimalExample.title} (minimal: one stage, one action)
|
|
80
|
+
\`\`\`json
|
|
81
|
+
${JSON.stringify(minimalExample, null, 2)}
|
|
82
|
+
\`\`\`
|
|
83
|
+
|
|
84
|
+
### Example 2 \u2014 ${reviewLoopExample.title} (review loop: reject routes back)
|
|
85
|
+
\`\`\`json
|
|
86
|
+
${JSON.stringify(reviewLoopExample, null, 2)}
|
|
87
|
+
\`\`\`
|
|
88
|
+
`, LIST_CAP = 25, TOOL_DESCRIPTORS = [
|
|
89
|
+
{
|
|
90
|
+
name: "list_workflow_instances",
|
|
91
|
+
description: "List workflow instances in the configured workspace. 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 \u2014 id, workflow type id and human-readable `workflowTitle`, current stage, whether the instance is done, and (when the workflow has a conventional 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 " + String(25) + " results. Do NOT use this to inspect a single known instance \u2014 use get_workflow_state for that, the response will be richer.",
|
|
92
|
+
input_schema: {
|
|
93
|
+
type: "object",
|
|
94
|
+
properties: {
|
|
95
|
+
definition: {
|
|
96
|
+
type: "string",
|
|
97
|
+
description: "Optional. Restrict to instances of this workflow definition (e.g. 'article-review')."
|
|
98
|
+
},
|
|
99
|
+
status: {
|
|
100
|
+
type: "string",
|
|
101
|
+
enum: ["in_flight", "done", "any"],
|
|
102
|
+
description: "Optional. 'in_flight' returns instances not in a terminal stage; 'done' returns completed/aborted; 'any' returns both. Defaults to 'in_flight'."
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
additionalProperties: !1
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: "get_workflow_state",
|
|
110
|
+
description: "Get the current state of a single workflow instance, projected for action. Returns the workflow's id and human-readable `workflowTitle`, the current stage, the subject document the workflow is about (when the workflow has a conventional subject \u2014 its ref and title), every task on the current stage with its available actions (and whether each action is currently allowed), and the most recent history entries. Use this whenever you need to understand what's possible on an instance before deciding to act. This is a pure read \u2014 it does not change anything. If you only need to discover what instances exist, use list_workflow_instances instead; this tool requires you to know the instance id. list_workflow_instances also returns the same `workflowTitle` and `subject` fields, so prefer it for fan-out discovery rather than polling get_workflow_state per instance.",
|
|
111
|
+
input_schema: {
|
|
112
|
+
type: "object",
|
|
113
|
+
properties: {
|
|
114
|
+
instance_id: {
|
|
115
|
+
type: "string",
|
|
116
|
+
description: "The workflow instance id."
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
required: ["instance_id"],
|
|
120
|
+
additionalProperties: !1
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: "diagnose_workflow",
|
|
125
|
+
description: "Explain why a single workflow instance is or isn't progressing. Returns a verdict (`state`: progressing, waiting, blocked, completed, aborted, or stuck), a one-line `summary`, and \u2014 when stuck \u2014 a structured `cause` plus the `remediations` that would unstick it. Use this when an instance seems stalled or the user asks \"why isn't this moving?\": it distinguishes a healthy instance (waiting on a human, or will advance on its own) from a genuinely stuck one (a failed effect or task, a dead-end transition). This is a pure read \u2014 it changes nothing, and the remediations it names are advisory: none can be executed through this server. To actually advance a healthy waiting instance use fire_action; for per-task action detail use get_workflow_state.",
|
|
126
|
+
input_schema: {
|
|
127
|
+
type: "object",
|
|
128
|
+
properties: {
|
|
129
|
+
instance_id: {
|
|
130
|
+
type: "string",
|
|
131
|
+
description: "The workflow instance id."
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
required: ["instance_id"],
|
|
135
|
+
additionalProperties: !1
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: "fire_action",
|
|
140
|
+
description: "Advance a workflow instance by firing an action on one of its tasks. This is the only way to advance workflow state from the outside \u2014 there is no separate 'complete task' or 'transition stage' tool. To find the right (task, action) pair, call get_workflow_state first and pick from the allowed actions listed on the current stage's tasks. After firing, the engine cascades any auto-transitions that become eligible (so an 'approve' action on a review task may transition the workflow to a terminal stage in one shot). Returns the resulting state, same shape as get_workflow_state. If the action is not currently allowed (e.g. the task is already done, or a guard fails), this returns an error describing why.",
|
|
141
|
+
input_schema: {
|
|
142
|
+
type: "object",
|
|
143
|
+
properties: {
|
|
144
|
+
instance_id: {
|
|
145
|
+
type: "string",
|
|
146
|
+
description: "The workflow instance id."
|
|
147
|
+
},
|
|
148
|
+
task: {
|
|
149
|
+
type: "string",
|
|
150
|
+
description: "The id of the task on the current stage. Must be one of the tasks returned by get_workflow_state."
|
|
151
|
+
},
|
|
152
|
+
action: {
|
|
153
|
+
type: "string",
|
|
154
|
+
description: "The id of the action on that task. Must be one of the actions listed as allowed=true on the task."
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
required: ["instance_id", "task", "action"],
|
|
158
|
+
additionalProperties: !1
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: "get_workflow_authoring_guide",
|
|
163
|
+
description: "Get the guide for authoring a workflow definition: the DSL shape, the GROQ condition built-ins, the sugars, the rules the validator enforces, and two worked JSON examples. Call this BEFORE writing a definition from a description, then generate the JSON and check it with validate_workflow_definition. Pure read; takes no arguments.",
|
|
164
|
+
input_schema: {
|
|
165
|
+
type: "object",
|
|
166
|
+
properties: {},
|
|
167
|
+
additionalProperties: !1
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
name: "validate_workflow_definition",
|
|
172
|
+
description: "Validate a workflow definition you have authored. Runs the same checks as deploy \u2014 structural shape, cross-field invariants (e.g. every transition target is a declared stage), and GROQ syntax \u2014 without writing anything. Returns `{valid:true, definition}` where `definition` is the desugared form that would deploy, or `{valid:false, error}` with every problem listed and path-prefixed. This does NOT deploy \u2014 a human deploys the validated definition via the CLI. Call get_workflow_authoring_guide first for the shape; on `valid:false`, fix the reported problems and validate again.",
|
|
173
|
+
input_schema: {
|
|
174
|
+
type: "object",
|
|
175
|
+
properties: {
|
|
176
|
+
definition: {
|
|
177
|
+
type: "object",
|
|
178
|
+
description: "The workflow definition to validate, as a JSON object in authoring shape. See get_workflow_authoring_guide for the shape and examples."
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
required: ["definition"],
|
|
182
|
+
additionalProperties: !1
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
], SUBJECT_ENTRY_NAME = "subject";
|
|
186
|
+
function getSubjectGdr(instance) {
|
|
187
|
+
const entry = instance.fields.find((s) => s._type === "doc.ref" && s.name === SUBJECT_ENTRY_NAME);
|
|
188
|
+
if (!(entry === void 0 || entry._type !== "doc.ref"))
|
|
189
|
+
return entry.value?.id;
|
|
190
|
+
}
|
|
191
|
+
async function fetchSubjectTitles(engine, instances) {
|
|
192
|
+
const docIdToGdr = /* @__PURE__ */ new Map();
|
|
193
|
+
for (const inst of instances) {
|
|
194
|
+
const gdr = getSubjectGdr(inst);
|
|
195
|
+
if (gdr !== void 0)
|
|
196
|
+
try {
|
|
197
|
+
docIdToGdr.set(workflowEngine.extractDocumentId(gdr), gdr);
|
|
198
|
+
} catch {
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (docIdToGdr.size === 0) return /* @__PURE__ */ new Map();
|
|
202
|
+
let docs;
|
|
203
|
+
try {
|
|
204
|
+
docs = await engine.client.fetch(
|
|
205
|
+
"*[_id in $docIds]{_id, title}",
|
|
206
|
+
{ docIds: Array.from(docIdToGdr.keys()) }
|
|
207
|
+
);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
return process.stderr.write(
|
|
210
|
+
`workflow-mcp: subject-title lookup failed; returning refs without titles: ${err instanceof Error ? err.message : String(err)}
|
|
211
|
+
`
|
|
212
|
+
), /* @__PURE__ */ new Map();
|
|
213
|
+
}
|
|
214
|
+
const out = /* @__PURE__ */ new Map();
|
|
215
|
+
for (const doc of docs) {
|
|
216
|
+
const gdr = docIdToGdr.get(doc._id);
|
|
217
|
+
gdr !== void 0 && typeof doc.title == "string" && doc.title !== "" && out.set(gdr, doc.title);
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
function buildSubject(instance, subjectTitles) {
|
|
222
|
+
const gdr = getSubjectGdr(instance);
|
|
223
|
+
if (gdr === void 0) return;
|
|
224
|
+
const title = subjectTitles.get(gdr);
|
|
225
|
+
return title !== void 0 ? { ref: gdr, title } : { ref: gdr };
|
|
226
|
+
}
|
|
227
|
+
function projectSummary(doc, subjectTitles) {
|
|
228
|
+
const definition = JSON.parse(doc.definitionSnapshot), stageTitle = definition.stages.find((s) => s.name === doc.currentStage)?.title, workflowTitle = definition.title, subject = buildSubject(doc, subjectTitles);
|
|
229
|
+
return {
|
|
230
|
+
instanceId: doc._id,
|
|
231
|
+
definition: doc.definition,
|
|
232
|
+
...workflowTitle !== void 0 ? { workflowTitle } : {},
|
|
233
|
+
currentStage: doc.currentStage,
|
|
234
|
+
...stageTitle !== void 0 ? { currentStageTitle: stageTitle } : {},
|
|
235
|
+
lastChangedAt: doc.lastChangedAt,
|
|
236
|
+
done: isInstanceDone(doc),
|
|
237
|
+
...subject !== void 0 ? { subject } : {}
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
function projectState(instance, evaluation, subjectTitles) {
|
|
241
|
+
const workflowTitle = JSON.parse(instance.definitionSnapshot).title, stageTitle = evaluation.currentStage.stage.title, tasks = evaluation.currentStage.tasks.map((te) => {
|
|
242
|
+
const actions = te.actions.map((ae) => {
|
|
243
|
+
const verdict = workflowEngine.actionVerdict(te, ae);
|
|
244
|
+
return {
|
|
245
|
+
action: verdict.action,
|
|
246
|
+
...verdict.title !== void 0 ? { title: verdict.title } : {},
|
|
247
|
+
allowed: verdict.allowed,
|
|
248
|
+
...verdict.allowed === !1 && verdict.disabledReason !== void 0 ? { disabledReason: formatDisabledReason(verdict.disabledReason) } : {}
|
|
249
|
+
};
|
|
250
|
+
});
|
|
251
|
+
return {
|
|
252
|
+
task: te.task.name,
|
|
253
|
+
...te.task.title !== void 0 ? { title: te.task.title } : {},
|
|
254
|
+
...te.task.description !== void 0 ? { description: te.task.description } : {},
|
|
255
|
+
status: te.status,
|
|
256
|
+
actions
|
|
257
|
+
};
|
|
258
|
+
}), recentHistory = instance.history.slice(-8).toReversed().map((entry) => ({
|
|
259
|
+
at: entry.at,
|
|
260
|
+
type: entry._type,
|
|
261
|
+
summary: summariseHistoryEntry(entry)
|
|
262
|
+
})), subject = buildSubject(instance, subjectTitles);
|
|
263
|
+
return {
|
|
264
|
+
instanceId: instance._id,
|
|
265
|
+
definition: instance.definition,
|
|
266
|
+
...workflowTitle !== void 0 ? { workflowTitle } : {},
|
|
267
|
+
currentStage: instance.currentStage,
|
|
268
|
+
...stageTitle !== void 0 ? { currentStageTitle: stageTitle } : {},
|
|
269
|
+
done: isInstanceDone(instance),
|
|
270
|
+
...subject !== void 0 ? { subject } : {},
|
|
271
|
+
tasks,
|
|
272
|
+
recentHistory
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
function isInstanceDone(instance) {
|
|
276
|
+
if (instance.completedAt !== void 0) return !0;
|
|
277
|
+
try {
|
|
278
|
+
const stage = JSON.parse(instance.definitionSnapshot).stages.find((s) => s.name === instance.currentStage);
|
|
279
|
+
return stage !== void 0 && workflowEngine.isTerminalStage(stage);
|
|
280
|
+
} catch {
|
|
281
|
+
return !1;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const DISABLED_REASON_TEXT = {
|
|
285
|
+
"filter-failed": "action's filter condition did not hold for this actor",
|
|
286
|
+
"task-not-active": "task is not currently active",
|
|
287
|
+
"stage-terminal": "stage is terminal \u2014 no further actions possible",
|
|
288
|
+
"instance-completed": "instance has already completed",
|
|
289
|
+
"requirements-unmet": "task's declared requirements are not yet satisfied"
|
|
290
|
+
};
|
|
291
|
+
function formatDisabledReason(reason) {
|
|
292
|
+
return DISABLED_REASON_TEXT[reason.kind] ?? reason.kind.replace(/-/g, " ");
|
|
293
|
+
}
|
|
294
|
+
function summariseHistoryEntry(entry) {
|
|
295
|
+
switch (entry._type) {
|
|
296
|
+
case "stageEntered":
|
|
297
|
+
return `entered stage "${String(entry.stage)}"`;
|
|
298
|
+
case "stageExited":
|
|
299
|
+
return `exited stage "${String(entry.stage)}" \u2192 "${String(entry.toStage)}"`;
|
|
300
|
+
case "taskActivated":
|
|
301
|
+
return `task "${String(entry.task)}" activated`;
|
|
302
|
+
case "taskStatusChanged":
|
|
303
|
+
return `task "${String(entry.task)}" status: ${String(entry.from)} \u2192 ${String(entry.to)}`;
|
|
304
|
+
case "actionFired":
|
|
305
|
+
return `action "${String(entry.action)}" fired on task "${String(entry.task)}"`;
|
|
306
|
+
case "transitionFired":
|
|
307
|
+
return `transition fired: "${String(entry.fromStage)}" \u2192 "${String(entry.toStage)}"`;
|
|
308
|
+
case "effectQueued":
|
|
309
|
+
return `effect "${String(entry.effect)}" queued`;
|
|
310
|
+
case "effectCompleted":
|
|
311
|
+
return `effect "${String(entry.effect)}" completed (${String(entry.status)})`;
|
|
312
|
+
case "spawned":
|
|
313
|
+
return `spawned child workflow from task "${String(entry.task)}"`;
|
|
314
|
+
default:
|
|
315
|
+
return entry._type;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
function diagnosisSummary(diagnosis) {
|
|
319
|
+
switch (diagnosis.state) {
|
|
320
|
+
case "progressing":
|
|
321
|
+
return "This instance will advance on its own.";
|
|
322
|
+
case "waiting":
|
|
323
|
+
return `Waiting for action on task "${diagnosis.task}": ${diagnosis.actions.join(" or ")}. This is the normal in-flight state \u2014 it advances when someone acts.`;
|
|
324
|
+
case "blocked":
|
|
325
|
+
return `Task "${diagnosis.task}" is visible but not yet executable \u2014 unmet requirement(s): ${diagnosis.requirements.join(", ")}. It will not advance until those are satisfied.`;
|
|
326
|
+
case "completed":
|
|
327
|
+
return `Completed at ${diagnosis.at}.`;
|
|
328
|
+
case "aborted":
|
|
329
|
+
return diagnosis.reason !== void 0 ? `Aborted at ${diagnosis.at}: ${diagnosis.reason}.` : `Aborted at ${diagnosis.at}.`;
|
|
330
|
+
case "stuck":
|
|
331
|
+
return stuckSummary(diagnosis.cause);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
function stuckSummary(cause) {
|
|
335
|
+
switch (cause.kind) {
|
|
336
|
+
case "failed-effect":
|
|
337
|
+
return `Stuck: a failed effect "${cause.effect.name}" is blocking task "${cause.effect.origin.name}", which can't complete until the effect succeeds.`;
|
|
338
|
+
case "hung-effect":
|
|
339
|
+
return `Stuck: effect "${cause.effect.name}" was claimed but never completed \u2014 the drainer likely died mid-dispatch, so it won't drain on its own.`;
|
|
340
|
+
case "failed-task":
|
|
341
|
+
return `Stuck: task "${cause.task}" is in a terminal failed state, so any exit transition gated on it can never fire.`;
|
|
342
|
+
case "no-transition-fires":
|
|
343
|
+
return "Stuck: every task is resolved but no exit transition's filter is satisfied \u2014 likely a routing state value a filter reads was never written.";
|
|
344
|
+
case "transition-unevaluable":
|
|
345
|
+
return `Stuck (recoverable): exit transition(s) ${cause.transitions.join(", ")} read an operand that is missing or unreadable (GROQ null). It advances on its own once that data becomes readable \u2014 no manual fix needed.`;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
function buildTools(engine, options = {}) {
|
|
349
|
+
const accessOverride = options.access;
|
|
350
|
+
return { descriptors: TOOL_DESCRIPTORS, impls: {
|
|
351
|
+
list_workflow_instances: async (rawInput) => {
|
|
352
|
+
const input = parseListInput(rawInput);
|
|
353
|
+
return listInstances(engine, input);
|
|
354
|
+
},
|
|
355
|
+
get_workflow_state: async (rawInput) => {
|
|
356
|
+
const input = parseInstanceIdInput(rawInput, "get_workflow_state");
|
|
357
|
+
return getInstanceState(engine, input.instance_id, accessOverride);
|
|
358
|
+
},
|
|
359
|
+
diagnose_workflow: async (rawInput) => {
|
|
360
|
+
const input = parseInstanceIdInput(rawInput, "diagnose_workflow");
|
|
361
|
+
return getInstanceDiagnosis(engine, input.instance_id, accessOverride);
|
|
362
|
+
},
|
|
363
|
+
fire_action: async (rawInput) => {
|
|
364
|
+
const input = parseFireActionInput(rawInput);
|
|
365
|
+
return await engine.fireAction({
|
|
366
|
+
instanceId: input.instance_id,
|
|
367
|
+
task: input.task,
|
|
368
|
+
action: input.action,
|
|
369
|
+
...accessOverride !== void 0 ? { access: accessOverride } : {}
|
|
370
|
+
}), getInstanceState(engine, input.instance_id, accessOverride);
|
|
371
|
+
},
|
|
372
|
+
// Authoring tools — engine-independent: they teach and check the DSL, they
|
|
373
|
+
// don't touch the lake or an instance.
|
|
374
|
+
get_workflow_authoring_guide: async () => AUTHORING_GUIDE,
|
|
375
|
+
validate_workflow_definition: async (rawInput) => {
|
|
376
|
+
const { definition } = parseValidateInput(rawInput);
|
|
377
|
+
try {
|
|
378
|
+
const stored = define.defineWorkflow(definition);
|
|
379
|
+
return workflowEngine.validateDefinition(stored), { valid: !0, definition: stored };
|
|
380
|
+
} catch (err) {
|
|
381
|
+
return { valid: !1, error: err instanceof Error ? err.message : String(err) };
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
} };
|
|
385
|
+
}
|
|
386
|
+
function parseListInput(raw) {
|
|
387
|
+
if (raw === null || typeof raw != "object")
|
|
388
|
+
return { status: "in_flight" };
|
|
389
|
+
const obj = raw, status = obj.status === "done" || obj.status === "any" ? obj.status : "in_flight", definition = typeof obj.definition == "string" ? obj.definition : void 0;
|
|
390
|
+
return definition !== void 0 ? { definition, status } : { status };
|
|
391
|
+
}
|
|
392
|
+
function parseValidateInput(raw) {
|
|
393
|
+
if (raw === null || typeof raw != "object")
|
|
394
|
+
throw new Error("validate_workflow_definition: input must be an object");
|
|
395
|
+
const obj = raw;
|
|
396
|
+
if (obj.definition === null || typeof obj.definition != "object")
|
|
397
|
+
throw new Error("validate_workflow_definition: `definition` is required and must be an object");
|
|
398
|
+
return { definition: obj.definition };
|
|
399
|
+
}
|
|
400
|
+
function parseInstanceIdInput(raw, tool) {
|
|
401
|
+
if (raw === null || typeof raw != "object")
|
|
402
|
+
throw new Error(`${tool}: input must be an object`);
|
|
403
|
+
const obj = raw;
|
|
404
|
+
if (typeof obj.instance_id != "string" || obj.instance_id === "")
|
|
405
|
+
throw new Error(`${tool}: instance_id is required`);
|
|
406
|
+
return { instance_id: obj.instance_id };
|
|
407
|
+
}
|
|
408
|
+
function parseFireActionInput(raw) {
|
|
409
|
+
if (raw === null || typeof raw != "object")
|
|
410
|
+
throw new Error("fire_action: input must be an object");
|
|
411
|
+
const obj = raw;
|
|
412
|
+
for (const key of ["instance_id", "task", "action"])
|
|
413
|
+
if (typeof obj[key] != "string" || obj[key] === "")
|
|
414
|
+
throw new Error(`fire_action: ${key} is required`);
|
|
415
|
+
return {
|
|
416
|
+
instance_id: obj.instance_id,
|
|
417
|
+
task: obj.task,
|
|
418
|
+
action: obj.action
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
async function listInstances(engine, input) {
|
|
422
|
+
const groqParts = [`_type == "${workflowEngine.WORKFLOW_INSTANCE_TYPE}"`];
|
|
423
|
+
input.definition !== void 0 && groqParts.push("definition == $definition");
|
|
424
|
+
const groq = `*[${groqParts.join(" && ")} && ${workflowEngine.tagScopeFilter()}] | order(lastChangedAt desc)[0...${LIST_CAP}]`, params = {};
|
|
425
|
+
input.definition !== void 0 && (params.definition = input.definition);
|
|
426
|
+
const filtered = (await engine.query({ groq, params })).filter((doc) => {
|
|
427
|
+
const done = isInstanceDone(doc);
|
|
428
|
+
return input.status === "done" ? done : input.status === "in_flight" ? !done : !0;
|
|
429
|
+
}), subjectTitles = await fetchSubjectTitles(engine, filtered);
|
|
430
|
+
return {
|
|
431
|
+
instances: filtered.map((doc) => projectSummary(doc, subjectTitles))
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
async function getInstanceState(engine, instanceId, accessOverride) {
|
|
435
|
+
const evaluation = await engine.evaluateInstance({
|
|
436
|
+
instanceId,
|
|
437
|
+
...accessOverride !== void 0 ? { access: accessOverride } : {}
|
|
438
|
+
}), instance = await engine.getInstance({ instanceId }), subjectTitles = await fetchSubjectTitles(engine, [instance]);
|
|
439
|
+
return projectState(instance, evaluation, subjectTitles);
|
|
440
|
+
}
|
|
441
|
+
async function getInstanceDiagnosis(engine, instanceId, accessOverride) {
|
|
442
|
+
const { diagnosis, remediations } = await engine.diagnose({
|
|
443
|
+
instanceId,
|
|
444
|
+
...accessOverride !== void 0 ? { access: accessOverride } : {}
|
|
445
|
+
});
|
|
446
|
+
return {
|
|
447
|
+
instanceId,
|
|
448
|
+
state: diagnosis.state,
|
|
449
|
+
summary: diagnosisSummary(diagnosis),
|
|
450
|
+
...diagnosis.state === "stuck" ? { cause: diagnosis.cause.kind } : {},
|
|
451
|
+
remediations
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
exports.buildTools = buildTools;
|
|
455
|
+
//# sourceMappingURL=index.cjs.map
|