@protobase/server 0.67.8 → 0.67.10

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.
Files changed (44) hide show
  1. package/dist/config-exports.d.ts +2 -1
  2. package/dist/config-exports.js +2 -1
  3. package/dist/create-admin.d.ts +4 -1
  4. package/dist/create-admin.js +11 -2
  5. package/dist/index.d.ts +7 -1
  6. package/dist/index.js +6 -1
  7. package/dist/internal.d.ts +2 -1
  8. package/dist/internal.js +2 -1
  9. package/dist/mcp/mcp-tools.d.ts +8 -2
  10. package/dist/mcp/mcp-tools.js +10 -3
  11. package/dist/package.js +1 -1
  12. package/dist/webhooks/attempt.js +2 -2
  13. package/dist/webhooks/table/webhook-table.js +1 -1
  14. package/dist/workflows/http-step.js +103 -0
  15. package/dist/workflows/input-limits.js +22 -0
  16. package/dist/workflows/mail-step.js +58 -0
  17. package/dist/workflows/mcp/merge-patch.js +15 -0
  18. package/dist/workflows/mcp/type-catalog.js +144 -0
  19. package/dist/workflows/mcp/workflow-instructions.d.ts +7 -0
  20. package/dist/workflows/mcp/workflow-instructions.js +77 -0
  21. package/dist/workflows/mcp/workflow-read-tools.d.ts +6 -0
  22. package/dist/workflows/mcp/workflow-read-tools.js +209 -0
  23. package/dist/workflows/mcp/workflow-tool-args.js +35 -0
  24. package/dist/workflows/mcp/workflow-write-tools.d.ts +7 -0
  25. package/dist/workflows/mcp/workflow-write-tools.js +149 -0
  26. package/dist/workflows/options.d.ts +27 -0
  27. package/dist/workflows/options.js +19 -0
  28. package/dist/workflows/piece-step.js +11 -0
  29. package/dist/workflows/records-step.js +114 -0
  30. package/dist/workflows/run-files.js +70 -0
  31. package/dist/workflows/save-hook.js +20 -0
  32. package/dist/workflows/schema-sql.js +147 -0
  33. package/dist/workflows/schema.d.ts +11 -0
  34. package/dist/workflows/schema.js +41 -0
  35. package/dist/workflows/segment-job.js +51 -0
  36. package/dist/workflows/start-hook.js +26 -0
  37. package/dist/workflows/start-workflow.d.ts +26 -0
  38. package/dist/workflows/start-workflow.js +28 -0
  39. package/dist/workflows/step-types.js +34 -0
  40. package/dist/workflows/store.js +42 -0
  41. package/dist/workflows/workflow-engine.js +37 -0
  42. package/dist/workflows/workflows-module.d.ts +124 -0
  43. package/dist/workflows/workflows-module.js +11 -0
  44. package/package.json +5 -4
