@doclift/workflows-mcp 0.1.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 +114 -0
- package/dist/client.d.ts +30 -0
- package/dist/client.js +92 -0
- package/dist/guidance.d.ts +5 -0
- package/dist/guidance.js +219 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +143 -0
- package/dist/tools/capabilities.d.ts +3 -0
- package/dist/tools/capabilities.js +13 -0
- package/dist/tools/checks.d.ts +3 -0
- package/dist/tools/checks.js +233 -0
- package/dist/tools/datasets.d.ts +3 -0
- package/dist/tools/datasets.js +37 -0
- package/dist/tools/documents.d.ts +3 -0
- package/dist/tools/documents.js +23 -0
- package/dist/tools/images.d.ts +3 -0
- package/dist/tools/images.js +24 -0
- package/dist/tools/index.d.ts +3 -0
- package/dist/tools/index.js +28 -0
- package/dist/tools/publication.d.ts +3 -0
- package/dist/tools/publication.js +17 -0
- package/dist/tools/render.d.ts +3 -0
- package/dist/tools/render.js +25 -0
- package/dist/tools/sections.d.ts +3 -0
- package/dist/tools/sections.js +118 -0
- package/dist/tools/shared.d.ts +20 -0
- package/dist/tools/shared.js +38 -0
- package/dist/tools/theme.d.ts +3 -0
- package/dist/tools/theme.js +27 -0
- package/dist/tools/variables.d.ts +3 -0
- package/dist/tools/variables.js +53 -0
- package/dist/tools/workflows.d.ts +3 -0
- package/dist/tools/workflows.js +56 -0
- package/package.json +44 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { respond, templateId } from './shared.js';
|
|
3
|
+
export const registerChecks = (server, api, base) => {
|
|
4
|
+
// ------------------------------------------------------ integrity and shape
|
|
5
|
+
server.registerTool('workflow_validate', {
|
|
6
|
+
title: 'Check a workflow without writing',
|
|
7
|
+
description: 'Runs the integrity check and answers with every anomaly, each carrying its type, whether it ' +
|
|
8
|
+
'blocks publication, and the node it points at. Optionally takes content you have NOT written ' +
|
|
9
|
+
'yet and reports what sanitising would strip from it, plus `inert_tokens` — <variable> ' +
|
|
10
|
+
'elements written without the interpolation class, which print their own name — ' +
|
|
11
|
+
'`unreachable_images`, sources the engine cannot fetch, `ejected_from_paragraph`, block ' +
|
|
12
|
+
'elements written inside a <p> that the parser moves out, and `removed_css_declarations` — ' +
|
|
13
|
+
'properties dropped from inside a `style` that survives, which is how text-transform or a ' +
|
|
14
|
+
'shadow disappears while the attribute stays and every other check reads clean. Call it ' +
|
|
15
|
+
'before publishing.',
|
|
16
|
+
inputSchema: {
|
|
17
|
+
id: templateId,
|
|
18
|
+
content: z
|
|
19
|
+
.array(z.string())
|
|
20
|
+
.optional()
|
|
21
|
+
.describe('HTML fragments to dry-run through the sanitiser before you save them.'),
|
|
22
|
+
scope: z
|
|
23
|
+
.enum(['content', 'running_title'])
|
|
24
|
+
.optional()
|
|
25
|
+
.describe('Which sanitiser to dry-run against. `content` is a section body; `running_title` is a ' +
|
|
26
|
+
'part header or footer, where script, style and iframe are removed WITH their text. ' +
|
|
27
|
+
'Defaults to content.'),
|
|
28
|
+
},
|
|
29
|
+
annotations: { readOnlyHint: true },
|
|
30
|
+
}, async ({ id, content, scope }) => respond(await api.post(`${base}/templates/${id}/validate`, {
|
|
31
|
+
...(content === undefined ? {} : { content }),
|
|
32
|
+
...(scope === undefined ? {} : { scope }),
|
|
33
|
+
})));
|
|
34
|
+
server.registerTool('workflow_payload_contract', {
|
|
35
|
+
title: 'What a generation payload must carry',
|
|
36
|
+
description: 'The variables a generation call has to send for this workflow, which are required, which rows ' +
|
|
37
|
+
'a collection must carry, and the limits on them. Read this before calling workflow_render.',
|
|
38
|
+
inputSchema: { id: templateId },
|
|
39
|
+
annotations: { readOnlyHint: true },
|
|
40
|
+
}, async ({ id }) => respond(await api.get(`${base}/templates/${id}/payload_contract`)));
|
|
41
|
+
// ---------------------------------------------------------------- self-check
|
|
42
|
+
server.registerTool('workflow_selfcheck', {
|
|
43
|
+
title: 'Check your own work before saying it is done',
|
|
44
|
+
description: 'Answers a checklist from the workflow\'s actual state — not a reminder, a reading of the ' +
|
|
45
|
+
'database. Covers what saved cleanly and is still wrong: anomalies, tokens the interpolator ' +
|
|
46
|
+
'will never read, pictures the engine cannot fetch, variables declared and never used, and ' +
|
|
47
|
+
'whether a PDF exists that is newer than your last edit. It also names the one thing it ' +
|
|
48
|
+
'cannot check — that the document is the one that was asked for. Call it before reporting ' +
|
|
49
|
+
'a workflow as finished.',
|
|
50
|
+
inputSchema: { id: templateId },
|
|
51
|
+
annotations: { readOnlyHint: true },
|
|
52
|
+
}, async ({ id }) => {
|
|
53
|
+
const [validation, template, payload] = await Promise.all([
|
|
54
|
+
api.post(`${base}/templates/${id}/validate`),
|
|
55
|
+
api.get(`${base}/templates/${id}`),
|
|
56
|
+
api.get(`${base}/templates/${id}/payload_contract`),
|
|
57
|
+
]);
|
|
58
|
+
if (!validation.ok)
|
|
59
|
+
return respond(validation);
|
|
60
|
+
return {
|
|
61
|
+
content: [
|
|
62
|
+
{
|
|
63
|
+
type: 'text',
|
|
64
|
+
text: JSON.stringify(selfcheck(validation.body, template.ok ? template.body : null, payload.ok ? payload.body : null), null, 2),
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
// A line whose evidence the API did not send is `unknown`, never `pass`. Reading
|
|
71
|
+
// silence as success is the exact habit this tool exists to break, and it would
|
|
72
|
+
// break it in the one case where the server is older than the client.
|
|
73
|
+
const absent = (check, next) => ({
|
|
74
|
+
check,
|
|
75
|
+
status: 'unknown',
|
|
76
|
+
detail: 'This server did not answer with the evidence for this line.',
|
|
77
|
+
next,
|
|
78
|
+
});
|
|
79
|
+
const selfcheck = (validation, template, payload) => {
|
|
80
|
+
const stored = validation.stored;
|
|
81
|
+
const blocking = (validation.anomalies ?? []).filter((anomaly) => anomaly.blocking);
|
|
82
|
+
const lines = [];
|
|
83
|
+
lines.push({
|
|
84
|
+
check: 'Nothing blocks publication',
|
|
85
|
+
status: blocking.length === 0 ? 'pass' : 'todo',
|
|
86
|
+
detail: blocking.length === 0 ? 'No blocking anomaly.' : blocking,
|
|
87
|
+
...(blocking.length === 0 ? {} : { next: 'Fix each anomaly above, then call workflow_selfcheck again.' }),
|
|
88
|
+
});
|
|
89
|
+
const listLine = (key, check, next) => {
|
|
90
|
+
if (!stored || stored[key] === undefined)
|
|
91
|
+
return absent(check, next);
|
|
92
|
+
const found = stored[key] ?? [];
|
|
93
|
+
return {
|
|
94
|
+
check,
|
|
95
|
+
status: found.length === 0 ? 'pass' : 'todo',
|
|
96
|
+
detail: found.length === 0 ? 'None found in the stored content.' : found,
|
|
97
|
+
...(found.length === 0 ? {} : { next }),
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
lines.push(listLine('inert_tokens', 'No variable token is inert', 'Rewrite each token with the class capabilities.content.tokens gives, then section_update.'));
|
|
101
|
+
lines.push(listLine('unreachable_images', 'No picture is unreachable', 'Upload each picture with image_upload and cite the URL it answers with.'));
|
|
102
|
+
lines.push(listLine('layout_tables', 'No table is used for a column layout with its rules showing', 'Put capabilities.authoring.table.layout_row_class on the <tr>: it takes the rules away and ' +
|
|
103
|
+
'leaves the columns. A ruled table around two blocks set side by side is a shape no ' +
|
|
104
|
+
'hand-made document has.'));
|
|
105
|
+
if (!stored || stored.unused_variables === undefined) {
|
|
106
|
+
lines.push(absent('No variable is declared and never used', 'Read the stored content and compare.'));
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
const unused = stored.unused_variables;
|
|
110
|
+
lines.push({
|
|
111
|
+
check: 'No variable is declared and never used',
|
|
112
|
+
status: unused.length === 0 ? 'pass' : 'todo',
|
|
113
|
+
detail: unused.length === 0 ? 'Every declared variable is cited.' : unused,
|
|
114
|
+
// Usually a typo: the token was written with one spelling and the variable
|
|
115
|
+
// declared with another, leaving a dead name behind rather than an error.
|
|
116
|
+
...(unused.length === 0
|
|
117
|
+
? {}
|
|
118
|
+
: { next: 'Either cite them, or delete them — an unused name is often a token typed differently.' }),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
const render = stored?.last_render;
|
|
122
|
+
if (!stored || stored.last_render === undefined) {
|
|
123
|
+
lines.push(absent('A PDF exists and is newer than your last edit', 'Call workflow_render, then read the PDF.'));
|
|
124
|
+
}
|
|
125
|
+
else if (!render || !render.at) {
|
|
126
|
+
lines.push({
|
|
127
|
+
check: 'A PDF exists and is newer than your last edit',
|
|
128
|
+
status: 'todo',
|
|
129
|
+
detail: 'This workflow has never been generated.',
|
|
130
|
+
next: 'Publish it, then call workflow_render with a dataset.',
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
lines.push({
|
|
135
|
+
check: 'A PDF exists and is newer than your last edit',
|
|
136
|
+
status: render.stale ? 'todo' : 'pass',
|
|
137
|
+
detail: render,
|
|
138
|
+
...(render.stale
|
|
139
|
+
? { next: 'You edited after the last generation. Render again — what you read is not what you have.' }
|
|
140
|
+
: {}),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
// Deliberately never `pass`. No tool can compare a document to an intention it
|
|
144
|
+
// never saw, and a checklist that ticked this line would be lying about the
|
|
145
|
+
// only step that catches what the others cannot.
|
|
146
|
+
// A weight nothing carries is rounded by the engine and printed as something
|
|
147
|
+
// nobody chose — the one part of this that is a fault rather than a taste. The
|
|
148
|
+
// rest is handed over: matching the sizes of an original is a comparison only
|
|
149
|
+
// the caller can make.
|
|
150
|
+
const type = stored?.typography;
|
|
151
|
+
if (!stored || type === undefined) {
|
|
152
|
+
lines.push(absent('Sizes and weights are the ones you meant', 'Compare the PDF with the original.'));
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
const orphaned = type.weights_no_family_carries ?? [];
|
|
156
|
+
lines.push({
|
|
157
|
+
check: 'Sizes and weights are the ones you meant',
|
|
158
|
+
status: orphaned.length > 0 ? 'todo' : 'unknown',
|
|
159
|
+
detail: {
|
|
160
|
+
sizes_pt: type.sizes_pt ?? [],
|
|
161
|
+
weights: type.weights ?? [],
|
|
162
|
+
weights_no_family_carries: orphaned,
|
|
163
|
+
off_the_editor_scale: type.sizes_off_the_editor_scale ?? [],
|
|
164
|
+
how: orphaned.length > 0
|
|
165
|
+
? 'No provisioned family carries these weights. The engine will round them and print a ' +
|
|
166
|
+
'thickness nobody chose — pick one the family declares in capabilities.authoring.fonts.'
|
|
167
|
+
: 'These are what the document asks for. Whether they match the original is a reading: ' +
|
|
168
|
+
'put the two PDFs side by side and compare a heading, a label and a line of body text. ' +
|
|
169
|
+
'A size off the editor scale is NOT refused and renders exactly as written — it is ' +
|
|
170
|
+
'simply one the editor menu does not offer, which only matters when the document is ' +
|
|
171
|
+
'meant to look hand-made. And this cannot tell whether a weight belongs to the family ' +
|
|
172
|
+
'a passage is set in, only whether some family carries it.',
|
|
173
|
+
},
|
|
174
|
+
...(orphaned.length > 0 ? { next: 'Replace each weight with one the chosen family declares.' } : {}),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
// Where a break lands is only visible in the PDF, so this line is never a
|
|
178
|
+
// `pass`: what the server can say is whether anybody decided, and what they
|
|
179
|
+
// decided. The rest is a reading the caller has to do.
|
|
180
|
+
const breaks = stored?.page_breaks;
|
|
181
|
+
if (!stored || breaks === undefined) {
|
|
182
|
+
lines.push(absent('Page breaks fall where you meant them to', 'Read the PDF and check each break.'));
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
const declared = breaks.declared ?? [];
|
|
186
|
+
const sections = breaks.sections ?? 0;
|
|
187
|
+
// Gated on how much there is to paginate, not on how many nodes it is cut
|
|
188
|
+
// into: a four-section invoice of 5 000 characters comes out on one page and
|
|
189
|
+
// needs no break at all, while a twelve-section deed of 14 500 took five.
|
|
190
|
+
// Counting sections nagged the first of those for nothing.
|
|
191
|
+
const undecided = declared.length === 0 && (breaks.content_length ?? 0) > 9000;
|
|
192
|
+
lines.push({
|
|
193
|
+
check: 'Page breaks fall where you meant them to',
|
|
194
|
+
status: undecided ? 'todo' : 'unknown',
|
|
195
|
+
detail: {
|
|
196
|
+
declared,
|
|
197
|
+
sections,
|
|
198
|
+
content_length: breaks.content_length ?? null,
|
|
199
|
+
how: undecided
|
|
200
|
+
? 'Not one section of this document asks for a break, so the engine paginated it wherever ' +
|
|
201
|
+
'the text ran out. Set page_break on the sections that open a part — see ' +
|
|
202
|
+
'capabilities.section.page_breaks — or confirm from the PDF that running on is what you want.'
|
|
203
|
+
: 'These are the breaks you asked for. Only the PDF shows where they landed, and whether a ' +
|
|
204
|
+
'heading was left alone at the foot of a page: fetch it and look.',
|
|
205
|
+
},
|
|
206
|
+
...(undecided ? { next: 'Decide the breaks, or read the PDF and confirm the engine chose well.' } : {}),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
const palette = stored?.palette;
|
|
210
|
+
lines.push({
|
|
211
|
+
check: 'The document is the one that was asked for',
|
|
212
|
+
status: 'unknown',
|
|
213
|
+
detail: {
|
|
214
|
+
how: 'Only you can check this. Fetch the URL workflow_render answers with, read the PDF, and ' +
|
|
215
|
+
'compare it to the request: the wording, the order of the sections, the values in place of ' +
|
|
216
|
+
'the tokens, the page count.',
|
|
217
|
+
// Handed over rather than judged: no tool knows what the original looked
|
|
218
|
+
// like. It is here so the one question that cannot be answered can at
|
|
219
|
+
// least be asked against something concrete — a document reproduced from
|
|
220
|
+
// a coloured original and coming back with an empty palette has lost
|
|
221
|
+
// something nobody will report.
|
|
222
|
+
colours_this_document_declares: palette ?? 'unknown',
|
|
223
|
+
},
|
|
224
|
+
next: 'Report what you could not reproduce and why, rather than presenting a partial result as complete.',
|
|
225
|
+
});
|
|
226
|
+
return {
|
|
227
|
+
workflow: template === null ? null : { id: template['id'], title: template['title'], published: template['published'] },
|
|
228
|
+
publishable: validation.publishable ?? null,
|
|
229
|
+
required_variables: payload === null ? null : payload['required'],
|
|
230
|
+
checklist: lines,
|
|
231
|
+
outstanding: lines.filter((line) => line.status === 'todo').length,
|
|
232
|
+
};
|
|
233
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { respond, templateId } from './shared.js';
|
|
3
|
+
export const registerDatasets = (server, api, base) => {
|
|
4
|
+
// -------------------------------------------------------------- the datasets
|
|
5
|
+
server.registerTool('dataset_list', {
|
|
6
|
+
title: 'List the preview datasets',
|
|
7
|
+
description: 'The sets of values the preview injects in place of a real payload.',
|
|
8
|
+
inputSchema: { template_id: templateId },
|
|
9
|
+
annotations: { readOnlyHint: true },
|
|
10
|
+
}, async ({ template_id }) => respond(await api.get(`${base}/templates/${template_id}/datasets`)));
|
|
11
|
+
server.registerTool('dataset_create', {
|
|
12
|
+
title: 'Create a preview dataset',
|
|
13
|
+
description: '`values` is a FLAT map of variable name to value. A collection appears as an array of flat ' +
|
|
14
|
+
'objects, exactly as in a generation payload. Anything more nested is refused.',
|
|
15
|
+
inputSchema: {
|
|
16
|
+
template_id: templateId,
|
|
17
|
+
name: z.string().min(1),
|
|
18
|
+
values: z.record(z.unknown()),
|
|
19
|
+
},
|
|
20
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
21
|
+
}, async ({ template_id, ...dataset }) => respond(await api.post(`${base}/templates/${template_id}/datasets`, { dataset })));
|
|
22
|
+
server.registerTool('dataset_update', {
|
|
23
|
+
title: 'Change a preview dataset',
|
|
24
|
+
inputSchema: {
|
|
25
|
+
template_id: templateId,
|
|
26
|
+
id: z.number().int(),
|
|
27
|
+
name: z.string().min(1).optional(),
|
|
28
|
+
values: z.record(z.unknown()).optional(),
|
|
29
|
+
},
|
|
30
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
31
|
+
}, async ({ template_id, id, ...dataset }) => respond(await api.patch(`${base}/templates/${template_id}/datasets/${id}`, { dataset })));
|
|
32
|
+
server.registerTool('dataset_delete', {
|
|
33
|
+
title: 'Delete a preview dataset',
|
|
34
|
+
inputSchema: { template_id: templateId, id: z.number().int() },
|
|
35
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
36
|
+
}, async ({ template_id, id }) => respond(await api.delete(`${base}/templates/${template_id}/datasets/${id}`)));
|
|
37
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { respond, templateId } from './shared.js';
|
|
3
|
+
export const registerDocuments = (server, api, base) => {
|
|
4
|
+
// ------------------------------------------------------------- the document
|
|
5
|
+
server.registerTool('workflow_document_get', {
|
|
6
|
+
title: 'Export the whole workflow',
|
|
7
|
+
description: 'The entire workflow as one manifest — tree, variables, datasets, theme, page setup. Useful to ' +
|
|
8
|
+
'copy a workflow, or to read one you did not build.',
|
|
9
|
+
inputSchema: { id: templateId },
|
|
10
|
+
annotations: { readOnlyHint: true },
|
|
11
|
+
}, async ({ id }) => respond(await api.get(`${base}/templates/${id}/document`)));
|
|
12
|
+
server.registerTool('workflow_document_put', {
|
|
13
|
+
title: 'Replace the whole workflow',
|
|
14
|
+
description: 'Wipes the tree, the variables and the datasets and rebuilds them from the manifest. This is a ' +
|
|
15
|
+
'replacement, not a merge: anything absent from the document is gone. Prefer the section tools ' +
|
|
16
|
+
'for incremental work.',
|
|
17
|
+
inputSchema: {
|
|
18
|
+
id: templateId,
|
|
19
|
+
document: z.record(z.unknown()).describe('A manifest in the shape workflow_document_get returns.'),
|
|
20
|
+
},
|
|
21
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
22
|
+
}, async ({ id, document }) => respond(await api.put(`${base}/templates/${id}/document`, { document })));
|
|
23
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { respond, templateId } from './shared.js';
|
|
3
|
+
export const registerImages = (server, api, base) => {
|
|
4
|
+
// ---------------------------------------------------------------- the images
|
|
5
|
+
server.registerTool('image_upload', {
|
|
6
|
+
title: 'Store a picture for use inside content',
|
|
7
|
+
description: 'Answers with the URL to put in an <img src>. The renderer makes no network request, so a ' +
|
|
8
|
+
'picture cited by an external URL prints an empty frame and nothing refuses it: every ' +
|
|
9
|
+
'picture in a section has to come through here. workflow_validate reports the ones that ' +
|
|
10
|
+
'would not resolve, under `unreachable_images`.',
|
|
11
|
+
inputSchema: {
|
|
12
|
+
template_id: templateId,
|
|
13
|
+
filename: z.string().min(1),
|
|
14
|
+
content_type: z.string().min(1),
|
|
15
|
+
data: z.string().min(1).describe('Base64 of the file, without a data: prefix.'),
|
|
16
|
+
},
|
|
17
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
18
|
+
}, async ({ template_id, ...image }) => respond(await api.post(`${base}/templates/${template_id}/images`, { image })));
|
|
19
|
+
server.registerTool('image_list', {
|
|
20
|
+
title: 'List the pictures of a workflow',
|
|
21
|
+
inputSchema: { template_id: templateId },
|
|
22
|
+
annotations: { readOnlyHint: true },
|
|
23
|
+
}, async ({ template_id }) => respond(await api.get(`${base}/templates/${template_id}/images`)));
|
|
24
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The registrations, in the order the tools were declared. `base` is computed
|
|
2
|
+
// once here and handed down: repeating it in each module is the duplication the
|
|
3
|
+
// rest of this package avoids.
|
|
4
|
+
import { registerCapabilities } from './capabilities.js';
|
|
5
|
+
import { registerWorkflows } from './workflows.js';
|
|
6
|
+
import { registerChecks } from './checks.js';
|
|
7
|
+
import { registerPublication } from './publication.js';
|
|
8
|
+
import { registerDocuments } from './documents.js';
|
|
9
|
+
import { registerTheme } from './theme.js';
|
|
10
|
+
import { registerSections } from './sections.js';
|
|
11
|
+
import { registerVariables } from './variables.js';
|
|
12
|
+
import { registerDatasets } from './datasets.js';
|
|
13
|
+
import { registerImages } from './images.js';
|
|
14
|
+
import { registerRender } from './render.js';
|
|
15
|
+
export const registerTools = (server, api) => {
|
|
16
|
+
const base = '/api/v1/workflows';
|
|
17
|
+
registerCapabilities(server, api, base);
|
|
18
|
+
registerWorkflows(server, api, base);
|
|
19
|
+
registerChecks(server, api, base);
|
|
20
|
+
registerPublication(server, api, base);
|
|
21
|
+
registerDocuments(server, api, base);
|
|
22
|
+
registerTheme(server, api, base);
|
|
23
|
+
registerSections(server, api, base);
|
|
24
|
+
registerVariables(server, api, base);
|
|
25
|
+
registerDatasets(server, api, base);
|
|
26
|
+
registerImages(server, api, base);
|
|
27
|
+
registerRender(server, api);
|
|
28
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { respond, templateId } from './shared.js';
|
|
2
|
+
export const registerPublication = (server, api, base) => {
|
|
3
|
+
server.registerTool('workflow_publish', {
|
|
4
|
+
title: 'Publish a workflow',
|
|
5
|
+
description: 'Publishing is refused while a blocking anomaly stands — run workflow_validate first. Only a ' +
|
|
6
|
+
'published workflow can be generated from.',
|
|
7
|
+
inputSchema: { id: templateId },
|
|
8
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
9
|
+
}, async ({ id }) => respond(await api.post(`${base}/templates/${id}/publication`)));
|
|
10
|
+
server.registerTool('workflow_unpublish', {
|
|
11
|
+
title: 'Withdraw a workflow',
|
|
12
|
+
description: 'Never gated on integrity: a workflow whose references broke after publication is exactly one ' +
|
|
13
|
+
'that has to be withdrawable.',
|
|
14
|
+
inputSchema: { id: templateId },
|
|
15
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
16
|
+
}, async ({ id }) => respond(await api.delete(`${base}/templates/${id}/publication`)));
|
|
17
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { respond, templateId } from './shared.js';
|
|
3
|
+
export const registerRender = (server, api) => {
|
|
4
|
+
// --------------------------------------------------------------- generation
|
|
5
|
+
server.registerTool('workflow_render', {
|
|
6
|
+
title: 'Generate a PDF',
|
|
7
|
+
description: 'Runs the real generation pipeline and answers with the document. The workflow must be ' +
|
|
8
|
+
'published. `variables` has to satisfy workflow_payload_contract — a required variable whose ' +
|
|
9
|
+
'KEY is absent is refused, while a key sent empty is accepted. This is the only honest way to ' +
|
|
10
|
+
'check that a reproduction matches its original. A sandbox key stamps a watermark across every ' +
|
|
11
|
+
'page: letters that appear in the PDF and in no content you wrote.',
|
|
12
|
+
inputSchema: {
|
|
13
|
+
id: templateId,
|
|
14
|
+
variables: z.record(z.unknown()).describe('Flat map, collections as arrays of flat objects.'),
|
|
15
|
+
tag: z.string().optional().describe('Free label carried back on the request.'),
|
|
16
|
+
},
|
|
17
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
18
|
+
}, async ({ id, variables, tag }) => respond(await api.post('/api/v1/document_requests', {
|
|
19
|
+
document_request: {
|
|
20
|
+
type: 'synchrone',
|
|
21
|
+
document_generations: [{ template_id: id, variables }],
|
|
22
|
+
...(tag === undefined ? {} : { tag }),
|
|
23
|
+
},
|
|
24
|
+
})));
|
|
25
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { condition, placements, respond, runningTitles, sectionId, templateId } from './shared.js';
|
|
3
|
+
export const registerSections = (server, api, base) => {
|
|
4
|
+
// ---------------------------------------------------------------- the tree
|
|
5
|
+
server.registerTool('section_tree', {
|
|
6
|
+
title: 'Read the whole tree',
|
|
7
|
+
description: 'Every section of the workflow, nested as it prints.',
|
|
8
|
+
inputSchema: { template_id: templateId },
|
|
9
|
+
annotations: { readOnlyHint: true },
|
|
10
|
+
}, async ({ template_id }) => respond(await api.get(`${base}/templates/${template_id}/sections`)));
|
|
11
|
+
server.registerTool('section_get', {
|
|
12
|
+
title: 'Read one section',
|
|
13
|
+
inputSchema: { template_id: templateId, id: sectionId },
|
|
14
|
+
annotations: { readOnlyHint: true },
|
|
15
|
+
}, async ({ template_id, id }) => respond(await api.get(`${base}/templates/${template_id}/sections/${id}`)));
|
|
16
|
+
server.registerTool('section_create', {
|
|
17
|
+
title: 'Add a section',
|
|
18
|
+
description: 'A node is EITHER a group — a title, a condition and children — OR a content section. Never ' +
|
|
19
|
+
'both: a group refuses content, and only an image_with_variable section carries placements. ' +
|
|
20
|
+
'The tree is three levels deep at most (group → group → section). A group may carry its whole ' +
|
|
21
|
+
'subtree in this one call, through `children`.',
|
|
22
|
+
inputSchema: {
|
|
23
|
+
template_id: templateId,
|
|
24
|
+
kind: z.string().describe('See capabilities.section.kinds and the `carries` matrix.'),
|
|
25
|
+
title: z.string().min(1),
|
|
26
|
+
content: z
|
|
27
|
+
.string()
|
|
28
|
+
.optional()
|
|
29
|
+
.describe('HTML, sanitised on write. Anything off the allow-list is removed WITHOUT AN ERROR — ' +
|
|
30
|
+
'dry-run it through workflow_validate first. A variable token is recognised by its ' +
|
|
31
|
+
'CLASS, not by its tag: <variable class="editor-text-variable non-editable-content editor-parsed">name</variable>, the name being ' +
|
|
32
|
+
'the inner text. Take the exact string from capabilities.content.tokens — a token ' +
|
|
33
|
+
'without the editor-parsed class prints the name instead of the value, and every ' +
|
|
34
|
+
'other check passes.'),
|
|
35
|
+
parent_id: z.number().int().nullable().optional().describe('Must name a group of the same workflow.'),
|
|
36
|
+
position: z
|
|
37
|
+
.number()
|
|
38
|
+
.int()
|
|
39
|
+
.optional()
|
|
40
|
+
.describe('Rank among siblings, zero-based and renumbered on write — read the answer for what it became.'),
|
|
41
|
+
layout: z.string().optional(),
|
|
42
|
+
page_break: z.string().optional(),
|
|
43
|
+
repeat_over: z
|
|
44
|
+
.string()
|
|
45
|
+
.nullable()
|
|
46
|
+
.optional()
|
|
47
|
+
.describe('Name of a collection variable: the whole node prints once per row. Nesting one repetition ' +
|
|
48
|
+
'inside another is refused in both directions.'),
|
|
49
|
+
condition: condition.optional(),
|
|
50
|
+
placements: placements.optional(),
|
|
51
|
+
running_titles: runningTitles.optional(),
|
|
52
|
+
children: z.array(z.record(z.unknown())).optional().describe('Nested sections, same shape, recursive.'),
|
|
53
|
+
},
|
|
54
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
55
|
+
}, async ({ template_id, ...section }) => respond(await api.post(`${base}/templates/${template_id}/sections`, { section })));
|
|
56
|
+
server.registerTool('section_update', {
|
|
57
|
+
title: 'Change a section',
|
|
58
|
+
description: 'Only the keys you send are written. `kind` is fixed at creation. Answers with the whole tree, ' +
|
|
59
|
+
'so you see what the database made of the change rather than guessing.',
|
|
60
|
+
inputSchema: {
|
|
61
|
+
template_id: templateId,
|
|
62
|
+
id: sectionId,
|
|
63
|
+
title: z.string().min(1).optional(),
|
|
64
|
+
content: z.string().optional(),
|
|
65
|
+
layout: z.string().optional(),
|
|
66
|
+
page_break: z.string().optional(),
|
|
67
|
+
repeat_over: z.string().nullable().optional(),
|
|
68
|
+
condition: condition.optional(),
|
|
69
|
+
placements: placements.optional(),
|
|
70
|
+
running_titles: runningTitles.optional(),
|
|
71
|
+
},
|
|
72
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
73
|
+
}, async ({ template_id, id, ...section }) => respond(await api.patch(`${base}/templates/${template_id}/sections/${id}`, { section })));
|
|
74
|
+
server.registerTool('section_move', {
|
|
75
|
+
title: 'Move a section',
|
|
76
|
+
description: 'Changes a section\'s parent and rank. A group that already holds a group cannot enter another ' +
|
|
77
|
+
'group — that is how a fourth level would appear by drift.',
|
|
78
|
+
inputSchema: {
|
|
79
|
+
template_id: templateId,
|
|
80
|
+
id: sectionId,
|
|
81
|
+
parent_id: z.number().int().nullable().describe('null moves it to the root.'),
|
|
82
|
+
position: z.number().int(),
|
|
83
|
+
},
|
|
84
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
85
|
+
}, async ({ template_id, id, parent_id, position }) => respond(await api.patch(`${base}/templates/${template_id}/sections/${id}/move`, {
|
|
86
|
+
section: { parent_id, position },
|
|
87
|
+
})));
|
|
88
|
+
server.registerTool('section_duplicate', {
|
|
89
|
+
title: 'Duplicate a section',
|
|
90
|
+
description: 'Deep copy of the section and its subtree, in the same workflow, backdrops included.',
|
|
91
|
+
inputSchema: { template_id: templateId, id: sectionId },
|
|
92
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
93
|
+
}, async ({ template_id, id }) => respond(await api.post(`${base}/templates/${template_id}/sections/${id}/duplicate`)));
|
|
94
|
+
server.registerTool('section_delete', {
|
|
95
|
+
title: 'Delete a section',
|
|
96
|
+
description: 'Deleting a group takes its whole subtree with it.',
|
|
97
|
+
inputSchema: { template_id: templateId, id: sectionId },
|
|
98
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
99
|
+
}, async ({ template_id, id }) => respond(await api.delete(`${base}/templates/${template_id}/sections/${id}`)));
|
|
100
|
+
server.registerTool('section_background_set', {
|
|
101
|
+
title: 'Lay a backdrop under an image section',
|
|
102
|
+
description: 'The picture an `image_with_variable` section places its labels over. Sent as base64. Refused ' +
|
|
103
|
+
'on any other kind of section, and bounded by capabilities.limits.image_bytes.',
|
|
104
|
+
inputSchema: {
|
|
105
|
+
template_id: templateId,
|
|
106
|
+
id: sectionId,
|
|
107
|
+
filename: z.string().min(1),
|
|
108
|
+
content_type: z.string().min(1),
|
|
109
|
+
data: z.string().min(1).describe('Base64 of the file, without a data: prefix.'),
|
|
110
|
+
},
|
|
111
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
112
|
+
}, async ({ template_id, id, ...background }) => respond(await api.patch(`${base}/templates/${template_id}/sections/${id}/background`, { background })));
|
|
113
|
+
server.registerTool('section_background_remove', {
|
|
114
|
+
title: 'Remove a backdrop',
|
|
115
|
+
inputSchema: { template_id: templateId, id: sectionId },
|
|
116
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
117
|
+
}, async ({ template_id, id }) => respond(await api.delete(`${base}/templates/${template_id}/sections/${id}/background`)));
|
|
118
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ApiResult } from '../client.js';
|
|
3
|
+
export declare const respond: <T>(result: ApiResult<T>) => {
|
|
4
|
+
content: {
|
|
5
|
+
type: "text";
|
|
6
|
+
text: string;
|
|
7
|
+
}[];
|
|
8
|
+
isError?: never;
|
|
9
|
+
} | {
|
|
10
|
+
isError: boolean;
|
|
11
|
+
content: {
|
|
12
|
+
type: "text";
|
|
13
|
+
text: string;
|
|
14
|
+
}[];
|
|
15
|
+
};
|
|
16
|
+
export declare const templateId: z.ZodNumber;
|
|
17
|
+
export declare const sectionId: z.ZodNumber;
|
|
18
|
+
export declare const condition: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
19
|
+
export declare const placements: z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>, "many">;
|
|
20
|
+
export declare const runningTitles: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
// Every tool answers with JSON text. A tool that formatted prose would be
|
|
3
|
+
// guessing what the caller wants to read, and the caller is a model that will do
|
|
4
|
+
// better with the payload than with a summary of it.
|
|
5
|
+
export const respond = (result) => {
|
|
6
|
+
if (result.ok) {
|
|
7
|
+
return { content: [{ type: 'text', text: JSON.stringify(result.body, null, 2) }] };
|
|
8
|
+
}
|
|
9
|
+
return {
|
|
10
|
+
isError: true,
|
|
11
|
+
content: [
|
|
12
|
+
{
|
|
13
|
+
type: 'text',
|
|
14
|
+
text: JSON.stringify({ status: result.status, errors: result.messages }, null, 2),
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
export const templateId = z.number().int().describe('Identifier of the workflow.');
|
|
20
|
+
export const sectionId = z.number().int().describe('Identifier of the section.');
|
|
21
|
+
// Left as a free-form object on purpose. The shape a rule takes depends on its
|
|
22
|
+
// operator, and `capabilities.condition.operators` is where that is decided; a
|
|
23
|
+
// schema written here would be a second catalogue, wrong the day an operator
|
|
24
|
+
// moves.
|
|
25
|
+
export const condition = z
|
|
26
|
+
.record(z.unknown())
|
|
27
|
+
.describe('Flat condition: {"match": "all"|"any", "rules": [...]}. The key a rule carries depends on its ' +
|
|
28
|
+
'operator — see workflow_capabilities.condition.operators. An unknown "match" is read as "all".');
|
|
29
|
+
export const placements = z
|
|
30
|
+
.array(z.record(z.unknown()))
|
|
31
|
+
.describe('Labels laid over the backdrop, in percent of the image box. `image_with_variable` sections only. ' +
|
|
32
|
+
'Key names are exact — see workflow_capabilities.placement.keys_by_kind. A misspelled key is ' +
|
|
33
|
+
'dropped silently and the label renders as nothing.');
|
|
34
|
+
export const runningTitles = z
|
|
35
|
+
.record(z.unknown())
|
|
36
|
+
.describe('Root groups only: {"header": html, "footer": html, "numbering": {"start": n}}. Header and footer ' +
|
|
37
|
+
'are sanitised like content, and script/style/iframe are removed with their text. Unknown keys ' +
|
|
38
|
+
'are discarded, never refused.');
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { respond, templateId } from './shared.js';
|
|
3
|
+
export const registerTheme = (server, api, base) => {
|
|
4
|
+
// ---------------------------------------------------------------- the theme
|
|
5
|
+
server.registerTool('theme_get', {
|
|
6
|
+
title: 'Read the document defaults',
|
|
7
|
+
description: 'The face, size, ink and leading a paragraph and each of the three heading levels take when a ' +
|
|
8
|
+
'section says nothing.',
|
|
9
|
+
inputSchema: { id: templateId },
|
|
10
|
+
annotations: { readOnlyHint: true },
|
|
11
|
+
}, async ({ id }) => respond(await api.get(`${base}/templates/${id}/theme`)));
|
|
12
|
+
server.registerTool('theme_update', {
|
|
13
|
+
title: 'Set the document defaults',
|
|
14
|
+
description: 'Blocks are paragraph, h1, h2 and h3. Each takes font_family, font_size, color and line_height. ' +
|
|
15
|
+
'A declaration that will not parse is DROPPED and the call still answers 200, so the shape ' +
|
|
16
|
+
'matters: font_size is a STRING carrying its unit ("11pt", never 11), line_height is unitless ' +
|
|
17
|
+
'("1.4" or 1.4 — a value carrying a unit is dropped, not converted), color is hex. font-weight ' +
|
|
18
|
+
'is not settable: headings take their weight from the engine. Set the theme BEFORE writing ' +
|
|
19
|
+
'sections — content that declares inline what the theme already says looks identical today and ' +
|
|
20
|
+
'stops following the theme forever. Read it back to confirm what was kept.',
|
|
21
|
+
inputSchema: {
|
|
22
|
+
id: templateId,
|
|
23
|
+
theme: z.record(z.unknown()).describe('{"paragraph": {"font_family": …}, "h1": {…}, …}'),
|
|
24
|
+
},
|
|
25
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
26
|
+
}, async ({ id, theme }) => respond(await api.patch(`${base}/templates/${id}/theme`, { theme })));
|
|
27
|
+
};
|