@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CapSens
|
|
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,114 @@
|
|
|
1
|
+
# Doclift workflows — MCP server
|
|
2
|
+
|
|
3
|
+
An MCP server over the workflow authoring API, so that an agent can build,
|
|
4
|
+
inspect and reproduce a Doclift workflow without a browser.
|
|
5
|
+
|
|
6
|
+
## What it does not do
|
|
7
|
+
|
|
8
|
+
**It holds no catalogue.** What a caller may write — the section kinds, the
|
|
9
|
+
condition operators, the faces the renderer carries, the ceilings this
|
|
10
|
+
organization is held to — is answered live by `workflow_capabilities`, which
|
|
11
|
+
reads them from the model and from the organization's own columns. Nothing here
|
|
12
|
+
is a copy, on purpose: a copy is a copy that will be wrong, and an agent that
|
|
13
|
+
trusted it would write values the API refuses without understanding why.
|
|
14
|
+
|
|
15
|
+
What *is* written down here is the part the API cannot express: the order
|
|
16
|
+
operations have to happen in, and the failures that answer 200.
|
|
17
|
+
|
|
18
|
+
## Running it
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install
|
|
22
|
+
npm run build
|
|
23
|
+
|
|
24
|
+
DOCLIFT_API_KEY=<the key you send in X-Api-Key> \
|
|
25
|
+
DOCLIFT_API_URL=https://app.doclift.io \
|
|
26
|
+
node dist/index.js
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
| Variable | Required | Default |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| `DOCLIFT_API_KEY` | yes | — |
|
|
32
|
+
| `DOCLIFT_API_URL` | no | `https://app.doclift.io` |
|
|
33
|
+
|
|
34
|
+
The key must belong to an organization the workflow editor is open to, and it
|
|
35
|
+
carries the same rights it carries on the REST API — no more.
|
|
36
|
+
|
|
37
|
+
### As a client would declare it
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"mcpServers": {
|
|
42
|
+
"doclift-workflows": {
|
|
43
|
+
"command": "node",
|
|
44
|
+
"args": ["/path/to/mcp/dist/index.js"],
|
|
45
|
+
"env": { "DOCLIFT_API_KEY": "…", "DOCLIFT_API_URL": "…" }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Calling it from a terminal
|
|
52
|
+
|
|
53
|
+
`bin/mcp-call.mjs` speaks to the server over the same stdio transport a real
|
|
54
|
+
client uses. It exists so the server can be exercised the way an agent meets it
|
|
55
|
+
— the tool list, the schemas, the descriptions, the guides, the error text.
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
node bin/mcp-call.mjs tools # every tool, with its schema
|
|
59
|
+
node bin/mcp-call.mjs resources # the guides
|
|
60
|
+
node bin/mcp-call.mjs read doclift://guides/how-to-build
|
|
61
|
+
node bin/mcp-call.mjs call workflow_capabilities '{}'
|
|
62
|
+
node bin/mcp-call.mjs call workflow_create '{"title":"Bulletin","description":"…"}'
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A call prints `OK` or `REFUSED` before its payload: a tool that refused is not a
|
|
66
|
+
tool that answered, and flattening the two would hide the thing these runs look
|
|
67
|
+
for.
|
|
68
|
+
|
|
69
|
+
## The tools
|
|
70
|
+
|
|
71
|
+
| Group | Tools |
|
|
72
|
+
|---|---|
|
|
73
|
+
| Catalogue | `workflow_capabilities` |
|
|
74
|
+
| Workflows | `workflow_list`, `workflow_get`, `workflow_create`, `workflow_update`, `workflow_delete` |
|
|
75
|
+
| Checking | `workflow_validate`, `workflow_payload_contract` |
|
|
76
|
+
| Publication | `workflow_publish`, `workflow_unpublish` |
|
|
77
|
+
| Whole document | `workflow_document_get`, `workflow_document_put` |
|
|
78
|
+
| Theme | `theme_get`, `theme_update` |
|
|
79
|
+
| Tree | `section_tree`, `section_get`, `section_create`, `section_update`, `section_move`, `section_duplicate`, `section_delete` |
|
|
80
|
+
| Backdrops | `section_background_set`, `section_background_remove` |
|
|
81
|
+
| Variables | `variable_list`, `variable_create`, `variable_update`, `variable_delete` |
|
|
82
|
+
| Datasets | `dataset_list`, `dataset_create`, `dataset_update`, `dataset_delete` |
|
|
83
|
+
| Pictures | `image_upload`, `image_list` |
|
|
84
|
+
| Generation | `workflow_render` |
|
|
85
|
+
| Self-check | `workflow_selfcheck` |
|
|
86
|
+
|
|
87
|
+
## The guides
|
|
88
|
+
|
|
89
|
+
Five resources, read with `resources/read`:
|
|
90
|
+
|
|
91
|
+
- `doclift://guides/how-to-build` — the order, and the three silent failures
|
|
92
|
+
- `doclift://guides/conditions` — which key each operator takes
|
|
93
|
+
- `doclift://guides/repetitions` — section repetition against row repetition
|
|
94
|
+
- `doclift://guides/before-you-finish` — what can be checked, and the one thing that cannot
|
|
95
|
+
- `doclift://guides/anomalies` — what blocks publication and what only warns
|
|
96
|
+
|
|
97
|
+
## Three things worth knowing before the first build
|
|
98
|
+
|
|
99
|
+
**A human in the builder wins.** While somebody holds the editing lock on a
|
|
100
|
+
workflow, every write through this server is refused with 409. Reads are
|
|
101
|
+
unaffected, and `workflow_get` reports `being_edited` so the refusal is never a
|
|
102
|
+
surprise.
|
|
103
|
+
|
|
104
|
+
**Three failures answer 200.** Content sanitising, placement key spelling, and
|
|
105
|
+
declaring inline what the theme already says. `workflow_validate` is the only
|
|
106
|
+
tool that sees the first coming; the other two are described where they can be
|
|
107
|
+
made.
|
|
108
|
+
|
|
109
|
+
**A green run is not a finished job.** `workflow_selfcheck` reads the workflow
|
|
110
|
+
back and answers a checklist from its actual state — tokens the interpolator
|
|
111
|
+
will never read, pictures the engine cannot fetch, variables declared and never
|
|
112
|
+
used, and whether a PDF exists that is newer than the last edit. It also names
|
|
113
|
+
the one line it can never tick: that the document is the one that was asked for.
|
|
114
|
+
Only reading the PDF answers that.
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface ClientOptions {
|
|
2
|
+
readonly baseUrl: string;
|
|
3
|
+
readonly apiKey: string;
|
|
4
|
+
readonly timeoutMs?: number;
|
|
5
|
+
}
|
|
6
|
+
export interface ApiSuccess<T> {
|
|
7
|
+
readonly ok: true;
|
|
8
|
+
readonly status: number;
|
|
9
|
+
readonly body: T;
|
|
10
|
+
}
|
|
11
|
+
export interface ApiFailure {
|
|
12
|
+
readonly ok: false;
|
|
13
|
+
readonly status: number;
|
|
14
|
+
/** Every message the API returned, already flattened out of `error`/`errors`. */
|
|
15
|
+
readonly messages: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
export type ApiResult<T> = ApiSuccess<T> | ApiFailure;
|
|
18
|
+
export declare class ApiClient {
|
|
19
|
+
private readonly baseUrl;
|
|
20
|
+
private readonly apiKey;
|
|
21
|
+
private readonly timeoutMs;
|
|
22
|
+
constructor({ baseUrl, apiKey, timeoutMs }: ClientOptions);
|
|
23
|
+
get<T>(path: string, query?: Record<string, string | number | undefined>): Promise<ApiResult<T>>;
|
|
24
|
+
post<T>(path: string, body?: unknown): Promise<ApiResult<T>>;
|
|
25
|
+
patch<T>(path: string, body?: unknown): Promise<ApiResult<T>>;
|
|
26
|
+
put<T>(path: string, body?: unknown): Promise<ApiResult<T>>;
|
|
27
|
+
delete<T>(path: string): Promise<ApiResult<T>>;
|
|
28
|
+
private withQuery;
|
|
29
|
+
private request;
|
|
30
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// The one door onto the Doclift API.
|
|
2
|
+
//
|
|
3
|
+
// Every tool goes through `request`, which is also the only place that knows how
|
|
4
|
+
// the API words a refusal. A tool that read `response.status` itself would be a
|
|
5
|
+
// second reading of the same contract, and the two would part company the first
|
|
6
|
+
// time a status changed.
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
8
|
+
export class ApiClient {
|
|
9
|
+
baseUrl;
|
|
10
|
+
apiKey;
|
|
11
|
+
timeoutMs;
|
|
12
|
+
constructor({ baseUrl, apiKey, timeoutMs }) {
|
|
13
|
+
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
|
14
|
+
this.apiKey = apiKey;
|
|
15
|
+
this.timeoutMs = timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
16
|
+
}
|
|
17
|
+
async get(path, query) {
|
|
18
|
+
return this.request('GET', this.withQuery(path, query));
|
|
19
|
+
}
|
|
20
|
+
async post(path, body) {
|
|
21
|
+
return this.request('POST', path, body);
|
|
22
|
+
}
|
|
23
|
+
async patch(path, body) {
|
|
24
|
+
return this.request('PATCH', path, body);
|
|
25
|
+
}
|
|
26
|
+
async put(path, body) {
|
|
27
|
+
return this.request('PUT', path, body);
|
|
28
|
+
}
|
|
29
|
+
async delete(path) {
|
|
30
|
+
return this.request('DELETE', path);
|
|
31
|
+
}
|
|
32
|
+
withQuery(path, query) {
|
|
33
|
+
if (!query)
|
|
34
|
+
return path;
|
|
35
|
+
const pairs = Object.entries(query).filter(([, value]) => value !== undefined && value !== '');
|
|
36
|
+
if (pairs.length === 0)
|
|
37
|
+
return path;
|
|
38
|
+
const search = new URLSearchParams(pairs.map(([key, value]) => [key, String(value)]));
|
|
39
|
+
return `${path}?${search.toString()}`;
|
|
40
|
+
}
|
|
41
|
+
async request(method, path, body) {
|
|
42
|
+
const controller = new AbortController();
|
|
43
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
44
|
+
try {
|
|
45
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
46
|
+
method,
|
|
47
|
+
headers: {
|
|
48
|
+
'X-Api-Key': this.apiKey,
|
|
49
|
+
'Content-Type': 'application/json',
|
|
50
|
+
Accept: 'application/json',
|
|
51
|
+
},
|
|
52
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
53
|
+
signal: controller.signal,
|
|
54
|
+
});
|
|
55
|
+
// 204 carries nothing, and JSON.parse('') throws — which used to surface as
|
|
56
|
+
// "Unexpected end of JSON input" on a delete that had in fact succeeded.
|
|
57
|
+
const text = await response.text();
|
|
58
|
+
const parsed = text.length === 0 ? null : safeParse(text);
|
|
59
|
+
if (response.ok) {
|
|
60
|
+
return { ok: true, status: response.status, body: parsed };
|
|
61
|
+
}
|
|
62
|
+
return { ok: false, status: response.status, messages: messagesOf(parsed, response.status) };
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
66
|
+
return { ok: false, status: 0, messages: [`The API could not be reached: ${reason}`] };
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const safeParse = (text) => {
|
|
74
|
+
try {
|
|
75
|
+
return JSON.parse(text);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return { error: text };
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
// The API says `error` for one message and `errors` for a list. Both shapes reach
|
|
82
|
+
// a caller as a list, so no tool has to ask which one it got.
|
|
83
|
+
const messagesOf = (parsed, status) => {
|
|
84
|
+
if (parsed && typeof parsed === 'object') {
|
|
85
|
+
const payload = parsed;
|
|
86
|
+
if (Array.isArray(payload.errors))
|
|
87
|
+
return payload.errors.map(String);
|
|
88
|
+
if (typeof payload.error === 'string')
|
|
89
|
+
return [payload.error];
|
|
90
|
+
}
|
|
91
|
+
return [`The API answered ${status} without saying why.`];
|
|
92
|
+
};
|
package/dist/guidance.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// What an agent cannot read off the API.
|
|
2
|
+
//
|
|
3
|
+
// Everything that is a *catalogue* — the kinds, the operators, the faces, the
|
|
4
|
+
// limits — is served by `GET /api/v1/workflows/capabilities` and must never be
|
|
5
|
+
// written down here: a second copy is a copy that will be wrong. What lives in
|
|
6
|
+
// this file is the part that is not data: the order to do things in, and the
|
|
7
|
+
// three mistakes that produce no error at all.
|
|
8
|
+
export const HOW_TO_BUILD = `
|
|
9
|
+
# Building a workflow through this server
|
|
10
|
+
|
|
11
|
+
## Read before you write
|
|
12
|
+
|
|
13
|
+
Call \`workflow_capabilities\` first, once per session. It answers with the
|
|
14
|
+
catalogues **for this organization** — the allowed kinds, the operators split by
|
|
15
|
+
the shape of value they take, the faces the renderer is provisioned with, and
|
|
16
|
+
the limits your account is actually held to. Nothing in this server hardcodes
|
|
17
|
+
them, and neither should you.
|
|
18
|
+
|
|
19
|
+
## The order that works
|
|
20
|
+
|
|
21
|
+
1. \`workflow_create\` — title and description; the page setup can follow.
|
|
22
|
+
2. \`variable_create\` — every variable **before** any content that cites one.
|
|
23
|
+
A citation is a string, not a foreign key: writing content first succeeds and
|
|
24
|
+
leaves you a broken reference to find later.
|
|
25
|
+
3. \`theme_update\` — the document's own face, size, ink and leading.
|
|
26
|
+
4. \`section_create\` — the tree, top down. A group can carry its children in the
|
|
27
|
+
same call.
|
|
28
|
+
5. \`workflow_validate\` — before publishing, and after any large edit.
|
|
29
|
+
6. \`workflow_publish\` — refused while a blocking anomaly stands.
|
|
30
|
+
7. \`workflow_render\`, then **fetch the URL it answers with and read the PDF**.
|
|
31
|
+
8. \`workflow_selfcheck\` — the last step, always. It reads the workflow back and
|
|
32
|
+
answers what is still wrong: tokens the interpolator will never look at,
|
|
33
|
+
pictures the engine cannot fetch, variables nothing cites, and whether the
|
|
34
|
+
PDF you read predates your last edit. Do not report a workflow as finished
|
|
35
|
+
before its \`outstanding\` count is zero.
|
|
36
|
+
|
|
37
|
+
## Anomalies are not write errors
|
|
38
|
+
|
|
39
|
+
A write refuses what is wrong with **the node**: a group carrying content, a
|
|
40
|
+
fourth level, a repetition inside a repetition. It does not refuse what is wrong
|
|
41
|
+
with **the document** — a broken reference, a nested table-row loop, a comparison
|
|
42
|
+
on a collection. Those are anomalies: they answer 200, they surface in
|
|
43
|
+
\`workflow_validate\`, and they refuse publication.
|
|
44
|
+
|
|
45
|
+
So a section can be saved in a state that will never publish, on purpose: work
|
|
46
|
+
in progress is allowed. Run \`workflow_validate\` after building, not only before
|
|
47
|
+
publishing.
|
|
48
|
+
|
|
49
|
+
## The three mistakes that produce no error
|
|
50
|
+
|
|
51
|
+
**1. An attribute that is not on the list is removed without a word.** The save
|
|
52
|
+
answers 200, and the mark is gone. \`workflow_validate\` is the only way to see it
|
|
53
|
+
coming: it reports what sanitising *would* strip from content you have not
|
|
54
|
+
written yet.
|
|
55
|
+
|
|
56
|
+
**2. A placement key spelled differently is dropped.** The label survives its
|
|
57
|
+
first save and then renders as nothing. The exact key names are in
|
|
58
|
+
\`capabilities.placement\`; there is no near-miss tolerance.
|
|
59
|
+
|
|
60
|
+
**2 bis. A variable token is recognised by its CLASS, not by its tag.** Write
|
|
61
|
+
the whole thing, exactly as \`capabilities.content.tokens\` gives it:
|
|
62
|
+
|
|
63
|
+
\`\`\`html
|
|
64
|
+
<variable class="editor-text-variable non-editable-content editor-parsed">investor_name</variable>
|
|
65
|
+
\`\`\`
|
|
66
|
+
|
|
67
|
+
The name is the **inner text**; only an *image* token reads \`data-src\`. A
|
|
68
|
+
\`<variable>\` without \`editor-parsed\` survives sanitising, satisfies the
|
|
69
|
+
integrity check, and prints the name of the variable instead of its value.
|
|
70
|
+
\`workflow_validate\` reports those as \`inert_tokens\`.
|
|
71
|
+
|
|
72
|
+
**2 quinquies. A CSS property is dropped from inside a \`style\` that survives.**
|
|
73
|
+
The attribute is on the allow list, its contents are filtered against a list of
|
|
74
|
+
their own. \`text-transform\` goes, \`style\` stays, and nothing about the element
|
|
75
|
+
looks different — measured, and it is what turns small-caps labels back into
|
|
76
|
+
sentence case. \`workflow_validate\` reports these as
|
|
77
|
+
\`removed_css_declarations\`. When a property you need is not carried, write the
|
|
78
|
+
result rather than the instruction: put the capitals in the text.
|
|
79
|
+
|
|
80
|
+
**2 quater. A block element written inside a \`<p>\` is moved out of it.** The
|
|
81
|
+
parser cuts the paragraph in three and drops the block between the halves — a
|
|
82
|
+
callout written as \`<p>text <div>…</div> more</p>\` comes back as three
|
|
83
|
+
siblings. Nothing is removed, so even the before/after comparison reads clean.
|
|
84
|
+
\`workflow_validate\` reports these as \`ejected_from_paragraph\`. Write a block
|
|
85
|
+
as a sibling of your paragraphs, never inside one.
|
|
86
|
+
|
|
87
|
+
**2 ter. A picture cited by an external URL prints an empty frame.** The engine
|
|
88
|
+
makes no network request at render time, so every picture has to go through
|
|
89
|
+
\`image_upload\` and be cited by the URL it answers with. \`workflow_validate\`
|
|
90
|
+
reports the others under \`unreachable_images\`.
|
|
91
|
+
|
|
92
|
+
**3. Declaring inline what the theme already says freezes the document.** It
|
|
93
|
+
looks identical today and stops following the theme forever. Set the theme, then
|
|
94
|
+
write only what departs from it. This is the mistake that costs the most and
|
|
95
|
+
shows the least.
|
|
96
|
+
|
|
97
|
+
A fourth, of the same family: **a theme declaration that will not parse is
|
|
98
|
+
dropped, not refused.** \`font_size\` is a string carrying its unit (\`"11pt"\`,
|
|
99
|
+
never \`11\`) and \`line_height\` is unitless. Read the theme back after writing
|
|
100
|
+
it; what is missing is what was thrown away.
|
|
101
|
+
|
|
102
|
+
## Reproducing an existing document
|
|
103
|
+
|
|
104
|
+
Ask for the face by the name the renderer carries, not by the name on the
|
|
105
|
+
original. \`capabilities.authoring.fonts\` gives, for each family, what it stands
|
|
106
|
+
in for and whether the substitution is **metric** — identical advance widths, so
|
|
107
|
+
the line breaks and the page count are the ones the original had — or
|
|
108
|
+
**approximate**, in which case the text will reflow and the page count is not
|
|
109
|
+
guaranteed.
|
|
110
|
+
|
|
111
|
+
\`capabilities.authoring.fidelity_meanings\` defines the three words. In
|
|
112
|
+
particular \`none\` means the family stands in for nothing because it is an
|
|
113
|
+
original face — it is always available, and it is not a warning.
|
|
114
|
+
|
|
115
|
+
Then: \`workflow_render\` produces a real PDF from a dataset, which is the only
|
|
116
|
+
honest way to check a reproduction.
|
|
117
|
+
`.trim();
|
|
118
|
+
export const CONDITIONS = `
|
|
119
|
+
# Conditions
|
|
120
|
+
|
|
121
|
+
A condition is flat — a \`match\` and a list of rules, with no nesting:
|
|
122
|
+
|
|
123
|
+
\`\`\`json
|
|
124
|
+
{"match": "all", "rules": [{"variable": "residence", "operator": "eq", "value": "France"}]}
|
|
125
|
+
\`\`\`
|
|
126
|
+
|
|
127
|
+
\`match\` is \`all\` or \`any\`. **Any other value is read as \`all\`**, silently.
|
|
128
|
+
|
|
129
|
+
The key a rule carries depends on the operator, and \`capabilities.condition.operators\`
|
|
130
|
+
says which: single-value operators take \`value\`, membership operators take
|
|
131
|
+
\`values\` (a list), and \`blank\`/\`present\` take neither. Sending \`value\` with
|
|
132
|
+
\`present\` is not a syntax error, it is an operator that ignores it.
|
|
133
|
+
|
|
134
|
+
**On a variable of type \`collection\`, only \`present\` and \`blank\` mean anything.**
|
|
135
|
+
Any other operator reaches the normaliser, which reads an array as its string
|
|
136
|
+
form and matches nothing an author would ever write. It is a blocking anomaly,
|
|
137
|
+
not a runtime surprise.
|
|
138
|
+
|
|
139
|
+
A rule naming a variable that no longer exists is **kept**, not cascaded away, so
|
|
140
|
+
that it can be reported as broken rather than disappear. Deleting a variable
|
|
141
|
+
therefore breaks nothing at write time and everything at render time.
|
|
142
|
+
`.trim();
|
|
143
|
+
export const REPETITIONS = `
|
|
144
|
+
# The two repetitions, which are not the same thing
|
|
145
|
+
|
|
146
|
+
**Section repetition — \`repeat_over\`.** The whole node is printed once per row
|
|
147
|
+
of the named collection. Nesting one inside another is refused: two nested
|
|
148
|
+
repetitions would print the cartesian product of two collections. The refusal is
|
|
149
|
+
checked in both directions — ancestors and descendants — because the tree is
|
|
150
|
+
saved one node at a time.
|
|
151
|
+
|
|
152
|
+
**Table row repetition — \`data-loop\`.** You draw **one** \`<tr>\` and mark it
|
|
153
|
+
\`data-loop="collection_name"\`; the document carries as many as the payload does.
|
|
154
|
+
It runs *before* interpolation. A collection that is absent, empty or malformed
|
|
155
|
+
removes **the row** and leaves the table standing: a table with a header and no
|
|
156
|
+
body says "no movement", a missing table says nothing.
|
|
157
|
+
|
|
158
|
+
Inside a repeated row, \`collection.field\` expands to \`collection.i.field\`.
|
|
159
|
+
Citing \`collection.field\` **outside** the loop that carries it is a blocking
|
|
160
|
+
anomaly.
|
|
161
|
+
|
|
162
|
+
Two nested \`data-loop\` rows are a blocking anomaly too.
|
|
163
|
+
`.trim();
|
|
164
|
+
export const ANOMALIES = `
|
|
165
|
+
# What blocks publication, and what only warns
|
|
166
|
+
|
|
167
|
+
\`workflow_validate\` returns anomalies, each with a type, whether it blocks, and
|
|
168
|
+
the node it points at. \`capabilities.anomalies\` is the authoritative list of
|
|
169
|
+
which types block — do not infer it from the name.
|
|
170
|
+
|
|
171
|
+
The distinction is deliberate. \`empty_membership\` — an \`in\` with an empty list —
|
|
172
|
+
does **not** block: an empty rule sitting in an \`any\` next to a complete sister
|
|
173
|
+
decides nothing, and refusing to publish a workflow that works would be wrong.
|
|
174
|
+
\`missing_background\` does not block either: an image section without its backdrop
|
|
175
|
+
is work in progress, not an error.
|
|
176
|
+
`.trim();
|
|
177
|
+
export const BEFORE_YOU_FINISH = `
|
|
178
|
+
# Before you say it is done
|
|
179
|
+
|
|
180
|
+
A document that saved cleanly, validated cleanly and generated successfully can
|
|
181
|
+
still be wrong in a way nothing reported. Three of this API's failures answer
|
|
182
|
+
200, and a fourth — the one that costs the most — is not a failure of the API at
|
|
183
|
+
all: it is having built something that is not what was asked for.
|
|
184
|
+
|
|
185
|
+
\`workflow_selfcheck\` answers this list from the workflow's actual state. It is
|
|
186
|
+
not a reminder; it reads the database and tells you which lines are already
|
|
187
|
+
satisfied.
|
|
188
|
+
|
|
189
|
+
## What it can check for you
|
|
190
|
+
|
|
191
|
+
1. **Nothing blocks publication.** Anomalies, with the node each points at.
|
|
192
|
+
2. **No token is inert.** A \`<variable>\` written with the right tag and the
|
|
193
|
+
wrong class prints the name of the variable. It survives saving, satisfies
|
|
194
|
+
the integrity check, and is invisible everywhere else.
|
|
195
|
+
3. **No picture is unreachable.** The engine makes no network request, and a
|
|
196
|
+
token that no longer names a stored image is removed at render time.
|
|
197
|
+
4. **No variable was declared and never used.** The mirror of a broken
|
|
198
|
+
reference: it usually means a token was written with a typo, and the typo
|
|
199
|
+
became a second, dead name.
|
|
200
|
+
5. **A PDF exists, and it is newer than your last edit.** If you rendered before
|
|
201
|
+
your last change, what you looked at is not what you have.
|
|
202
|
+
|
|
203
|
+
## What it cannot check, and you must
|
|
204
|
+
|
|
205
|
+
**That the document is the one that was asked for.** No tool can compare your
|
|
206
|
+
result to an intention it never saw. Download the PDF, read it, and compare it
|
|
207
|
+
line by line with the request — the wording, the order of the sections, the
|
|
208
|
+
values in place of the tokens, the page count.
|
|
209
|
+
|
|
210
|
+
\`workflow_render\` answers with a URL. Fetch it. Reading the bytes is the only
|
|
211
|
+
step of this whole procedure that no tool will do for you, and it is the one
|
|
212
|
+
that catches everything the others cannot.
|
|
213
|
+
|
|
214
|
+
## The honest ending
|
|
215
|
+
|
|
216
|
+
If something could not be reproduced, say so and say why. A reproduction that is
|
|
217
|
+
90% right and presented as complete costs more than one that names its own gap:
|
|
218
|
+
the reader stops looking.
|
|
219
|
+
`.trim();
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The Doclift workflow authoring server, spoken over stdio.
|
|
3
|
+
//
|
|
4
|
+
// It is a thin skin on the public API and holds no catalogue of its own: what a
|
|
5
|
+
// caller may write is answered by `workflow_capabilities`, live and scoped to the
|
|
6
|
+
// organization the key belongs to. The only knowledge that lives here is the
|
|
7
|
+
// knowledge the API cannot express — the order to do things in, and the failures
|
|
8
|
+
// that succeed.
|
|
9
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
10
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
import { ApiClient } from './client.js';
|
|
13
|
+
import { registerTools } from './tools/index.js';
|
|
14
|
+
import { ANOMALIES, BEFORE_YOU_FINISH, CONDITIONS, HOW_TO_BUILD, REPETITIONS } from './guidance.js';
|
|
15
|
+
const DEFAULT_BASE_URL = 'https://app.doclift.io';
|
|
16
|
+
const readConfig = () => {
|
|
17
|
+
const apiKey = process.env['DOCLIFT_API_KEY'];
|
|
18
|
+
if (!apiKey) {
|
|
19
|
+
process.stderr.write('DOCLIFT_API_KEY is not set. It is the same key the REST API takes in X-Api-Key.\n');
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
return { baseUrl: process.env['DOCLIFT_API_URL'] ?? DEFAULT_BASE_URL, apiKey };
|
|
23
|
+
};
|
|
24
|
+
const GUIDES = [
|
|
25
|
+
{
|
|
26
|
+
name: 'how-to-build',
|
|
27
|
+
title: 'Building a workflow',
|
|
28
|
+
description: 'The order operations have to happen in, and the three mistakes that raise no error.',
|
|
29
|
+
text: HOW_TO_BUILD,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: 'conditions',
|
|
33
|
+
title: 'Conditions',
|
|
34
|
+
description: 'How a rule is shaped, which key each operator takes, and what a collection allows.',
|
|
35
|
+
text: CONDITIONS,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: 'repetitions',
|
|
39
|
+
title: 'The two repetitions',
|
|
40
|
+
description: 'Section repetition and table-row repetition, and why they must not be confused.',
|
|
41
|
+
text: REPETITIONS,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'before-you-finish',
|
|
45
|
+
title: 'Before you say it is done',
|
|
46
|
+
description: 'What the workflow can be checked for, and the one thing only you can check.',
|
|
47
|
+
text: BEFORE_YOU_FINISH,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'anomalies',
|
|
51
|
+
title: 'Anomalies',
|
|
52
|
+
description: 'What blocks publication, what only warns, and why the line is drawn where it is.',
|
|
53
|
+
text: ANOMALIES,
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
const main = async () => {
|
|
57
|
+
const { baseUrl, apiKey } = readConfig();
|
|
58
|
+
const server = new McpServer({ name: 'doclift-workflows', version: '0.1.0' }, {
|
|
59
|
+
instructions: 'Authoring of Doclift workflows: conditional PDF documents assembled from a tree of sections. ' +
|
|
60
|
+
'Call `workflow_capabilities` before writing anything — it carries the catalogues and the ' +
|
|
61
|
+
'limits for this organization, and nothing in this server duplicates them. Read the ' +
|
|
62
|
+
'`how-to-build` resource before a first build: three of this API\'s failure modes answer 200 ' +
|
|
63
|
+
'and lose what you sent. Finish with `workflow_selfcheck` — it reads the workflow back and ' +
|
|
64
|
+
'names what is still wrong. A workflow that saved, validated and generated can still be ' +
|
|
65
|
+
'broken in ways only it reports.',
|
|
66
|
+
});
|
|
67
|
+
registerTools(server, new ApiClient({ baseUrl, apiKey }));
|
|
68
|
+
for (const guide of GUIDES) {
|
|
69
|
+
server.registerResource(guide.name, `doclift://guides/${guide.name}`, { title: guide.title, description: guide.description, mimeType: 'text/markdown' }, async (uri) => ({ contents: [{ uri: uri.href, mimeType: 'text/markdown', text: guide.text }] }));
|
|
70
|
+
}
|
|
71
|
+
registerPrompts(server);
|
|
72
|
+
await server.connect(new StdioServerTransport());
|
|
73
|
+
};
|
|
74
|
+
// Two procedures rather than two descriptions. Both start by reading the
|
|
75
|
+
// catalogue and both end at `workflow_validate`, because the order is the part
|
|
76
|
+
// that cannot be inferred from a tool list.
|
|
77
|
+
const registerPrompts = (server) => {
|
|
78
|
+
const message = (text) => ({
|
|
79
|
+
messages: [{ role: 'user', content: { type: 'text', text } }],
|
|
80
|
+
});
|
|
81
|
+
server.registerPrompt('reproduce_document', {
|
|
82
|
+
title: 'Reproduce an existing document',
|
|
83
|
+
description: 'The procedure for turning a document you have in front of you into a workflow.',
|
|
84
|
+
argsSchema: {
|
|
85
|
+
source: z.string().describe('What the original is: its sections, its typography, its variable parts.'),
|
|
86
|
+
},
|
|
87
|
+
}, ({ source }) => message([
|
|
88
|
+
`Reproduce this document as a Doclift workflow:\n\n${source}\n`,
|
|
89
|
+
'Work in this order, and do not skip a step:',
|
|
90
|
+
'',
|
|
91
|
+
'1. `workflow_capabilities`, and read the `authoring` block. It says what the editor can',
|
|
92
|
+
' actually produce, which is narrower than what survives sanitising.',
|
|
93
|
+
'2. Match every face of the original against `authoring.fonts`. Ask for the family the',
|
|
94
|
+
' renderer carries, not the name on the original — and note the `fidelity`: `metric`',
|
|
95
|
+
' means the line breaks and the page count are preserved, `approximate` means the text',
|
|
96
|
+
' will reflow and you should say so rather than promise an identical result.',
|
|
97
|
+
'3. `workflow_create`, then set the page: orientation and the four margins.',
|
|
98
|
+
'4. `theme_update` with the document\'s own face, size, ink and leading — BEFORE any',
|
|
99
|
+
' content. Everything that stays silent then follows it.',
|
|
100
|
+
'5. `variable_create` for every part that changes between two copies of the original.',
|
|
101
|
+
'6. `section_create`, top down. Write inline styling ONLY where a passage departs from',
|
|
102
|
+
' the theme. Styling every paragraph with the theme\'s own values looks identical today',
|
|
103
|
+
' and freezes the document forever.',
|
|
104
|
+
'7. `workflow_validate`, passing the content you are about to write, until it comes back',
|
|
105
|
+
' clean. Then publish, then `workflow_render` with a dataset, fetch the URL it answers',
|
|
106
|
+
' with, and read the PDF against the original.',
|
|
107
|
+
'8. `workflow_selfcheck` last, always. Do not report this as finished while its',
|
|
108
|
+
' `outstanding` count is above zero.',
|
|
109
|
+
'',
|
|
110
|
+
'A document has a look as well as a text, and reproducing only the text is how a',
|
|
111
|
+
'reproduction reads as a draft. Carry over its ink — accent colours, tinted callouts,',
|
|
112
|
+
'grey labels — with `style` on the element, and its typographic treatment: a heading',
|
|
113
|
+
'set in small capitals, a coloured rule down the left of a section title, a band behind',
|
|
114
|
+
'it. A callout is a single block CONTAINING its text, never a bordered box above it.',
|
|
115
|
+
'The self-check hands you the palette your document declares: if the original had an',
|
|
116
|
+
'accent and yours comes back in greys, you have left something out.',
|
|
117
|
+
'',
|
|
118
|
+
'Report what you could not reproduce and why, rather than approximating in silence.',
|
|
119
|
+
].join('\n')));
|
|
120
|
+
server.registerPrompt('audit_workflow', {
|
|
121
|
+
title: 'Audit a workflow',
|
|
122
|
+
description: 'Read a workflow you did not build and report what is wrong with it.',
|
|
123
|
+
argsSchema: { id: z.string().describe('The workflow identifier.') },
|
|
124
|
+
}, ({ id }) => message([
|
|
125
|
+
`Audit workflow ${id}.`,
|
|
126
|
+
'',
|
|
127
|
+
'1. `workflow_capabilities` for the catalogue and the ceilings.',
|
|
128
|
+
`2. \`workflow_document_get\` with id ${id} — the whole thing in one read.`,
|
|
129
|
+
`3. \`workflow_validate\` with id ${id} — the anomalies, and which of them block.`,
|
|
130
|
+
`4. \`workflow_selfcheck\` with id ${id} — what is wrong with the workflow as stored, which`,
|
|
131
|
+
' is where an audit finds what its author never saw.',
|
|
132
|
+
'',
|
|
133
|
+
'Then report, in this order: what blocks publication; what is merely advisory and why it',
|
|
134
|
+
'was judged not to block; every reference naming a variable that no longer exists; every',
|
|
135
|
+
'passage that declares inline what the theme already says, which is invisible today and',
|
|
136
|
+
'stops following the theme forever; and anything that would print differently from what',
|
|
137
|
+
'its author appears to have intended.',
|
|
138
|
+
].join('\n')));
|
|
139
|
+
};
|
|
140
|
+
main().catch((error) => {
|
|
141
|
+
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
|
|
142
|
+
process.exit(1);
|
|
143
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { respond } from './shared.js';
|
|
2
|
+
export const registerCapabilities = (server, api, base) => {
|
|
3
|
+
// ---------------------------------------------------------------- catalogue
|
|
4
|
+
server.registerTool('workflow_capabilities', {
|
|
5
|
+
title: 'Read what may be written',
|
|
6
|
+
description: 'The authoring catalogue for this organization: section kinds and what each may carry, page ' +
|
|
7
|
+
'breaks and layouts, condition operators split by the shape of value they take, placement keys ' +
|
|
8
|
+
'by kind, the faces the renderer is provisioned with, the tags and attributes that survive ' +
|
|
9
|
+
'sanitising, the anomaly types and which of them block publication, and the limits this account ' +
|
|
10
|
+
'is held to. Call this first, once. Nothing here is a constant you may assume.',
|
|
11
|
+
annotations: { readOnlyHint: true },
|
|
12
|
+
}, async () => respond(await api.get(`${base}/capabilities`)));
|
|
13
|
+
};
|