@@ -0,0 +1,144 @@
1
+ //#region src/workflows/mcp/type-catalog.ts
2
+ const needs = (takes) => ({
3
+ required: true,
4
+ takes
5
+ });
6
+ const may = (takes) => ({
7
+ required: false,
8
+ takes
9
+ });
10
+ const resource = needs("the resource's name, as list_resources gives it");
11
+ const key = needs("the record's key");
12
+ const refused = ["Records.Refused: the API refused it, its status and message in the cause", "Records.InvalidArguments: an argument has the wrong type, named in the cause"];
13
+ /** The step types a Task names as its `Resource`, as the workflows reference describes them. */
14
+ const stepTypeCatalog = [
15
+ {
16
+ resource: "app:http",
17
+ does: "Sends one HTTP request to a public address and reads the answer.",
18
+ arguments: {
19
+ url: needs("an http or https URL"),
20
+ method: may("GET (default), POST, PUT, PATCH or DELETE"),
21
+ headers: may("an object of text values"),
22
+ query: may("an object of text values, added to the URL's query"),
23
+ body: may("text sent as it is, a file value sent as its bytes, any other value sent as JSON; a GET sends none"),
24
+ response: may("how the answer's body is read: json (default; empty is null), text, or file (kept as a file value of the run)")
25
+ },
26
+ result: "{ status, headers, body }",
27
+ errors: [
28
+ "Http.Status: a status outside 2xx; the cause names it with the start of the body",
29
+ "Http.Network: no answer",
30
+ "Http.InvalidArguments: an argument has the wrong type, named in the cause",
31
+ "Http.InvalidBody: response json, and the body is not JSON",
32
+ "Http.AddressRefused: the host is not a public address",
33
+ "Http.ResponseTooLarge: a body over 10 MiB"
34
+ ]
35
+ },
36
+ {
37
+ resource: "app:records.create",
38
+ does: "Creates records of the app's resources, as the workflow's last editor.",
39
+ arguments: {
40
+ resource,
41
+ data: needs("an object of field values, or a list of them, created in one batch: all or none")
42
+ },
43
+ result: "the created record, or the list of them",
44
+ errors: refused
45
+ },
46
+ {
47
+ resource: "app:records.update",
48
+ does: "Updates one record, as the workflow's last editor.",
49
+ arguments: {
50
+ resource,
51
+ key,
52
+ data: needs("an object of the field values to change")
53
+ },
54
+ result: "the updated record",
55
+ errors: refused
56
+ },
57
+ {
58
+ resource: "app:records.get",
59
+ does: "Reads one record, as the workflow's last editor.",
60
+ arguments: {
61
+ resource,
62
+ key
63
+ },
64
+ result: "the record",
65
+ errors: refused
66
+ },
67
+ {
68
+ resource: "app:records.list",
69
+ does: "Lists records, as the workflow's last editor.",
70
+ arguments: {
71
+ resource,
72
+ filter: may("a filter in the API's syntax, such as status = \"open\""),
73
+ order_by: may("such as \"created_at desc\""),
74
+ limit: may("how many at most")
75
+ },
76
+ result: "{ records }",
77
+ errors: refused
78
+ },
79
+ {
80
+ resource: "app:mail.send",
81
+ does: "Sends a mail from the app's own sender.",
82
+ arguments: {
83
+ to: needs("one address, or a list of at most 50"),
84
+ subject: needs("one line of text"),
85
+ text: needs("the body as text"),
86
+ html: may("the body as HTML too"),
87
+ attachments: may("a list of file values the run keeps, at most 10 MiB together")
88
+ },
89
+ result: "{ message_id }",
90
+ errors: ["Mail.InvalidArguments: an argument has the wrong type, named in the cause", "States.TaskFailed: the app sends no mail, or the mailer refused it"]
91
+ },
92
+ {
93
+ resource: "app:piece",
94
+ does: "Runs an action of a connector. Not implemented yet: a run fails it.",
95
+ arguments: {
96
+ piece: needs("the connector's name"),
97
+ action: needs("one of its actions"),
98
+ connection: needs("the connection holding its sign-in"),
99
+ props: may("the action's arguments, by name")
100
+ },
101
+ result: "what the action returns",
102
+ errors: ["Piece.Error: always, until connectors are implemented"]
103
+ }
104
+ ];
105
+ /** The triggers a definition's `trigger` holds exactly one of. */
106
+ const triggerCatalog = [
107
+ {
108
+ trigger: "manual",
109
+ example: { manual: {} },
110
+ starts: "when someone runs the workflow, such as with run_workflow",
111
+ input: "the input they give"
112
+ },
113
+ {
114
+ trigger: "webhook",
115
+ example: { webhook: {} },
116
+ starts: "on each POST to the workflow's webhook address",
117
+ input: "{ body, headers, query }"
118
+ },
119
+ {
120
+ trigger: "schedule",
121
+ example: { schedule: {
122
+ cron: "0 6 * * 1-5",
123
+ timezone: "Europe/Amsterdam"
124
+ } },
125
+ starts: "on a 5-field cron, in an IANA time zone (default UTC)",
126
+ input: "{ scheduled_at }, ISO 8601 with the zone's offset"
127
+ },
128
+ {
129
+ trigger: "mail",
130
+ example: { mail: { mailbox: "support" } },
131
+ starts: "on each mail arriving in that mailbox",
132
+ input: "{ from, to, subject, text, html, received_at, attachments }"
133
+ },
134
+ {
135
+ trigger: "record",
136
+ example: { record: { events: ["orders.created"] } },
137
+ starts: "on each of those record events, <resource>.<event>",
138
+ input: "{ event, resource, key, after, before, changed }"
139
+ }
140
+ ];
141
+ /** Only manual runs start for now: a save takes the other triggers, which start nothing yet. */
142
+ const triggerNote = "Runs start only by hand (run_workflow) for now: a workflow saves with any trigger, and the other triggers start no runs yet.";
143
+ //#endregion
144
+ export { stepTypeCatalog, triggerCatalog, triggerNote };
@@ -0,0 +1,7 @@
1
+ //#region src/workflows/mcp/workflow-instructions.d.ts
2
+ /**
3
+ * What the MCP endpoint tells a client about workflows, beside reading records: the language, in the named-reads
4
+ * profile of the States Language with JSONata, and how the workflow tools find, make, change and test-run them.
5
+ */
6
+ export declare const workflowInstructions: string;
7
+ //#endregion
@@ -0,0 +1,77 @@
1
+ import { limits } from "@protobase/workflows";
2
+ /**
3
+ * What the MCP endpoint tells a client about workflows, beside reading records: the language, in the named-reads
4
+ * profile of the States Language with JSONata, and how the workflow tools find, make, change and test-run them.
5
+ */
6
+ const workflowInstructions = [
7
+ "Workflows: list_workflow_types names the step types and triggers with their arguments and inputs; list_workflows and get_workflow read workflows; validate_workflow checks a definition without saving it; create_workflow and update_workflow save one; run_workflow starts a run with an input, and get_workflow_run reads it with each step's input, output, logs and error. A run goes on in the background: read it again until its status is succeeded or failed, then fix the step that failed with update_workflow and run it again.",
8
+ "A definition is one JSON document: { \"trigger\": <exactly one trigger>, \"stateMachine\": { \"QueryLanguage\": \"JSONata\", \"StartAt\": \"<state>\", \"States\": { \"<state>\": <state>, ... } } }, the States Language with JSONata expressions, in a profile where every value is read by name. It holds no layout.",
9
+ "States: Task { Resource: <step type>, Arguments, TimeoutSeconds, Retry, Catch }; Pass { Output, Assign }; Choice { Choices: [{ Condition, Next, Output?, Assign? }], Default }; Map { Items, ItemProcessor: { StartAt, States }, MaxConcurrency }; Wait { Seconds | Timestamp }; Succeed { Output }; Fail { Error, Cause }. Every state may have Comment, Output and Assign. Task, Pass, Map and Wait have exactly one of Next (a state name) and End: true. State names are unique across the document, Maps included, and every state must be reachable from StartAt. InputPath, Parameters, ResultPath and the other JSONPath fields, and Parallel, are not part of the profile.",
10
+ "Expressions: a string that is whole \"{% ... %}\" is a JSONata expression whose value, of any type, the field takes; any other string is literal text. Build text inside one expression with &: \"{% 'Order ' & $order.id %}\". Expressions sit at any depth in Arguments, Output and Assign, and in Items, Condition, Seconds, Timestamp, Error and Cause.",
11
+ "Reading values by name, the only reads allowed: $states.context.Execution.Input (the run's input, in every state), $states.context.Execution.StartTime, $states.context.Secrets.<NAME> (a secret the app lists), $<variable> (assigned by a state that runs before), $states.result (a Task's or Map's result, in its Output and Assign), $states.errorOutput ({ Error, Cause }, in a Catch rule's Output and Assign), $states.input (the item, only in the first state of a Map's ItemProcessor). Never a bare field name, $ or $$ on its own, or $eval. Keep a result for later states with \"Assign\": { \"order\": \"{% $states.result %}\" }, read as $order from the next state on; a state inside a Map may not assign a name an outer state assigns.",
12
+ `Errors: a failing Task or Map fails with an error name, its step type's own (list_workflow_types names them), States.Timeout, States.TaskFailed, or States.QueryEvaluationError for an expression that failed. Retry: [{ ErrorEquals: ["States.ALL"], IntervalSeconds, MaxAttempts (retries after the first try, at most ${limits.retryAttempts}), BackoffRate }]; Catch: [{ ErrorEquals, Next, Assign }] goes on to Next once no retry is left; without a Catch the run fails. A Choice whose rules all fail and that has no Default fails with States.NoChoiceMatched.`,
13
+ `Limits: ${limits.states} states, a Map's MaxConcurrency ${limits.mapConcurrency}, a Task's TimeoutSeconds ${limits.taskTimeoutSeconds}.`,
14
+ `A saved or validated definition that does not fit is refused with problems, each { state, field, message }: the state (a Map's inner state as Map/State), the field such as Arguments.url, and what was expected. Fix those fields and try again; nothing was saved. An example: ${JSON.stringify({
15
+ trigger: { manual: {} },
16
+ stateMachine: {
17
+ QueryLanguage: "JSONata",
18
+ StartAt: "GetQuote",
19
+ States: {
20
+ GetQuote: {
21
+ Type: "Task",
22
+ Resource: "app:records.get",
23
+ Arguments: {
24
+ resource: "quotes",
25
+ key: "{% $states.context.Execution.Input.quote_id %}"
26
+ },
27
+ Assign: { quote: "{% $states.result %}" },
28
+ Next: "IsDraft"
29
+ },
30
+ IsDraft: {
31
+ Type: "Choice",
32
+ Choices: [{
33
+ Condition: "{% $quote.status = 'draft' %}",
34
+ Next: "Notify"
35
+ }],
36
+ Default: "NotADraft"
37
+ },
38
+ NotADraft: {
39
+ Type: "Fail",
40
+ Error: "Quote.NotDraft",
41
+ Cause: "{% 'Quote ' & $quote.id & ' is not a draft' %}"
42
+ },
43
+ Notify: {
44
+ Type: "Task",
45
+ Resource: "app:http",
46
+ Arguments: {
47
+ method: "POST",
48
+ url: "https://hooks.example.com/quotes",
49
+ body: {
50
+ id: "{% $quote.id %}",
51
+ total: "{% $quote.total %}"
52
+ }
53
+ },
54
+ Retry: [{
55
+ ErrorEquals: ["States.ALL"],
56
+ IntervalSeconds: 10,
57
+ MaxAttempts: 3
58
+ }],
59
+ Catch: [{
60
+ ErrorEquals: ["States.ALL"],
61
+ Next: "Failed",
62
+ Assign: { notifyError: "{% $states.errorOutput %}" }
63
+ }],
64
+ Output: "{% $states.result.body %}",
65
+ End: true
66
+ },
67
+ Failed: {
68
+ Type: "Fail",
69
+ Error: "Notify.Failed",
70
+ Cause: "{% $notifyError.Cause %}"
71
+ }
72
+ }
73
+ }
74
+ })}`
75
+ ].join("\n");
76
+ //#endregion
77
+ export { workflowInstructions };
@@ -0,0 +1,6 @@
1
+ import { AssistantTool, ToolContext } from "../../assistant/tools.js";
2
+ import "../../index.js";
3
+ //#region src/workflows/mcp/workflow-read-tools.d.ts
4
+ /** The tools that read workflows, their types and runs, and check definitions. */
5
+ export declare const workflowReadTools: () => AssistantTool<ToolContext>[];
6
+ //#endregion
@@ -0,0 +1,209 @@
1
+ import { defineTool } from "../../assistant/tools.js";
2
+ import { refused } from "../../assistant/record-tool-args.js";
3
+ import { stepTypeNames } from "../step-types.js";
4
+ import { mergePatch } from "./merge-patch.js";
5
+ import { stepTypeCatalog, triggerCatalog, triggerNote } from "./type-catalog.js";
6
+ import { definitionParameter, editParameter, idOf, idParameter, objectOf, pageSizeOf, textOf } from "./workflow-tool-args.js";
7
+ import { checkDefinition } from "@protobase/workflows";
8
+ //#region src/workflows/mcp/workflow-read-tools.ts
9
+ const reads = {
10
+ readOnlyHint: true,
11
+ openWorldHint: false
12
+ };
13
+ const listed = [
14
+ "id",
15
+ "name",
16
+ "description",
17
+ "version",
18
+ "updated_at"
19
+ ];
20
+ /** The step types and triggers a definition may name, with their arguments, results, errors and run inputs. */
21
+ const listWorkflowTypesTool = defineTool({
22
+ name: "list_workflow_types",
23
+ description: "Lists what a workflow definition may name, as JSON ({ step_types, triggers }): each step type a Task names as its Resource, with its Arguments (required or not, and what each takes), its result and the errors it fails with; and each trigger, how the definition holds it and the input a run gets from it.",
24
+ parameters: {
25
+ type: "object",
26
+ properties: {}
27
+ },
28
+ annotations: reads,
29
+ run: async () => JSON.stringify({
30
+ step_types: stepTypeCatalog,
31
+ triggers: triggerCatalog,
32
+ note: triggerNote
33
+ })
34
+ });
35
+ /** The workflows the caller may read, a page at a time, without their definitions. */
36
+ const listWorkflowsTool = defineTool({
37
+ name: "list_workflows",
38
+ description: "Lists the workflows you may read, newest change first, as JSON ({ items, next_page_token }), each with its id, name, description and version but not its definition: get_workflow reads that. filter uses AIP-160 on name, such as name = \"Daily report\"; pass next_page_token back as page_token for the next page.",
39
+ parameters: {
40
+ type: "object",
41
+ properties: {
42
+ filter: {
43
+ type: "string",
44
+ description: "Such as name = \"Daily report\"."
45
+ },
46
+ page_size: {
47
+ type: "integer",
48
+ minimum: 1,
49
+ maximum: 100,
50
+ description: "Workflows per page, 20 by default."
51
+ },
52
+ page_token: {
53
+ type: "string",
54
+ description: "The next_page_token of the page before."
55
+ }
56
+ }
57
+ },
58
+ annotations: reads,
59
+ run: async (args, context) => {
60
+ const params = {
61
+ fields: listed,
62
+ order_by: "updated_at desc",
63
+ page_size: pageSizeOf(args.page_size, 20, 100),
64
+ ...textOf(args.filter) && { filter: textOf(args.filter) },
65
+ ...textOf(args.page_token) && { page_token: textOf(args.page_token) }
66
+ };
67
+ return context.records.list("workflows", params).then((page) => JSON.stringify({
68
+ items: page.items.map(({ etag: _etag, permissions: _permissions, ...item }) => item),
69
+ next_page_token: page.next_page_token
70
+ }), refused);
71
+ }
72
+ });
73
+ /** One workflow with its definition and the etag an update can pass. */
74
+ const getWorkflowTool = defineTool({
75
+ name: "get_workflow",
76
+ description: "Reads one workflow by its id, as JSON ({ workflow, etag }): its name, description, version and whole definition. Pass the etag to update_workflow to fail when someone changed it since.",
77
+ parameters: {
78
+ type: "object",
79
+ properties: { id: idParameter },
80
+ required: ["id"]
81
+ },
82
+ annotations: reads,
83
+ run: async (args, context) => {
84
+ const id = idOf(args);
85
+ if (!id) return "Pass the workflow's id as { \"id\": \"<id>\" }.";
86
+ return context.records.get("workflows", id).then(({ record, etag }) => JSON.stringify({
87
+ workflow: record,
88
+ etag
89
+ }), refused);
90
+ }
91
+ });
92
+ const checked = (definition) => {
93
+ const problems = checkDefinition(definition, { resources: stepTypeNames });
94
+ return JSON.stringify(problems.length > 0 ? {
95
+ valid: false,
96
+ problems
97
+ } : { valid: true });
98
+ };
99
+ /** Checks a definition, or a saved one with an edit, without saving anything. */
100
+ const validateWorkflowTool = defineTool({
101
+ name: "validate_workflow",
102
+ description: "Checks a definition as a save checks it, and saves nothing: pass a whole definition, or a workflow's id with an edit to check that edit of its saved definition. Returns JSON { valid: true }, or { valid: false, problems }, each problem { state, field, message } naming the state, the field and what was expected.",
103
+ parameters: {
104
+ type: "object",
105
+ properties: {
106
+ definition: definitionParameter,
107
+ id: idParameter,
108
+ edit: editParameter
109
+ }
110
+ },
111
+ annotations: reads,
112
+ run: async (args, context) => {
113
+ const id = idOf(args);
114
+ const edit = objectOf(args.edit);
115
+ if (args.definition !== void 0 && id === void 0 && edit === void 0) return checked(args.definition);
116
+ if (args.definition === void 0 && id && edit) return context.records.get("workflows", id).then(({ record }) => checked(mergePatch(record.definition, edit)), refused);
117
+ return "Pass either { \"definition\": { ... } } or { \"id\": \"<id>\", \"edit\": { ... } }.";
118
+ }
119
+ });
120
+ const runFields = [
121
+ "id",
122
+ "workflow_id",
123
+ "version",
124
+ "status",
125
+ "input",
126
+ "output",
127
+ "error",
128
+ "cause",
129
+ "resume_at",
130
+ "created_at",
131
+ "finished_at",
132
+ "duration_ms"
133
+ ];
134
+ const stepFields = [
135
+ "state",
136
+ "scope",
137
+ "seq",
138
+ "type",
139
+ "attempt",
140
+ "status",
141
+ "input",
142
+ "output",
143
+ "assigned",
144
+ "logs",
145
+ "error",
146
+ "cause",
147
+ "next",
148
+ "resume_at",
149
+ "duration_ms"
150
+ ];
151
+ /** A run with its steps in the order they ran, a page at a time. */
152
+ const getWorkflowRunTool = defineTool({
153
+ name: "get_workflow_run",
154
+ description: "Reads a run by its id, as JSON ({ run, steps, next_page_token }): its status (queued, running, waiting, succeeded or failed), input, output, error and cause, and its steps in the order they ran, each with its state, scope (empty, or the Map item it ran in), try, status (succeeded, retrying, caught or failed), input, output, assigned variables, logs, error and cause. A run goes on in the background: read it again while it is queued, running or waiting. Pass state to read only that state's steps; pass next_page_token back as page_token for more steps.",
155
+ parameters: {
156
+ type: "object",
157
+ properties: {
158
+ run_id: {
159
+ type: "string",
160
+ description: "The run's id, as run_workflow gives it."
161
+ },
162
+ state: {
163
+ type: "string",
164
+ description: "Only this state's steps."
165
+ },
166
+ page_size: {
167
+ type: "integer",
168
+ minimum: 1,
169
+ maximum: 100,
170
+ description: "Steps per page, 20 by default."
171
+ },
172
+ page_token: {
173
+ type: "string",
174
+ description: "The next_page_token of the page before."
175
+ }
176
+ },
177
+ required: ["run_id"]
178
+ },
179
+ annotations: reads,
180
+ run: async (args, context) => {
181
+ const id = textOf(args.run_id);
182
+ if (!id) return "Pass the run's id as { \"run_id\": \"<id>\" }.";
183
+ const run = await context.records.get("workflow_runs", id, { fields: runFields }).then(({ record }) => record, refused);
184
+ if (typeof run === "string") return run;
185
+ const state = textOf(args.state);
186
+ const params = {
187
+ filter: `run_id = ${JSON.stringify(id)}${state ? ` AND state = ${JSON.stringify(state)}` : ""}`,
188
+ fields: stepFields,
189
+ order_by: "id",
190
+ page_size: pageSizeOf(args.page_size, 20, 100),
191
+ ...textOf(args.page_token) && { page_token: textOf(args.page_token) }
192
+ };
193
+ return context.records.list("workflow_steps", params).then((page) => JSON.stringify({
194
+ run,
195
+ steps: page.items.map(({ etag: _etag, permissions: _permissions, ...step }) => step),
196
+ next_page_token: page.next_page_token
197
+ }), refused);
198
+ }
199
+ });
200
+ /** The tools that read workflows, their types and runs, and check definitions. */
201
+ const workflowReadTools = () => [
202
+ listWorkflowTypesTool,
203
+ listWorkflowsTool,
204
+ getWorkflowTool,
205
+ validateWorkflowTool,
206
+ getWorkflowRunTool
207
+ ];
208
+ //#endregion
209
+ export { getWorkflowRunTool, getWorkflowTool, listWorkflowTypesTool, listWorkflowsTool, validateWorkflowTool, workflowReadTools };
@@ -0,0 +1,35 @@
1
+ import { problemOf } from "../../error-handler.js";
2
+ import { refused } from "../../assistant/record-tool-args.js";
3
+ //#region src/workflows/mcp/workflow-tool-args.ts
4
+ const idParameter = {
5
+ type: "string",
6
+ description: "The workflow's id, as list_workflows gives it."
7
+ };
8
+ const definitionParameter = {
9
+ type: "object",
10
+ description: "The whole definition: { \"trigger\": { ... }, \"stateMachine\": { \"QueryLanguage\": \"JSONata\", \"StartAt\": ..., \"States\": { ... } } }, as the server's instructions describe it."
11
+ };
12
+ const editParameter = {
13
+ type: "object",
14
+ description: "A small edit of the saved definition as a JSON Merge Patch (RFC 7396): only what changes, at its place in the document. An object merges into the object there, null removes a member, and any other value, a list included, replaces it. Such as { \"stateMachine\": { \"States\": { \"Fetch\": { \"Arguments\": { \"url\": \"https://...\" } }, \"Old\": null } } }."
15
+ };
16
+ const idOf = (args) => typeof args.id === "string" && args.id ? args.id : void 0;
17
+ const objectOf = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
18
+ const textOf = (value) => typeof value === "string" ? value : void 0;
19
+ /** A page size given as a whole number, cut to `max`; `fallback` when none is given. */
20
+ const pageSizeOf = (value, fallback, max) => typeof value === "number" && Number.isInteger(value) && value > 0 ? Math.min(value, max) : fallback;
21
+ /**
22
+ * A save the workflow check refused, as `{ error, message, problems }`, each problem naming the state and field and
23
+ * what was expected; any other refusal as `refused` words it.
24
+ */
25
+ const workflowRefused = (error) => {
26
+ const problem = problemOf(error);
27
+ if (problem?.slug === "invalid-workflow") return JSON.stringify({
28
+ error: problem.slug,
29
+ message: "The definition does not fit the language; nothing was saved. Fix each problem and try again.",
30
+ problems: problem.extras.problems
31
+ });
32
+ return refused(error);
33
+ };
34
+ //#endregion
35
+ export { definitionParameter, editParameter, idOf, idParameter, objectOf, pageSizeOf, textOf, workflowRefused };
@@ -0,0 +1,7 @@
1
+ import { AssistantTool, ToolContext } from "../../assistant/tools.js";
2
+ import { WriteRecordToolsInput } from "../../assistant/write-tool-limits.js";
3
+ import "../../index.js";
4
+ //#region src/workflows/mcp/workflow-write-tools.d.ts
5
+ /** The tools that save and run workflows, on the resources `input` allows. */
6
+ export declare const workflowWriteTools: (input?: WriteRecordToolsInput) => AssistantTool<ToolContext>[];
7
+ //#endregion
@@ -0,0 +1,149 @@
1
+ import { notFound } from "../../problem.js";
2
+ import { defineTool } from "../../assistant/tools.js";
3
+ import { refused } from "../../assistant/record-tool-args.js";
4
+ import { createApproval, updateApproval } from "../../assistant/write-prompts.js";
5
+ import { approvalTexts, recordBefore } from "../../assistant/record-before.js";
6
+ import { notApproved, onlyNote, outside } from "../../assistant/write-tool-limits.js";
7
+ import { mergePatch } from "./merge-patch.js";
8
+ import { definitionParameter, editParameter, idOf, idParameter, objectOf, textOf, workflowRefused } from "./workflow-tool-args.js";
9
+ //#region src/workflows/mcp/workflow-write-tools.ts
10
+ /**
11
+ * `create_workflow`, `update_workflow` and `run_workflow`: saving and running workflows as the caller, through the
12
+ * resources' access rules and the save check, each after the user approves it in their client, as the record write
13
+ * tools ask. A refused definition comes back with its problems, each naming the state and field.
14
+ */
15
+ const writes = {
16
+ readOnlyHint: false,
17
+ destructiveHint: false,
18
+ openWorldHint: false
19
+ };
20
+ const nameParameter = {
21
+ type: "string",
22
+ description: "A name, 1 to 100 characters."
23
+ };
24
+ const descriptionParameter = {
25
+ type: "string",
26
+ description: "What it does, for people; at most 2000 characters."
27
+ };
28
+ const saved = (record, etag) => JSON.stringify({
29
+ id: record.id,
30
+ version: record.version,
31
+ etag
32
+ });
33
+ /** Creates a workflow once the user approves it; a definition the check refuses saves nothing. */
34
+ const createWorkflowTool = (input = {}) => defineTool({
35
+ name: "create_workflow",
36
+ description: `Creates a workflow from its name and whole definition, checked as validate_workflow checks it, and returns JSON { id, version, etag }. A definition that does not fit is refused with { error, problems }, each problem naming the state, the field and what was expected; nothing is saved then. The user approves each save in their client first.${onlyNote(input)}`,
37
+ parameters: {
38
+ type: "object",
39
+ properties: {
40
+ name: nameParameter,
41
+ description: descriptionParameter,
42
+ definition: definitionParameter
43
+ },
44
+ required: ["name", "definition"]
45
+ },
46
+ annotations: writes,
47
+ run: async (args, context) => {
48
+ const name = textOf(args.name);
49
+ const definition = objectOf(args.definition);
50
+ if (!name || !definition) return "Pass { \"name\": \"<name>\", \"definition\": { \"trigger\": ..., \"stateMachine\": ... } }.";
51
+ const refusal = outside(input, "workflows");
52
+ if (refusal) return refusal;
53
+ const data = {
54
+ name,
55
+ definition,
56
+ ...textOf(args.description) !== void 0 && { description: textOf(args.description) }
57
+ };
58
+ const { text, described } = await approvalTexts(context, "workflows");
59
+ const answer = await context.approve(createApproval(text, described, data));
60
+ if (answer !== "approved") return notApproved(answer);
61
+ return context.records.create("workflows", data).then(({ record, etag }) => saved(record, etag), workflowRefused);
62
+ }
63
+ });
64
+ /** Changes a workflow's definition by an edit or a whole new one, or its name or description, once the user approves it. */
65
+ const updateWorkflowTool = (input = {}) => defineTool({
66
+ name: "update_workflow",
67
+ description: `Changes a workflow: its definition by a small edit (edit, a JSON Merge Patch of the saved definition) or whole (definition), and its name or description. The changed definition is checked as validate_workflow checks it and saved as the workflow's next version; runs started before keep theirs. Returns JSON { id, version, etag }. A definition that does not fit is refused with { error, problems }, each naming the state, the field and what was expected; nothing is saved then. Pass the etag get_workflow gave to fail when someone changed it since. The user approves each save in their client first.${onlyNote(input)}`,
68
+ parameters: {
69
+ type: "object",
70
+ properties: {
71
+ id: idParameter,
72
+ edit: editParameter,
73
+ definition: definitionParameter,
74
+ name: nameParameter,
75
+ description: descriptionParameter,
76
+ etag: {
77
+ type: "string",
78
+ description: "The etag get_workflow gave, to fail when the workflow changed since."
79
+ }
80
+ },
81
+ required: ["id"]
82
+ },
83
+ annotations: writes,
84
+ run: async (args, context) => {
85
+ const id = idOf(args);
86
+ const edit = objectOf(args.edit);
87
+ const whole = objectOf(args.definition);
88
+ if (!id || edit && whole) return "Pass { \"id\": \"<id>\" } with either an edit or a whole definition, and any name or description to change.";
89
+ const refusal = outside(input, "workflows");
90
+ if (refusal) return refusal;
91
+ const before = await recordBefore(context, "workflows", id, textOf(args.etag) || void 0);
92
+ if (typeof before === "string") return before;
93
+ if (edit && !before) return refused(notFound("No workflows with this key that you may read"));
94
+ const definition = edit ? mergePatch(before.record.definition, edit) : whole;
95
+ const data = {
96
+ ...definition !== void 0 && { definition },
97
+ ...textOf(args.name) !== void 0 && { name: textOf(args.name) },
98
+ ...textOf(args.description) !== void 0 && { description: textOf(args.description) }
99
+ };
100
+ if (Object.keys(data).length === 0) return "Pass an edit, a definition, a name or a description to change; nothing was saved.";
101
+ const { text, described } = await approvalTexts(context, "workflows");
102
+ const answer = await context.approve(updateApproval(text, described, id, before?.record ?? {}, data));
103
+ if (answer !== "approved") return notApproved(answer);
104
+ const etag = textOf(args.etag) || (edit ? before.etag : void 0);
105
+ return context.records.update("workflows", id, data, { ...etag && { etag } }).then(({ record, etag: next }) => saved(record, next), workflowRefused);
106
+ }
107
+ });
108
+ /** Starts a run of a workflow with an input once the user approves it. */
109
+ const runWorkflowTool = (input = {}) => defineTool({
110
+ name: "run_workflow",
111
+ description: `Starts a run of a workflow's current version with an input, which its states read as $states.context.Execution.Input, and returns JSON { run_id, status }. The run goes on in the background, doing what its steps do (requests, mail, record writes) as the workflow's last editor: read it with get_workflow_run until its status is succeeded or failed. The user approves each run in their client first.${onlyNote(input)}`,
112
+ parameters: {
113
+ type: "object",
114
+ properties: {
115
+ id: idParameter,
116
+ input: { description: "The run's input, any JSON value, at most 1 MiB; an empty object by default." }
117
+ },
118
+ required: ["id"]
119
+ },
120
+ annotations: {
121
+ ...writes,
122
+ openWorldHint: true
123
+ },
124
+ run: async (args, context) => {
125
+ const id = idOf(args);
126
+ if (!id) return "Pass { \"id\": \"<id>\", \"input\": { ... } }.";
127
+ const refusal = outside(input, "workflow_runs");
128
+ if (refusal) return refusal;
129
+ const data = {
130
+ workflow_id: id,
131
+ input: args.input ?? {}
132
+ };
133
+ const { text, described } = await approvalTexts(context, "workflow_runs");
134
+ const answer = await context.approve(createApproval(text, described, data));
135
+ if (answer !== "approved") return notApproved(answer);
136
+ return context.records.create("workflow_runs", data).then(({ record }) => JSON.stringify({
137
+ run_id: record.id,
138
+ status: record.status
139
+ }), refused);
140
+ }
141
+ });
142
+ /** The tools that save and run workflows, on the resources `input` allows. */
143
+ const workflowWriteTools = (input = {}) => [
144
+ createWorkflowTool(input),
145
+ updateWorkflowTool(input),
146
+ runWorkflowTool(input)
147
+ ];
148
+ //#endregion
149
+ export { createWorkflowTool, runWorkflowTool, updateWorkflowTool, workflowWriteTools };
@@ -0,0 +1,27 @@
1
+ import { TenantValue } from "../types.js";
2
+ //#region src/workflows/options.d.ts
3
+ /** Workflows made in the admin app (`workflows: {}`), kept with their runs in a schema of the app's database. */
4
+ export type WorkflowsOptions = {
5
+ /** The schema of the workflows, their versions, runs and steps; default `workflows`. */
6
+ schema?: string;
7
+ /**
8
+ * The environment variables workflows may read, each as `$states.context.Secrets.<NAME>`; no other is reachable.
9
+ * Their values are blanked out of all a run records. Startup stops when one is unset or shorter than 8 characters.
10
+ */
11
+ secrets?: string[];
12
+ /**
13
+ * The roles a workflow's last editor holds now in its tenant, which its records steps act with. Default: read from
14
+ * the project's Better Auth (`auth`); a project that signs people in otherwise passes its own, which the CLI reads
15
+ * from the default export of `workflows/saved-by-roles.ts`.
16
+ */
17
+ savedByRoles?: (user: {
18
+ id: string;
19
+ tenant?: TenantValue;
20
+ }) => Promise<string[]>;
21
+ /**
22
+ * Lets the `app:http` step reach private, loopback and link-local addresses, for development and tests. Default
23
+ * false: a URL whose host resolves to one fails the step, so a workflow cannot reach the app's own network.
24
+ */
25
+ allowPrivateAddresses?: boolean;
26
+ };
27
+ //#endregion