mikser-io 7.0.0 → 7.6.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/README.md +1 -0
- package/ngrok.ymlt +3 -0
- package/package.json +8 -8
- package/src/api.js +42 -8
- package/src/engine.js +22 -1
- package/src/mcp.js +70 -18
- package/src/plugins/api.js +45 -9
- package/src/plugins/layouts.js +189 -13
- package/src/plugins/preview.js +206 -0
package/README.md
CHANGED
|
@@ -116,6 +116,7 @@ The engine is what stays stable — the lifecycle, the catalog, the file-based c
|
|
|
116
116
|
|---|---|
|
|
117
117
|
| `data` | JSON snapshots of entities / context / catalog, written to disk for static serving |
|
|
118
118
|
| `api` | REST endpoints with sift-backed queries, per-endpoint tokens, optional render, opt-in [per-query disk cache](./documentation/caching.md) for reverse-proxy failover |
|
|
119
|
+
| `preview` | In-memory render cache + `GET /preview/:filename` route. Companion to the [`mikser_preview`](./documentation/mcp.md) MCP tool — transient render bytes served at a clickable URL, no filesystem footprint |
|
|
119
120
|
|
|
120
121
|
**Integrations:**
|
|
121
122
|
|
package/ngrok.ymlt
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.6.0",
|
|
4
4
|
"description": "<p align=\"center\"> <img src=\"mikser-lockup-stacked.svg\" alt=\"mikser\" width=\"198\" /> </p>",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -20,14 +20,14 @@
|
|
|
20
20
|
"author": "",
|
|
21
21
|
"license": "ISC",
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@budibase/handlebars-helpers": "^0.14.
|
|
23
|
+
"@budibase/handlebars-helpers": "^0.14.3",
|
|
24
24
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
25
25
|
"await-semaphore": "^0.1.3",
|
|
26
|
-
"axios": "^1.
|
|
26
|
+
"axios": "^1.17.0",
|
|
27
27
|
"chokidar": "^5.0.0",
|
|
28
28
|
"cli-progress": "^3.12.0",
|
|
29
|
-
"commander": "^
|
|
30
|
-
"dayjs": "^1.11.
|
|
29
|
+
"commander": "^15.0.0",
|
|
30
|
+
"dayjs": "^1.11.21",
|
|
31
31
|
"deepdash": "^5.3.9",
|
|
32
32
|
"escape-string-regexp": "^5.0.0",
|
|
33
33
|
"execa": "^9.6.1",
|
|
@@ -43,18 +43,18 @@
|
|
|
43
43
|
"minimatch": "^10.2.5",
|
|
44
44
|
"node-cron": "^4.2.1",
|
|
45
45
|
"p-map": "^7.0.4",
|
|
46
|
-
"p-queue": "^9.
|
|
46
|
+
"p-queue": "^9.3.0",
|
|
47
47
|
"pino": "^10.3.1",
|
|
48
48
|
"pino-pretty": "^13.1.3",
|
|
49
49
|
"piscina": "^5.1.4",
|
|
50
50
|
"sift": "^17.1.3",
|
|
51
51
|
"sqlite3": "^6.0.1",
|
|
52
52
|
"truncate-stream": "^1.0.2",
|
|
53
|
-
"yaml": "^2.
|
|
53
|
+
"yaml": "^2.9.0"
|
|
54
54
|
},
|
|
55
55
|
"optionalDependencies": {
|
|
56
56
|
"express": "^5.2.1",
|
|
57
|
-
"puppeteer": "^
|
|
57
|
+
"puppeteer": "^25.1.0"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"fluent-ffmpeg": "^2.1.3",
|
package/src/api.js
CHANGED
|
@@ -37,10 +37,20 @@ export function useRenderer(runtime, { defaultTimeout = 30_000 } = {}) {
|
|
|
37
37
|
const remaining = new Map(batch.map(b => [b.correlationId, b]))
|
|
38
38
|
const completedHooks = runtime.hooks.completed
|
|
39
39
|
const hook = async (entry) => {
|
|
40
|
-
const cid = entry.entity?.
|
|
40
|
+
const cid = entry.entity?.options?.correlationId
|
|
41
41
|
if (!cid) return
|
|
42
42
|
const item = remaining.get(cid)
|
|
43
43
|
if (!item) return
|
|
44
|
+
// Only resolve on the FINAL completion. For postprocessor-
|
|
45
|
+
// equipped entities the engine fires runtime.complete twice:
|
|
46
|
+
// once after render (intermediate bytes, entity.origin not
|
|
47
|
+
// set) and once after postprocess (final bytes, entity.origin
|
|
48
|
+
// set to the intermediate destination). useRenderer's
|
|
49
|
+
// contract is "return the pipeline's final output", so we
|
|
50
|
+
// skip the intermediate fire and wait for the final.
|
|
51
|
+
const hasPostprocessor = entry.entity?.layout?.postprocessor
|
|
52
|
+
const isFinal = !hasPostprocessor || entry.entity?.origin != null
|
|
53
|
+
if (!isFinal) return
|
|
44
54
|
remaining.delete(cid)
|
|
45
55
|
clearTimeout(item.timer)
|
|
46
56
|
item.resolve({ output: entry.output, entity: entry.entity })
|
|
@@ -99,13 +109,24 @@ export function useRenderer(runtime, { defaultTimeout = 30_000 } = {}) {
|
|
|
99
109
|
* skip the final disk write; the bytes still come back via
|
|
100
110
|
* `output.result` for you to pipe wherever you want (HTTP
|
|
101
111
|
* response, S3, …). For layouts with a postprocessor (e.g.
|
|
102
|
-
* `*.html-pdf.*`), the intermediate
|
|
103
|
-
*
|
|
112
|
+
* `*.html-pdf.*`), the intermediate is written to a scratch path
|
|
113
|
+
* under `runtime.options.previewFolder` (engine-owned, never
|
|
114
|
+
* in outputFolder) so the postprocessor can consume it; only
|
|
115
|
+
* the FINAL output is skipped from disk. `output.result` is
|
|
116
|
+
* always the FINAL pipeline output — PDF bytes for a
|
|
117
|
+
* `*.html-pdf.*` layout, MJML-derived HTML for
|
|
118
|
+
* `*.html-mjml.*`, etc., not the intermediate.
|
|
104
119
|
*
|
|
105
120
|
* The rendered output's bytes are always returned in `output.result`
|
|
106
121
|
* regardless of either flag — `save` only affects whether they also
|
|
107
122
|
* end up on disk.
|
|
108
123
|
*
|
|
124
|
+
* Per-entity engine state (correlation id, control flags) lives at
|
|
125
|
+
* `entity.options.*` — same noun mikser uses for engine config
|
|
126
|
+
* (`runtime.options`) and plugin params, scoped to one entity's
|
|
127
|
+
* pass through the lifecycle. Consumers should not set
|
|
128
|
+
* `entity.options.correlationId` themselves; useRenderer owns it.
|
|
129
|
+
*
|
|
109
130
|
* @param {object} entity - any entity-shaped object
|
|
110
131
|
* @param {object} [opts]
|
|
111
132
|
* @param {number} [opts.timeout] - override the default timeout
|
|
@@ -116,12 +137,25 @@ export function useRenderer(runtime, { defaultTimeout = 30_000 } = {}) {
|
|
|
116
137
|
async function render(entity, { timeout = defaultTimeout, catalog = true, save = true } = {}) {
|
|
117
138
|
const result = await new Promise((resolve, reject) => {
|
|
118
139
|
const correlationId = randomUUID()
|
|
119
|
-
|
|
120
|
-
//
|
|
121
|
-
// entity
|
|
122
|
-
|
|
140
|
+
// Engine-set fields live under entity.options. The caller's
|
|
141
|
+
// render(entity, { save: false }) becomes
|
|
142
|
+
// entity.options.save = false here — same noun mikser uses
|
|
143
|
+
// for engine config (runtime.options) and plugin params,
|
|
144
|
+
// just scoped to one entity's pass through the lifecycle.
|
|
145
|
+
//
|
|
146
|
+
// Only set `save` when explicitly opting out — leaves the
|
|
147
|
+
// entity.options as a clean { correlationId } in the common
|
|
148
|
+
// case rather than carrying a redundant save:true.
|
|
149
|
+
const prepared = {
|
|
150
|
+
...entity,
|
|
151
|
+
options: {
|
|
152
|
+
...entity.options,
|
|
153
|
+
correlationId,
|
|
154
|
+
...(save === false ? { save: false } : {}),
|
|
155
|
+
},
|
|
156
|
+
}
|
|
123
157
|
pending.push({
|
|
124
|
-
entity:
|
|
158
|
+
entity: prepared,
|
|
125
159
|
correlationId,
|
|
126
160
|
timeout,
|
|
127
161
|
resolve,
|
package/src/engine.js
CHANGED
|
@@ -75,6 +75,12 @@ export async function setup(options) {
|
|
|
75
75
|
|
|
76
76
|
runtime.options.runtimeFolder = path.join(runtime.options.workingFolder, runtime.options.runtimeFolder || 'runtime')
|
|
77
77
|
runtime.options.outputFolder = path.join(runtime.options.workingFolder, runtime.options.outputFolder || 'out')
|
|
78
|
+
// Scratch path for intermediate render artifacts when a caller
|
|
79
|
+
// opted out of disk writes via render({ save: false }) but the
|
|
80
|
+
// layout has a postprocessor that still needs to read the
|
|
81
|
+
// intermediate. Lives under runtimeFolder so it's engine-owned
|
|
82
|
+
// and never appears in outputFolder.
|
|
83
|
+
runtime.options.previewFolder = path.join(runtime.options.runtimeFolder, 'preview')
|
|
78
84
|
|
|
79
85
|
if (runtime.options.clear) {
|
|
80
86
|
try {
|
|
@@ -415,7 +421,22 @@ export async function setup(options) {
|
|
|
415
421
|
origin: entity.destination,
|
|
416
422
|
destination
|
|
417
423
|
},
|
|
418
|
-
options: {
|
|
424
|
+
options: {
|
|
425
|
+
postprocessor: options.postprocessor,
|
|
426
|
+
tasks: options.tasks,
|
|
427
|
+
// When the originating render call passed
|
|
428
|
+
// `save: false`, the layouts plugin wrote the
|
|
429
|
+
// intermediate into runtime.options.previewFolder
|
|
430
|
+
// rather than outputFolder. Postprocess plugins
|
|
431
|
+
// resolve `entity.origin` against
|
|
432
|
+
// `options.outputFolder`, so swap it here so
|
|
433
|
+
// they look in the right place. No change for
|
|
434
|
+
// normal builds (entity.options.save unset →
|
|
435
|
+
// outputFolder).
|
|
436
|
+
...(entity.options?.save === false
|
|
437
|
+
? { outputFolder: runtime.options.previewFolder }
|
|
438
|
+
: {}),
|
|
439
|
+
},
|
|
419
440
|
context
|
|
420
441
|
})
|
|
421
442
|
}
|
package/src/mcp.js
CHANGED
|
@@ -22,7 +22,6 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
|
22
22
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
|
23
23
|
import packageInfo from '../package.json' with { type: 'json' }
|
|
24
24
|
import runtime from './runtime.js'
|
|
25
|
-
import { onLoaded } from './lifecycle.js'
|
|
26
25
|
|
|
27
26
|
let pinoLevelToMcp = (pinoLevel) => {
|
|
28
27
|
if (pinoLevel >= 50) return 'error'
|
|
@@ -63,8 +62,6 @@ export function createMcpSubstrate() {
|
|
|
63
62
|
}
|
|
64
63
|
|
|
65
64
|
const substrate = {
|
|
66
|
-
// ---- plugin-facing surface (mirrors McpServer) -----------
|
|
67
|
-
|
|
68
65
|
// The SDK's register* methods take different argument counts
|
|
69
66
|
// (3 for tools, 4 for resources, 3 for prompts). We spread the
|
|
70
67
|
// recorded args verbatim — substrate doesn't peek at the
|
|
@@ -97,8 +94,6 @@ export function createMcpSubstrate() {
|
|
|
97
94
|
return substrate.registerTool(name, { description, inputSchema }, handler)
|
|
98
95
|
},
|
|
99
96
|
|
|
100
|
-
// ---- engine-facing surface -------------------------------
|
|
101
|
-
|
|
102
97
|
// Create a fresh McpServer pre-loaded with every recorded
|
|
103
98
|
// registration. Called by the transport mount per new session.
|
|
104
99
|
_createServer() {
|
|
@@ -113,8 +108,6 @@ export function createMcpSubstrate() {
|
|
|
113
108
|
_detach(server) { activeServers.delete(server) },
|
|
114
109
|
_activeServerCount() { return activeServers.size },
|
|
115
110
|
|
|
116
|
-
// ---- notification broadcast ------------------------------
|
|
117
|
-
|
|
118
111
|
// Send a logging-message notification to every connected
|
|
119
112
|
// client. The SDK's per-session level filtering applies.
|
|
120
113
|
broadcastLog(params) {
|
|
@@ -129,8 +122,6 @@ export function createMcpSubstrate() {
|
|
|
129
122
|
}
|
|
130
123
|
},
|
|
131
124
|
|
|
132
|
-
// ---- rolling log buffer ----------------------------------
|
|
133
|
-
|
|
134
125
|
// Called by wireLoggerToMcp on every log call. Records a
|
|
135
126
|
// monotonic seq number so clients can poll "give me lines
|
|
136
127
|
// since seq=N" against mikser://logs/recent.
|
|
@@ -238,13 +229,34 @@ export function createMcpSubstrate() {
|
|
|
238
229
|
}),
|
|
239
230
|
)
|
|
240
231
|
|
|
232
|
+
// mikser://server — single-shot answer to "where do I put output
|
|
233
|
+
// so the user can see it?" Combines server state (running? on what
|
|
234
|
+
// URL?) and the path conventions agents should write to for
|
|
235
|
+
// preview-style outputs.
|
|
236
|
+
substrate.registerResource(
|
|
237
|
+
'mikser-server',
|
|
238
|
+
'mikser://server',
|
|
239
|
+
{
|
|
240
|
+
title: 'HTTP server location and preview conventions',
|
|
241
|
+
description: 'Where the running engine is reachable (URL, MCP path, preview path prefix) and what folder it serves. The single resource an agent needs to answer "where can the user see this output?"',
|
|
242
|
+
mimeType: 'application/json',
|
|
243
|
+
},
|
|
244
|
+
async (uri) => ({
|
|
245
|
+
contents: [{
|
|
246
|
+
uri: uri.href,
|
|
247
|
+
mimeType: 'application/json',
|
|
248
|
+
text: JSON.stringify(serverInfo(), null, 2),
|
|
249
|
+
}],
|
|
250
|
+
}),
|
|
251
|
+
)
|
|
252
|
+
|
|
241
253
|
// Built-in liveness/identity tool. Also ensures tools/list works
|
|
242
254
|
// before any plugin has registered (McpServer only advertises
|
|
243
255
|
// tools/list capability after at least one registration).
|
|
244
256
|
substrate.registerTool(
|
|
245
257
|
'mikser_ping',
|
|
246
258
|
{
|
|
247
|
-
description: 'Return mikser engine identity and the
|
|
259
|
+
description: 'Return mikser engine identity, current lifecycle phase, and (if --server is on) where the HTTP server is reachable. Use to confirm the connection is live before issuing other tool calls and to learn the base URL for preview outputs.',
|
|
248
260
|
inputSchema: {},
|
|
249
261
|
},
|
|
250
262
|
async () => ({
|
|
@@ -258,6 +270,7 @@ export function createMcpSubstrate() {
|
|
|
258
270
|
workingFolder: runtime.options.workingFolder,
|
|
259
271
|
outputFolder: runtime.options.outputFolder,
|
|
260
272
|
activeClients: substrate._activeServerCount(),
|
|
273
|
+
server: serverInfo(),
|
|
261
274
|
}, null, 2),
|
|
262
275
|
}],
|
|
263
276
|
}),
|
|
@@ -266,6 +279,41 @@ export function createMcpSubstrate() {
|
|
|
266
279
|
return substrate
|
|
267
280
|
}
|
|
268
281
|
|
|
282
|
+
// Derive a stable snapshot of "where outputs are visible to the user."
|
|
283
|
+
// Three cases:
|
|
284
|
+
// 1. --server is on → engine owns Express, knows port → full URL
|
|
285
|
+
// 2. external app → caller supplied runtime.options.app; URL not
|
|
286
|
+
// visible to engine (port unknown), but the
|
|
287
|
+
// outputFolder and path conventions are still
|
|
288
|
+
// useful for preview-writing tools
|
|
289
|
+
// 3. no server → only the static folder layout applies; an
|
|
290
|
+
// agent should not try to advertise a URL
|
|
291
|
+
//
|
|
292
|
+
// Kept as a function rather than a const so each call re-reads
|
|
293
|
+
// runtime.options — covers the case where --server flips on after
|
|
294
|
+
// the substrate was created (rare but possible programmatically).
|
|
295
|
+
function serverInfo() {
|
|
296
|
+
const opts = runtime.options
|
|
297
|
+
const hasInternalServer = opts.server != null && opts.port != null
|
|
298
|
+
const hasExternalApp = opts.app && !hasInternalServer
|
|
299
|
+
|
|
300
|
+
const base = hasInternalServer
|
|
301
|
+
? `http://localhost:${opts.port}`
|
|
302
|
+
: null
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
running: hasInternalServer ? 'internal' : (hasExternalApp ? 'external' : 'none'),
|
|
306
|
+
port: opts.port ?? null,
|
|
307
|
+
url: base,
|
|
308
|
+
serves: opts.outputFolder ?? null,
|
|
309
|
+
mcpPath: opts.mcpPath ?? null,
|
|
310
|
+
mcpUrl: base && opts.mcpPath ? `${base}${opts.mcpPath}` : null,
|
|
311
|
+
// Preview URLs are returned directly by mikser_preview (api
|
|
312
|
+
// plugin), so we don't advertise a path convention here —
|
|
313
|
+
// doing so would be a lie when the api plugin isn't loaded.
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
269
317
|
/**
|
|
270
318
|
* Mount the MCP substrate on an Express app at the given path
|
|
271
319
|
* (default `/mcp`). Each connecting client gets its own
|
|
@@ -375,11 +423,15 @@ function pinoLevelNumber(name) {
|
|
|
375
423
|
return { trace: 10, debug: 20, info: 30, warn: 40, error: 50, fatal: 60 }[name] ?? 30
|
|
376
424
|
}
|
|
377
425
|
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
}
|
|
426
|
+
// (whenMcpActive was removed in v7.4.0 — plugins now use the same
|
|
427
|
+
// inline pattern as Express route registration: gate on the runtime
|
|
428
|
+
// option inside whichever lifecycle hook makes sense.
|
|
429
|
+
//
|
|
430
|
+
// onLoaded(async () => {
|
|
431
|
+
// if (runtime.options.mcp) {
|
|
432
|
+
// runtime.options.mcp.simpleTool(...)
|
|
433
|
+
// }
|
|
434
|
+
// })
|
|
435
|
+
//
|
|
436
|
+
// Reveals timing, matches the Express pattern, doesn't lock the
|
|
437
|
+
// registration into onLoaded.)
|
package/src/plugins/api.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
|
-
import { access, writeFile, mkdir, rm } from 'node:fs/promises'
|
|
2
|
+
import { access, writeFile, readFile, mkdir, rm } from 'node:fs/promises'
|
|
3
3
|
import { createHash } from 'node:crypto'
|
|
4
4
|
import _ from 'lodash'
|
|
5
5
|
import sift from 'sift'
|
|
@@ -333,6 +333,11 @@ export default ({
|
|
|
333
333
|
const globalPageSize = runtime.config.api?.pageSize ?? 10
|
|
334
334
|
const globalRenderTimeout = runtime.config.api?.renderTimeout ?? 30_000
|
|
335
335
|
|
|
336
|
+
// Preview workflow (render → cache → URL) lives in its own
|
|
337
|
+
// plugin (src/plugins/preview.js) as of v7.3.0. The api plugin
|
|
338
|
+
// stays focused on REST catalog access; preview is a separate
|
|
339
|
+
// domain. To use mikser_preview, load `preview` alongside `api`.
|
|
340
|
+
|
|
336
341
|
// cachedEndpoints is hoisted above (shared with onFinalize).
|
|
337
342
|
// Per-endpoint setup loop pushes into it when cache: true is set.
|
|
338
343
|
|
|
@@ -637,9 +642,9 @@ export default ({
|
|
|
637
642
|
ep.token ? '[token]' : '[public]')
|
|
638
643
|
}
|
|
639
644
|
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
//
|
|
645
|
+
// MCP tool registrations. MCP is in-process: whoever can reach
|
|
646
|
+
// the /mcp transport already controls the engine. So the tool
|
|
647
|
+
// surface mirrors the *admin*-
|
|
643
648
|
// shape (list/query/read/update/delete/render) without the HTTP
|
|
644
649
|
// endpoint's token gate or query scope — the catalog is global.
|
|
645
650
|
// Tools register once (not per HTTP endpoint); plugin-author
|
|
@@ -695,11 +700,12 @@ export default ({
|
|
|
695
700
|
|
|
696
701
|
mcp.simpleTool(
|
|
697
702
|
'mikser_read_entity',
|
|
698
|
-
'Read a single entity by its catalog id (e.g. "/documents/about.md"). Returns the full entity record or null when not found.',
|
|
703
|
+
'Read a single entity by its catalog id (e.g. "/documents/about.md"). Returns the full entity record or null when not found. Pass include: ["content"] to also fetch the source file content from disk — useful for reading a layout template, document frontmatter+body, or any text-format source without dropping out to the filesystem.',
|
|
699
704
|
{
|
|
700
705
|
id: z.string().describe('Catalog id of the entity to read.'),
|
|
706
|
+
include: z.array(z.enum(['content'])).optional().describe('Optional list of extra fields to populate. Currently only "content" is supported: reads the file at entity.uri and attaches it as .content (text formats only — md, html, yml, liquid, hbs, eta, json, css, js, svg, xml, mjml).'),
|
|
701
707
|
},
|
|
702
|
-
async ({ id }) => {
|
|
708
|
+
async ({ id, include }) => {
|
|
703
709
|
try {
|
|
704
710
|
if (!id) return fail('id is required')
|
|
705
711
|
const { items } = await runQuery({
|
|
@@ -708,7 +714,37 @@ export default ({
|
|
|
708
714
|
scope: null,
|
|
709
715
|
findEntities,
|
|
710
716
|
})
|
|
711
|
-
|
|
717
|
+
const entity = items[0]
|
|
718
|
+
if (!entity) return ok(null)
|
|
719
|
+
|
|
720
|
+
if (include?.includes('content') && entity.uri) {
|
|
721
|
+
// Heuristic — read content only for text-like
|
|
722
|
+
// formats. Binary types (png, pdf, mp4, etc.)
|
|
723
|
+
// get a marker so the caller knows to use a
|
|
724
|
+
// different tool (mikser_render, or fetch
|
|
725
|
+
// directly) rather than expecting bytes back.
|
|
726
|
+
const TEXT_EXTS = new Set([
|
|
727
|
+
'md', 'markdown', 'html', 'htm', 'xhtml',
|
|
728
|
+
'yml', 'yaml', 'json', 'jsonc',
|
|
729
|
+
'txt', 'csv', 'tsv',
|
|
730
|
+
'css', 'js', 'mjs', 'cjs', 'ts',
|
|
731
|
+
'liquid', 'hbs', 'handlebars', 'eta', 'mustache',
|
|
732
|
+
'svg', 'xml', 'mjml', 'rss', 'atom',
|
|
733
|
+
'aml',
|
|
734
|
+
])
|
|
735
|
+
const ext = path.extname(entity.uri).slice(1).toLowerCase()
|
|
736
|
+
if (TEXT_EXTS.has(ext)) {
|
|
737
|
+
try {
|
|
738
|
+
entity.content = await readFile(entity.uri, 'utf8')
|
|
739
|
+
} catch (err) {
|
|
740
|
+
entity.contentError = err.message
|
|
741
|
+
}
|
|
742
|
+
} else {
|
|
743
|
+
entity.contentSkipped = `Non-text format (.${ext}). Use mikser_render to materialize output or read the file directly at entity.uri.`
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
return ok(entity)
|
|
712
748
|
} catch (err) {
|
|
713
749
|
logger.error('MCP mikser_read_entity error: %s', err.message)
|
|
714
750
|
return fail(err.message)
|
|
@@ -755,7 +791,7 @@ export default ({
|
|
|
755
791
|
|
|
756
792
|
mcp.simpleTool(
|
|
757
793
|
'mikser_render',
|
|
758
|
-
'Render a transient entity through the engine pipeline (parse → layouts → resources → render → postprocess) and return the produced bytes. Use this for "preview this layout against this data" without writing the entity to disk. Set options.save=false to skip the disk write; options.catalog=false to prune the catalog row after rendering.',
|
|
794
|
+
'Render a transient entity through the engine pipeline (parse → layouts → resources → render → postprocess) and return the FINAL produced bytes. Use this for "preview this layout against this data" without writing the entity to disk. The returned bytes are the pipeline\'s final output — PDF for a `*.html-pdf.*` layout, MJML-derived HTML for `*.html-mjml.*`, etc. Set options.save=false to skip the disk write; options.catalog=false to prune the catalog row after rendering.',
|
|
759
795
|
{
|
|
760
796
|
entity: z.record(z.any()).describe('Entity shape with at least { id, collection } and any meta/content the renderer needs.'),
|
|
761
797
|
options: z.record(z.any()).optional().describe('Renderer options: { save: false, catalog: false, renderer: "...", postprocessor: "..." }.'),
|
|
@@ -798,7 +834,7 @@ export default ({
|
|
|
798
834
|
},
|
|
799
835
|
)
|
|
800
836
|
|
|
801
|
-
logger.info('MCP tools registered: list/read/update/delete/render')
|
|
837
|
+
logger.info('MCP tools registered: list/read/update/delete/render (api plugin)')
|
|
802
838
|
}
|
|
803
839
|
})
|
|
804
840
|
|
package/src/plugins/layouts.js
CHANGED
|
@@ -1,8 +1,82 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
|
-
import { mkdir, writeFile, unlink, rmdir } from 'node:fs/promises'
|
|
2
|
+
import { mkdir, writeFile, unlink, rmdir, readFile } from 'node:fs/promises'
|
|
3
3
|
import { existsSync } from 'node:fs'
|
|
4
4
|
import { globby } from 'globby'
|
|
5
5
|
import _ from 'lodash'
|
|
6
|
+
import { z } from 'zod'
|
|
7
|
+
|
|
8
|
+
// Liquid / Handlebars / Eta keywords we don't want surfaced as
|
|
9
|
+
// "variables this layout references." Anything that looks like a path
|
|
10
|
+
// (`document.meta.X`) survives; bare keywords filter out.
|
|
11
|
+
const TEMPLATE_KEYWORDS = new Set([
|
|
12
|
+
'if', 'else', 'elsif', 'endif', 'unless', 'endunless',
|
|
13
|
+
'for', 'endfor', 'each', 'break', 'continue', 'in', 'of',
|
|
14
|
+
'case', 'when', 'endcase', 'switch',
|
|
15
|
+
'capture', 'endcapture', 'assign', 'include', 'layout', 'block',
|
|
16
|
+
'endblock', 'comment', 'endcomment', 'raw', 'endraw',
|
|
17
|
+
'true', 'false', 'nil', 'null', 'and', 'or', 'not', 'with',
|
|
18
|
+
])
|
|
19
|
+
|
|
20
|
+
// Naive multi-engine template scan. Hits liquid (`{{ X }}`, `{% ... %}`),
|
|
21
|
+
// handlebars (`{{ X }}`, `{{#each X}}`), and eta (`<%= X %>`,
|
|
22
|
+
// `<% for X of Y %>`). Returns up to three buckets:
|
|
23
|
+
// variables — bare identifier paths used in output position
|
|
24
|
+
// includes — referenced sub-templates (liquid `include` / `layout`)
|
|
25
|
+
// iterations — `for X in Y` / `each X` shapes so the caller knows
|
|
26
|
+
// where array fields are expected
|
|
27
|
+
//
|
|
28
|
+
// "Naive" is the right word: regex pass, not an AST walk. False positives
|
|
29
|
+
// possible; per-engine plugins can register smarter parsers later.
|
|
30
|
+
function parseTemplateReferences(source) {
|
|
31
|
+
const empty = { variables: [], includes: [], iterations: [] }
|
|
32
|
+
if (typeof source !== 'string' || !source) return empty
|
|
33
|
+
|
|
34
|
+
const variables = new Set()
|
|
35
|
+
const includes = new Set()
|
|
36
|
+
const iterations = []
|
|
37
|
+
|
|
38
|
+
// {{ X.Y.Z }} and <%= X.Y.Z %> — output expressions. Capture leading
|
|
39
|
+
// identifier path; ignore filters (`| upcase`), pipes, and anything
|
|
40
|
+
// after a space.
|
|
41
|
+
const outputExpr = /(?:\{\{=?|<%=)\s*([^}%|]+?)(?:\s*\||\s*\}\}|\s*-?%>)/g
|
|
42
|
+
for (const m of source.matchAll(outputExpr)) {
|
|
43
|
+
const expr = m[1].trim()
|
|
44
|
+
const ident = expr.match(/^[a-zA-Z_$][\w$]*(?:\.[a-zA-Z_$][\w$]*)*/)
|
|
45
|
+
if (ident && !TEMPLATE_KEYWORDS.has(ident[0])) {
|
|
46
|
+
variables.add(ident[0])
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Liquid include / layout — sub-template references.
|
|
51
|
+
for (const m of source.matchAll(/\{%\s*include\s+['"]([^'"]+)['"]/g)) {
|
|
52
|
+
includes.add(m[1])
|
|
53
|
+
}
|
|
54
|
+
for (const m of source.matchAll(/\{%\s*layout\s+['"]([^'"]+)['"]/g)) {
|
|
55
|
+
includes.add(m[1])
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Liquid `{% for X in Y %}` and `{% for X in Y.Z %}`.
|
|
59
|
+
for (const m of source.matchAll(/\{%\s*for\s+(\w+)\s+in\s+([\w.]+)/g)) {
|
|
60
|
+
iterations.push({ item: m[1], collection: m[2] })
|
|
61
|
+
variables.add(m[2])
|
|
62
|
+
}
|
|
63
|
+
// Handlebars `{{#each X.Y}}`.
|
|
64
|
+
for (const m of source.matchAll(/\{\{#each\s+([\w.]+)/g)) {
|
|
65
|
+
iterations.push({ item: '(each)', collection: m[1] })
|
|
66
|
+
variables.add(m[1])
|
|
67
|
+
}
|
|
68
|
+
// Eta `<% for (const X of Y) { %>` / `<% for X of Y %>`.
|
|
69
|
+
for (const m of source.matchAll(/<%\s*for\s*\(?\s*(?:const|let|var)?\s*(\w+)\s+of\s+([\w.]+)/g)) {
|
|
70
|
+
iterations.push({ item: m[1], collection: m[2] })
|
|
71
|
+
variables.add(m[2])
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
variables: Array.from(variables).sort(),
|
|
76
|
+
includes: Array.from(includes).sort(),
|
|
77
|
+
iterations,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
6
80
|
|
|
7
81
|
export default ({
|
|
8
82
|
runtime,
|
|
@@ -370,22 +444,29 @@ export default ({
|
|
|
370
444
|
onComplete(async ({ entity, options, output }) => {
|
|
371
445
|
const logger = useLogger()
|
|
372
446
|
if (entity.layout && !options?.ignore && output.result != null) {
|
|
373
|
-
// `
|
|
374
|
-
// { save: false }) opts out of writing the
|
|
375
|
-
// disk. The bytes still come back to the
|
|
376
|
-
// output.result. Strict equality — only the
|
|
377
|
-
// opts out, matching the catalog-flag pattern.
|
|
447
|
+
// `entity.options.save === false` (set by useRenderer when
|
|
448
|
+
// called with { save: false }) opts out of writing the
|
|
449
|
+
// FINAL output to disk. The bytes still come back to the
|
|
450
|
+
// caller via output.result. Strict equality — only the
|
|
451
|
+
// literal `false` opts out, matching the catalog-flag pattern.
|
|
378
452
|
//
|
|
379
453
|
// The intermediate file (when a postprocessor will run next)
|
|
380
|
-
// must still
|
|
381
|
-
//
|
|
382
|
-
//
|
|
383
|
-
//
|
|
454
|
+
// must still exist somewhere on disk so the postprocessor
|
|
455
|
+
// can consume it. For save:true, that's outputFolder; for
|
|
456
|
+
// save:false, that's runtime.options.previewFolder — an
|
|
457
|
+
// engine-owned scratch path under runtimeFolder, never
|
|
458
|
+
// exposed in user-visible outputFolder. The postprocess
|
|
459
|
+
// task's outputFolder is rewritten in engine.js so post
|
|
460
|
+
// plugins resolve `entity.origin` against the same base.
|
|
384
461
|
const isFinal = !entity.layout.postprocessor || entity.origin != null
|
|
385
|
-
const
|
|
462
|
+
const previewMode = entity.options?.save === false
|
|
463
|
+
const skipWrite = previewMode && isFinal
|
|
464
|
+
const writeBase = (previewMode && !isFinal)
|
|
465
|
+
? runtime.options.previewFolder
|
|
466
|
+
: runtime.options.outputFolder
|
|
386
467
|
|
|
387
468
|
if (!skipWrite) {
|
|
388
|
-
const destinationFile = path.join(
|
|
469
|
+
const destinationFile = path.join(writeBase, entity.destination)
|
|
389
470
|
await mkdir(path.dirname(destinationFile), { recursive: true })
|
|
390
471
|
try {
|
|
391
472
|
await unlink(destinationFile)
|
|
@@ -401,7 +482,14 @@ export default ({
|
|
|
401
482
|
// wrote to (post plugins that produce the same extension as
|
|
402
483
|
// the renderer's output — e.g. MJML→HTML on `*.html-mjml.*`
|
|
403
484
|
// layouts). Otherwise we'd delete our own final file.
|
|
404
|
-
|
|
485
|
+
//
|
|
486
|
+
// For preview flow (entity.options.save === false) the
|
|
487
|
+
// intermediate lived in previewFolder; for normal flow
|
|
488
|
+
// it lived in outputFolder. Pick the right base.
|
|
489
|
+
const originBase = previewMode
|
|
490
|
+
? runtime.options.previewFolder
|
|
491
|
+
: runtime.options.outputFolder
|
|
492
|
+
const originFile = path.join(originBase, entity.origin)
|
|
405
493
|
try {
|
|
406
494
|
await unlink(originFile)
|
|
407
495
|
} catch { }
|
|
@@ -418,6 +506,94 @@ export default ({
|
|
|
418
506
|
}
|
|
419
507
|
})
|
|
420
508
|
|
|
509
|
+
// mikser_inspect_layout lives in the layouts plugin (not core)
|
|
510
|
+
// because "what does a layout expect?" is layout-specific knowledge.
|
|
511
|
+
// Follows ADR-0006: domain logic → plugin; the MCP substrate stays
|
|
512
|
+
// in core.
|
|
513
|
+
//
|
|
514
|
+
// Gating on runtime.options.mcp inside onLoaded matches the Express
|
|
515
|
+
// pattern (gating on runtime.options.app for route mounts) — same
|
|
516
|
+
// shape, no special wrapper. The check happens once per boot.
|
|
517
|
+
onLoaded(() => {
|
|
518
|
+
if (!runtime.options.mcp) return
|
|
519
|
+
const mcp = runtime.options.mcp
|
|
520
|
+
mcp.simpleTool(
|
|
521
|
+
'mikser_inspect_layout',
|
|
522
|
+
'Inspect a layout: template source, variables it references, the postprocessor it produces, and sample entities currently using it. Use this to answer "what data does this layout need?" before drafting a preview render — saves a guess-and-render-empty cycle.',
|
|
523
|
+
{
|
|
524
|
+
id: z.string().describe('Layout id, e.g. "/layouts/reports/royalty.html-pdf.liquid". Use mikser_list_entities with { collection: "layouts" } to discover ids.'),
|
|
525
|
+
samples: z.number().int().min(0).max(10).optional().describe('How many existing entities currently using this layout to include as data-shape examples. Default 3. Only entities with explicit meta.layout match; auto-matched layouts are not surfaced.'),
|
|
526
|
+
},
|
|
527
|
+
async ({ id, samples = 3 }) => {
|
|
528
|
+
const logger = useLogger()
|
|
529
|
+
try {
|
|
530
|
+
const layout = await findEntity({ id })
|
|
531
|
+
if (!layout || layout.collection !== collection) {
|
|
532
|
+
return {
|
|
533
|
+
isError: true,
|
|
534
|
+
content: [{ type: 'text', text: `Layout not found: ${id}` }],
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
let template = ''
|
|
539
|
+
try {
|
|
540
|
+
template = await readFile(layout.uri, 'utf8')
|
|
541
|
+
} catch (err) {
|
|
542
|
+
return {
|
|
543
|
+
isError: true,
|
|
544
|
+
content: [{
|
|
545
|
+
type: 'text',
|
|
546
|
+
text: `Layout entity exists but template file unreadable (${layout.uri}): ${err.message}`,
|
|
547
|
+
}],
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const references = parseTemplateReferences(template)
|
|
552
|
+
|
|
553
|
+
let sampleEntities = []
|
|
554
|
+
if (samples > 0) {
|
|
555
|
+
const all = await findEntities()
|
|
556
|
+
sampleEntities = all
|
|
557
|
+
.filter(e => e.meta?.layout === layout.name)
|
|
558
|
+
.slice(0, samples)
|
|
559
|
+
.map(e => ({ id: e.id, name: e.name, meta: e.meta }))
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return {
|
|
563
|
+
content: [{
|
|
564
|
+
type: 'text',
|
|
565
|
+
text: JSON.stringify({
|
|
566
|
+
layout: {
|
|
567
|
+
id: layout.id,
|
|
568
|
+
name: layout.name,
|
|
569
|
+
uri: layout.uri,
|
|
570
|
+
format: layout.format,
|
|
571
|
+
template: layout.template,
|
|
572
|
+
postprocessor: layout.postprocessor ?? null,
|
|
573
|
+
},
|
|
574
|
+
templateSource: template,
|
|
575
|
+
references,
|
|
576
|
+
samples: sampleEntities,
|
|
577
|
+
notes: [
|
|
578
|
+
'references.variables is a naive regex pass across liquid/handlebars/eta — false positives possible, but covers the common `{{ document.meta.X }}` and `<%= entity.X %>` patterns.',
|
|
579
|
+
'samples only includes entities with explicit meta.layout. Auto-matched layouts are not listed; use mikser_list_entities with a filename-pattern filter for those.',
|
|
580
|
+
],
|
|
581
|
+
}, null, 2),
|
|
582
|
+
}],
|
|
583
|
+
}
|
|
584
|
+
} catch (err) {
|
|
585
|
+
logger.error('MCP mikser_inspect_layout error: %s', err.message)
|
|
586
|
+
return {
|
|
587
|
+
isError: true,
|
|
588
|
+
content: [{ type: 'text', text: err.message }],
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
},
|
|
592
|
+
)
|
|
593
|
+
const logger = useLogger()
|
|
594
|
+
logger.info('MCP tool registered: mikser_inspect_layout (layouts plugin)')
|
|
595
|
+
})
|
|
596
|
+
|
|
421
597
|
return {
|
|
422
598
|
collection,
|
|
423
599
|
type
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Preview plugin. Owns the "render an entity transiently and surface
|
|
2
|
+
// the bytes at a clickable URL" workflow. Three responsibilities:
|
|
3
|
+
//
|
|
4
|
+
// 1. An in-memory cache (Map<filename, { bytes, mime, expiresAt, size }>)
|
|
5
|
+
// with LRU eviction past a configurable byte cap.
|
|
6
|
+
// 2. An Express GET /preview/:filename route that serves cache entries.
|
|
7
|
+
// 3. The mikser_preview MCP tool, registered on the substrate when
|
|
8
|
+
// --mcp is active.
|
|
9
|
+
//
|
|
10
|
+
// Lives outside the api plugin because preview is not a REST catalog
|
|
11
|
+
// concern — it's a render-and-cache workflow whose lifetime is the
|
|
12
|
+
// process, not the catalog. Lives outside core because it's domain
|
|
13
|
+
// (a workflow), not transport substrate. Per ADR-0006: plugin.
|
|
14
|
+
//
|
|
15
|
+
// Composes independently: needs `runtime.options.app` (any Express
|
|
16
|
+
// app — engine-supplied or external) and `useRenderer` (a core helper).
|
|
17
|
+
// Does NOT need the api plugin. Loading just `preview` + `mcp` is
|
|
18
|
+
// a valid combination for AI-driven workflows that skip the REST API.
|
|
19
|
+
//
|
|
20
|
+
// Library-mode surface: this plugin exposes
|
|
21
|
+
// runtime.options.preview = { store, get, stats }
|
|
22
|
+
// so any plugin or programmatic caller can stash bytes and get a URL
|
|
23
|
+
// back without going through MCP. The mikser_preview tool is a thin
|
|
24
|
+
// wrapper over this surface.
|
|
25
|
+
|
|
26
|
+
import path from 'node:path'
|
|
27
|
+
import { randomUUID } from 'node:crypto'
|
|
28
|
+
import { z } from 'zod'
|
|
29
|
+
import { useRenderer } from '../api.js'
|
|
30
|
+
import { mimeForEntity } from './api.js'
|
|
31
|
+
|
|
32
|
+
export default ({
|
|
33
|
+
runtime,
|
|
34
|
+
onLoaded,
|
|
35
|
+
useLogger,
|
|
36
|
+
}) => {
|
|
37
|
+
// Factory-scope cache. One per engine instance. Module-scope would
|
|
38
|
+
// share across multiple engines in the same Node process, which is
|
|
39
|
+
// a scenario mikser doesn't really support.
|
|
40
|
+
const previews = new Map() // filename → { bytes, mime, expiresAt, size }
|
|
41
|
+
let bytesInUse = 0
|
|
42
|
+
|
|
43
|
+
// Config knobs with sensible defaults. The defaults match the
|
|
44
|
+
// values 7.2.x shipped under the api plugin so existing callers
|
|
45
|
+
// see no change in behavior — just a different plugin owner.
|
|
46
|
+
const config = () => ({
|
|
47
|
+
maxBytes: runtime.config.preview?.maxBytes ?? (100 * 1024 * 1024),
|
|
48
|
+
defaultTtl: runtime.config.preview?.defaultTtl ?? 600,
|
|
49
|
+
ttlMin: runtime.config.preview?.ttlMin ?? 30,
|
|
50
|
+
ttlMax: runtime.config.preview?.ttlMax ?? 3600,
|
|
51
|
+
path: runtime.config.preview?.path ?? '/preview',
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// Three primitives the rest of the plugin (and library-mode
|
|
55
|
+
// callers) build on: store(), get(), stats(). All operate against
|
|
56
|
+
// the closed-over `previews` Map and `bytesInUse` counter.
|
|
57
|
+
function store({ filename, bytes, mime, ttlMs }) {
|
|
58
|
+
const size = Buffer.isBuffer(bytes) ? bytes.length : Buffer.byteLength(bytes)
|
|
59
|
+
const cfg = config()
|
|
60
|
+
// LRU evict until there's room. JS Map preserves insertion
|
|
61
|
+
// order — `keys().next().value` is the oldest entry.
|
|
62
|
+
while (bytesInUse + size > cfg.maxBytes && previews.size > 0) {
|
|
63
|
+
const oldestKey = previews.keys().next().value
|
|
64
|
+
const oldest = previews.get(oldestKey)
|
|
65
|
+
bytesInUse -= oldest.size
|
|
66
|
+
previews.delete(oldestKey)
|
|
67
|
+
}
|
|
68
|
+
previews.set(filename, { bytes, mime, expiresAt: Date.now() + ttlMs, size })
|
|
69
|
+
bytesInUse += size
|
|
70
|
+
return { filename, size, expiresAt: Date.now() + ttlMs }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function get(filename) {
|
|
74
|
+
const entry = previews.get(filename)
|
|
75
|
+
if (!entry) return null
|
|
76
|
+
if (entry.expiresAt < Date.now()) {
|
|
77
|
+
bytesInUse -= entry.size
|
|
78
|
+
previews.delete(filename)
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
// Touch — move to tail for LRU recency.
|
|
82
|
+
previews.delete(filename)
|
|
83
|
+
previews.set(filename, entry)
|
|
84
|
+
return entry
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function stats() {
|
|
88
|
+
return {
|
|
89
|
+
count: previews.size,
|
|
90
|
+
bytesInUse,
|
|
91
|
+
maxBytes: config().maxBytes,
|
|
92
|
+
utilization: bytesInUse / config().maxBytes,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Expose the cache surface at runtime.options.preview so other
|
|
97
|
+
// plugins / library callers can use it without going through MCP.
|
|
98
|
+
// Done at factory-eval time (before any onLoaded fires) so a
|
|
99
|
+
// later plugin's onLoad / onLoaded can already see it.
|
|
100
|
+
runtime.options.preview = { store, get, stats }
|
|
101
|
+
|
|
102
|
+
// HTTP route: served regardless of whether MCP is on, so previews
|
|
103
|
+
// are also reachable from library-mode callers that stored bytes
|
|
104
|
+
// via runtime.options.preview.store() directly.
|
|
105
|
+
onLoaded(async () => {
|
|
106
|
+
const logger = useLogger()
|
|
107
|
+
const app = runtime.options.app
|
|
108
|
+
if (!app) {
|
|
109
|
+
// No HTTP app means previews can still be stored
|
|
110
|
+
// programmatically but the URL won't be reachable. That's
|
|
111
|
+
// a valid configuration for batch / one-shot uses; we
|
|
112
|
+
// just don't mount the route.
|
|
113
|
+
logger.debug('Preview plugin: no runtime.options.app — cache available, route not mounted')
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const cfg = config()
|
|
118
|
+
const routePath = `${cfg.path.replace(/\/$/, '')}/:filename`
|
|
119
|
+
app.get(routePath, (req, res) => {
|
|
120
|
+
const entry = get(req.params.filename)
|
|
121
|
+
if (!entry) {
|
|
122
|
+
res.status(404).type('text/plain').send('Preview expired or not found')
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
res.type(entry.mime).send(entry.bytes)
|
|
126
|
+
})
|
|
127
|
+
logger.info('Preview route mounted: %s (cache cap: %d MB)', cfg.path, Math.round(cfg.maxBytes / 1024 / 1024))
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
// Gating on runtime.options.mcp inside onLoaded matches the route-
|
|
131
|
+
// mount pattern above. Same shape as `if (!app)`: check the flag
|
|
132
|
+
// in the hook, register if present. No special wrapper needed.
|
|
133
|
+
onLoaded(() => {
|
|
134
|
+
if (!runtime.options.mcp) return
|
|
135
|
+
const mcp = runtime.options.mcp
|
|
136
|
+
const { render: previewRender } = useRenderer(runtime, {
|
|
137
|
+
defaultTimeout: runtime.config.preview?.renderTimeout ?? 30_000,
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
mcp.simpleTool(
|
|
141
|
+
'mikser_preview',
|
|
142
|
+
'Render an entity through the engine pipeline AND surface the FINAL output as a clickable URL served by the running --server. Use this instead of mikser_render when the user needs to see the result in a browser. The URL serves the pipeline\'s final output — PDF for a `*.html-pdf.*` layout, MJML-derived HTML for `*.html-mjml.*`, etc. Requires --server. Previews live in memory (not on disk, never under outputFolder) and auto-expire — default 10 minutes, clamped 30..3600 seconds.',
|
|
143
|
+
{
|
|
144
|
+
entity: z.record(z.any()).describe('Entity shape with at least { id, collection } and any meta/content the renderer needs. Same shape as mikser_render.'),
|
|
145
|
+
options: z.record(z.any()).optional().describe('Renderer options. Same as mikser_render, plus { expiresInSeconds: number = 600 } controlling preview TTL.'),
|
|
146
|
+
},
|
|
147
|
+
async ({ entity = {}, options = {} }) => {
|
|
148
|
+
const logger = useLogger()
|
|
149
|
+
const ok = (data) => ({
|
|
150
|
+
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
|
|
151
|
+
})
|
|
152
|
+
const fail = (msg) => ({
|
|
153
|
+
isError: true,
|
|
154
|
+
content: [{ type: 'text', text: msg }],
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
if (!runtime.options.port) {
|
|
159
|
+
return fail('mikser_preview requires --server to be running so the preview URL is reachable. Use mikser_render to get raw bytes inline instead.')
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const { expiresInSeconds = config().defaultTtl, ...renderOptions } = options ?? {}
|
|
163
|
+
const { output, entity: rendered } = await previewRender(entity, {
|
|
164
|
+
...renderOptions,
|
|
165
|
+
save: false,
|
|
166
|
+
catalog: false,
|
|
167
|
+
})
|
|
168
|
+
const result = output?.result
|
|
169
|
+
if (result == null) {
|
|
170
|
+
return fail('Render produced no output. Check that the entity has a resolvable layout and the layout matched a registered renderer.')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const destExt = path.extname(rendered.destination || '').slice(1)
|
|
174
|
+
const ext = destExt || 'html'
|
|
175
|
+
const filename = `${randomUUID()}.${ext}`
|
|
176
|
+
const mime = mimeForEntity(rendered) ?? 'application/octet-stream'
|
|
177
|
+
const cfg = config()
|
|
178
|
+
const ttlSec = Math.max(cfg.ttlMin, Math.min(cfg.ttlMax, expiresInSeconds))
|
|
179
|
+
|
|
180
|
+
store({ filename, bytes: result, mime, ttlMs: ttlSec * 1000 })
|
|
181
|
+
|
|
182
|
+
const url = `http://localhost:${runtime.options.port}${cfg.path}/${filename}`
|
|
183
|
+
const bytes = Buffer.isBuffer(result) ? result.length : Buffer.byteLength(result)
|
|
184
|
+
|
|
185
|
+
logger.info('MCP mikser_preview cached %s (%d bytes, ttl %ds): %s', filename, bytes, ttlSec, url)
|
|
186
|
+
|
|
187
|
+
return ok({
|
|
188
|
+
previewUrl: url,
|
|
189
|
+
mimeType: mime,
|
|
190
|
+
bytes,
|
|
191
|
+
expiresInSeconds: ttlSec,
|
|
192
|
+
instructions: 'Open previewUrl in a browser to view. The preview lives in mikser memory and auto-expires after expiresInSeconds — re-run mikser_preview to refresh.',
|
|
193
|
+
})
|
|
194
|
+
} catch (err) {
|
|
195
|
+
logger.error('MCP mikser_preview error: %s', err.message)
|
|
196
|
+
return fail(err.message)
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
const logger = useLogger()
|
|
202
|
+
logger.info('MCP tool registered: mikser_preview (preview plugin)')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
return { name: 'preview' }
|
|
206
|
+
}
|