mikser-io-mcp 0.2.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/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "mikser-io-mcp",
3
+ "version": "0.2.0",
4
+ "description": "MCP (Model Context Protocol) substrate and tools for mikser-io. Extracted from core to iterate on its own release cadence.",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "files": [
8
+ "index.js",
9
+ "preview.js",
10
+ "public",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "scripts": {
15
+ "test": "npm run test:unit",
16
+ "test:unit": "node --test --test-reporter=spec 'test/unit/**/*.test.js'"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/almero-digital-marketing/mikser-io-mcp.git"
21
+ },
22
+ "keywords": [
23
+ "mikser",
24
+ "mikser-io",
25
+ "mcp",
26
+ "model-context-protocol",
27
+ "ai-agent"
28
+ ],
29
+ "author": "",
30
+ "license": "MIT",
31
+ "bugs": {
32
+ "url": "https://github.com/almero-digital-marketing/mikser-io-mcp/issues"
33
+ },
34
+ "homepage": "https://github.com/almero-digital-marketing/mikser-io-mcp#readme",
35
+ "dependencies": {
36
+ "@modelcontextprotocol/sdk": "^1.29.0",
37
+ "minimatch": "^10.0.3"
38
+ },
39
+ "peerDependencies": {
40
+ "mikser-io": "^8.2.0",
41
+ "zod": "^4.0.0"
42
+ },
43
+ "devDependencies": {
44
+ "mikser-io": "file:../mikser-io",
45
+ "zod": "^4.0.0"
46
+ }
47
+ }
package/preview.js ADDED
@@ -0,0 +1,532 @@
1
+ // MCP-UI surface for mikser-io-mcp. Registers everything that
2
+ // surfaces UI through MCP: the shell resource, the discovery resource
3
+ // for mcpUi modes, the mikser_preview_ui tool (with _meta.ui.resourceUri
4
+ // pointing at the shell), the mikser_ui_action tool (with
5
+ // _meta.ui.visibility=['app']), the forwardToHandler webhook helper,
6
+ // and the mikser_preview_render tool that streams pipeline output via
7
+ // the in-memory preview cache.
8
+ //
9
+ // The cache itself lives in mikser-io's preview plugin
10
+ // (runtime.options.preview.{store, get, stats, config}). This module
11
+ // reaches into it via that surface — no cross-plugin imports.
12
+
13
+ import path from 'node:path'
14
+ import { readFileSync } from 'node:fs'
15
+ import { fileURLToPath } from 'node:url'
16
+ import { randomUUID, createHmac } from 'node:crypto'
17
+ import { z } from 'zod'
18
+ import { useRenderer, mimeForEntity, matchEntity } from 'mikser-io'
19
+
20
+ // Resolve a path relative to this module's file. Used to find the
21
+ // public/ folder regardless of where the package is installed (works
22
+ // in node_modules, file: deps, npm-link setups, etc.).
23
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
24
+
25
+ // Forward an MCP-UI action to an external handler URL. Returns the
26
+ // handler's JSON response, which becomes the tool result. Throws on
27
+ // network error, non-2xx status, timeout, or invalid response shape —
28
+ // callers fall back to pure-relay on throw.
29
+ //
30
+ // HMAC signing: when handler.secret is set, we sign the request body
31
+ // with sha256(secret) and pass it in X-Mikser-Signature. Receivers
32
+ // MUST verify before processing. When secret is unset, no signature
33
+ // is sent (acceptable for dev; not recommended in production — see
34
+ // ADR-0008).
35
+ //
36
+ // Extracted as a standalone function so it can be unit-tested with a
37
+ // mock URL and so mikser_ui_action's main path stays readable.
38
+ export async function forwardToHandler(handler, body) {
39
+ const { url, secret, timeout = 5000 } = handler
40
+ if (!url) throw new Error('forwardToHandler: handler.url is required')
41
+
42
+ const json = JSON.stringify({
43
+ ...body,
44
+ // Timestamp is set inside the forward, not by the caller, so
45
+ // a stale callback that came in via a slow network still has
46
+ // a fresh timestamp on the outgoing forward. Receivers that
47
+ // care can put their own.
48
+ timestamp: new Date().toISOString(),
49
+ })
50
+
51
+ const headers = {
52
+ 'content-type': 'application/json',
53
+ 'x-mikser-layout-id': body.layoutId ?? '',
54
+ 'x-mikser-mode': body.mode ?? '',
55
+ 'x-mikser-request-id': randomUUID(),
56
+ }
57
+ if (secret) {
58
+ const sig = createHmac('sha256', secret).update(json).digest('hex')
59
+ headers['x-mikser-signature'] = `sha256=${sig}`
60
+ }
61
+
62
+ const ac = new AbortController()
63
+ const timer = setTimeout(() => ac.abort(), timeout)
64
+
65
+ let res
66
+ try {
67
+ res = await fetch(url, {
68
+ method: 'POST',
69
+ headers,
70
+ body: json,
71
+ signal: ac.signal,
72
+ })
73
+ } catch (err) {
74
+ clearTimeout(timer)
75
+ if (err.name === 'AbortError') {
76
+ throw new Error(`Handler timeout (${timeout}ms) — ${url}`)
77
+ }
78
+ throw new Error(`Handler unreachable: ${err.message} — ${url}`)
79
+ }
80
+ clearTimeout(timer)
81
+
82
+ if (!res.ok) {
83
+ const text = await res.text().catch(() => '')
84
+ throw new Error(`Handler ${res.status} ${res.statusText} — ${text.slice(0, 200)}`)
85
+ }
86
+
87
+ const ct = res.headers.get('content-type') ?? ''
88
+ if (ct.includes('application/json')) {
89
+ return await res.json()
90
+ }
91
+ // Non-JSON response — wrap as a structured result so the agent
92
+ // sees something meaningful. The handler is technically off-spec
93
+ // here (ADR-0008 says return JSON), but we don't punish callers
94
+ // for a casual `res.send('ok')` from the handler side.
95
+ const text = await res.text()
96
+ return { ok: true, handlerResponse: text }
97
+ }
98
+
99
+ // The MCP-UI shell HTML. Served as a single `ui://mikser/preview-ui-shell`
100
+ // resource that mikser_preview_ui's _meta.ui.resourceUri points at, per
101
+ // the MCP Apps spec (2026-01-26). Spec-conformant hosts fetch this once
102
+ // per call, load it in a sandboxed iframe, then push the tool's
103
+ // structuredContent via `ui/notifications/tool-result`.
104
+ //
105
+ // What the shell does:
106
+ // - ui/initialize handshake with the host (timeouts at 2s)
107
+ // - Listens for ui/notifications/tool-result → injects structuredContent.html
108
+ // into #mikser-ui-root, re-executes any <script> tags the layout brought
109
+ // - Exposes window.sendAction(action, payload) — the API layouts use to
110
+ // deliver clicks back as `tools/call` against mikser_ui_action
111
+ // - Logs every protocol event to an in-iframe debug panel so authors can
112
+ // see exactly where the spec round-trip fails on hosts that don't bridge
113
+ //
114
+ // Why a shell at all: per spec, tool UIs are RESOURCES. The host fetches
115
+ // the resource (static) and receives the dynamic per-call data via the
116
+ // tool-result notification. Layouts can stay server-side-templated against
117
+ // the entity (mikser's strength) — they just produce *body content*, not
118
+ // full HTML documents. The shell handles the protocol; layouts handle the
119
+ // content. See documentation/decisions/0008-mcp-ui-action-delivery.md.
120
+ const PREVIEW_UI_SHELL_HTML = readFileSync(path.join(__dirname, "public", "preview-ui-shell.html"), "utf8")
121
+
122
+ // Plugin function — invoked by mikser-io-mcp/index.js's factory after
123
+ // the substrate is set up. Registers the MCP-UI surface (shell + modes
124
+ // resource + preview_ui + ui_action) AND the preview-render tool (which
125
+ // reaches into runtime.options.preview for the cache).
126
+ //
127
+ // Not a default export plugin in the mikser sense — this is internal
128
+ // composition. The mcp plugin's index.js is what mikser loads; this
129
+ // file is just an organization unit.
130
+ export default ({
131
+ runtime,
132
+ onLoaded,
133
+ useLogger,
134
+ findEntity,
135
+ findEntities,
136
+ }) => {
137
+ onLoaded(() => {
138
+ if (!runtime.options.mcp) return
139
+ const mcp = runtime.options.mcp
140
+ const { render: previewRender } = useRenderer(runtime, {
141
+ defaultTimeout: runtime.config.preview?.renderTimeout ?? 30_000,
142
+ })
143
+
144
+ // mikser_preview_render — render an entity through the pipeline,
145
+ // stash the bytes in the in-memory preview cache (provided by
146
+ // mikser-io core's preview plugin via runtime.options.preview),
147
+ // and return a clickable URL. Requires --server (or any
148
+ // engine-supplied Express app) so the URL is reachable.
149
+ mcp.simpleTool(
150
+ 'mikser_preview_render',
151
+ '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.',
152
+ {
153
+ 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.'),
154
+ options: z.record(z.any()).optional().describe('Renderer options. Same as mikser_render, plus { expiresInSeconds: number = 600 } controlling preview TTL.'),
155
+ },
156
+ async ({ entity = {}, options = {} }) => {
157
+ const logger = useLogger()
158
+ const preview = runtime.options.preview
159
+ const ok = (data) => ({
160
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
161
+ })
162
+ const fail = (msg) => ({
163
+ isError: true,
164
+ content: [{ type: 'text', text: msg }],
165
+ })
166
+
167
+ try {
168
+ if (!runtime.options.port) {
169
+ return fail('mikser_preview_render requires --server to be running so the preview URL is reachable. Use mikser_render to get raw bytes inline instead.')
170
+ }
171
+ if (!preview) {
172
+ return fail('mikser_preview_render requires the preview cache. Ensure mikser-io core is loaded and runtime.options.preview is available.')
173
+ }
174
+
175
+ const cfg = preview.config()
176
+ const { expiresInSeconds = cfg.defaultTtl, ...renderOptions } = options ?? {}
177
+ const { output, entity: rendered } = await previewRender(entity, {
178
+ ...renderOptions,
179
+ save: false,
180
+ catalog: false,
181
+ })
182
+ const result = output?.result
183
+ if (result == null) {
184
+ return fail('Render produced no output. Check that the entity has a resolvable layout and the layout matched a registered renderer.')
185
+ }
186
+
187
+ const destExt = path.extname(rendered.destination || '').slice(1)
188
+ const ext = destExt || 'html'
189
+ const filename = `${randomUUID()}.${ext}`
190
+ const mime = mimeForEntity(rendered) ?? 'application/octet-stream'
191
+ const ttlSec = Math.max(cfg.ttlMin, Math.min(cfg.ttlMax, expiresInSeconds))
192
+
193
+ preview.store({ filename, bytes: result, mime, ttlMs: ttlSec * 1000 })
194
+
195
+ const url = `http://localhost:${runtime.options.port}${cfg.path}/${filename}`
196
+ const bytes = Buffer.isBuffer(result) ? result.length : Buffer.byteLength(result)
197
+
198
+ logger.debug('MCP mikser_preview_render cached %s (%d bytes, ttl %ds): %s', filename, bytes, ttlSec, url)
199
+
200
+ return ok({
201
+ previewUrl: url,
202
+ mimeType: mime,
203
+ bytes,
204
+ expiresInSeconds: ttlSec,
205
+ instructions: 'Open previewUrl in a browser to view. The preview lives in mikser memory and auto-expires after expiresInSeconds — re-run mikser_preview_render to refresh.',
206
+ })
207
+ } catch (err) {
208
+ logger.error('MCP mikser_preview_render error: %s', err.message)
209
+ return fail(err.message)
210
+ }
211
+ },
212
+ )
213
+
214
+ // Discovery resource. Read this BEFORE calling mikser_preview_ui
215
+ // to learn which modes exist in the current project and which
216
+ // entity patterns each one covers — saves a round of guess-and-
217
+ // retry on mode names. Derived live from the catalog, so newly
218
+ // added layouts show up without restarts or tool re-registration.
219
+ mcp.registerResource(
220
+ 'mikser-mcp-ui-modes',
221
+ 'mikser://mcp-ui/modes',
222
+ {
223
+ title: 'MCP-UI modes available in this project',
224
+ description: 'Live list of mcpUi modes and their candidate layouts, derived from layout frontmatter. Read this to discover what mikser_preview_ui can do before calling it.',
225
+ mimeType: 'application/json',
226
+ },
227
+ async (uri) => {
228
+ const all = await findEntities()
229
+ const layouts = all.filter(l =>
230
+ l.collection === 'layouts' && l.meta?.mcpUi)
231
+ const modes = {}
232
+ for (const layout of layouts) {
233
+ const m = layout.meta.mcpUi
234
+ const mode = m.mode ?? 'preview'
235
+ if (!modes[mode]) modes[mode] = []
236
+ modes[mode].push({
237
+ layoutId: layout.id,
238
+ match: layout.meta.match ?? null,
239
+ description: m.description ?? null,
240
+ actions: m.actions ?? [],
241
+ sandbox: m.sandbox ?? ['allow-scripts'],
242
+ })
243
+ }
244
+ return {
245
+ contents: [{
246
+ uri: uri.href,
247
+ mimeType: 'application/json',
248
+ text: JSON.stringify({
249
+ modes,
250
+ totalLayouts: layouts.length,
251
+ notes: [
252
+ "Modes are derived from layout.meta.mcpUi.mode (defaults to 'preview' when omitted).",
253
+ 'Each candidate has a `match` pattern. mikser_preview_ui matches your entityId against these patterns when you call it with that mode.',
254
+ 'Layouts without `mcpUi` frontmatter are not listed here and not eligible for mikser_preview_ui.',
255
+ ],
256
+ }, null, 2),
257
+ }],
258
+ }
259
+ },
260
+ )
261
+
262
+ // ui://mikser/preview-ui-shell — the MCP-UI shell resource.
263
+ // mikser_preview_ui's _meta.ui.resourceUri points here. Hosts
264
+ // that implement MCP Apps fetch this once via resources/read,
265
+ // load it in a sandboxed iframe, then deliver the per-call
266
+ // structuredContent via ui/notifications/tool-result. The shell
267
+ // is static and version-independent across tool calls.
268
+ //
269
+ // mimeType MUST be 'text/html;profile=mcp-app' per the spec —
270
+ // basic-host and other conformant hosts reject anything else.
271
+ mcp.registerResource(
272
+ 'mikser-preview-ui-shell',
273
+ 'ui://mikser/preview-ui-shell',
274
+ {
275
+ title: 'MCP-UI shell for mikser_preview_ui',
276
+ description: 'The static iframe shell that mikser_preview_ui renders into. Handles the MCP Apps protocol (ui/initialize, tool-result injection, tools/call relay) so layouts can be content-only HTML.',
277
+ mimeType: 'text/html;profile=mcp-app',
278
+ },
279
+ async (uri) => ({
280
+ contents: [{
281
+ uri: uri.href,
282
+ mimeType: 'text/html;profile=mcp-app',
283
+ text: PREVIEW_UI_SHELL_HTML,
284
+ }],
285
+ }),
286
+ )
287
+
288
+ // mikser_preview_ui — uses registerTool (not simpleTool) so we
289
+ // can set _meta.ui.resourceUri pointing at the shell. Per the
290
+ // MCP Apps spec, this signals to hosts: "this tool renders UI
291
+ // — fetch the resource at ui:// for the iframe template, and
292
+ // pass the tool's structuredContent to the iframe via
293
+ // ui/notifications/tool-result." Hosts without MCP Apps
294
+ // support fall back to displaying content[0].text.
295
+ mcp.registerTool(
296
+ 'mikser_preview_ui',
297
+ {
298
+ description: 'Render an entity through a layout that declares `mcpUi` frontmatter and return it as a UI block. Selects the layout by matching `entityId` against `layout.meta.match` and filtering on `mode`. **Read `mikser://mcp-ui/modes` first** to discover which modes and entity patterns this project supports. Layouts without `mcpUi` frontmatter are not eligible. Spec-conformant hosts render the result inside an iframe loaded from `ui://mikser/preview-ui-shell`; non-UI hosts display the rendered HTML as text.',
299
+ inputSchema: {
300
+ entityId: z.string().describe('Entity to render, e.g. "/articles/2026-launch".'),
301
+ mode: z.string().optional().describe('Which UI mode to render. Defaults to "preview". Available modes are whatever your layouts declare as `mcpUi.mode`.'),
302
+ },
303
+ _meta: {
304
+ ui: {
305
+ // Spec-required: tells MCP Apps hosts to render
306
+ // this tool's result inside the shell iframe.
307
+ resourceUri: 'ui://mikser/preview-ui-shell',
308
+ },
309
+ },
310
+ },
311
+ async ({ entityId, mode = 'preview' }) => {
312
+ const logger = useLogger()
313
+ const fail = (msg) => ({
314
+ isError: true,
315
+ content: [{ type: 'text', text: msg }],
316
+ })
317
+
318
+ try {
319
+ // Find candidate layouts for this mode. The catalog
320
+ // already has frontmatter-parsed meta — front-matter
321
+ // plugin ran at onProcess. No re-parse needed.
322
+ const all = await findEntities()
323
+ const candidates = all.filter(l =>
324
+ l.collection === 'layouts'
325
+ && l.meta?.mcpUi
326
+ && (l.meta.mcpUi.mode ?? 'preview') === mode
327
+ )
328
+ if (candidates.length === 0) {
329
+ return fail(`No layouts found with mcpUi.mode="${mode}". Author a layout with YAML frontmatter at the top: \`---\\nmatch: "@/articles/*"\\nmcpUi:\\n mode: ${mode}\\n description: "..."\\n actions: [...]\\n---\``)
330
+ }
331
+
332
+ const entity = await findEntity({ id: entityId })
333
+ if (!entity) return fail(`Entity not found: ${entityId}`)
334
+
335
+ const matched = candidates.find(l =>
336
+ l.meta?.match && matchEntity(entity, l.meta.match))
337
+ if (!matched) {
338
+ const patterns = candidates
339
+ .map(l => ` ${l.id}: match=${JSON.stringify(l.meta?.match ?? null)}`)
340
+ .join('\n')
341
+ return fail(`No mcpUi layout matched ${entityId} in mode=${mode}.\nCandidates for this mode:\n${patterns}`)
342
+ }
343
+
344
+ // Force the chosen layout — bypass autoLayouts /
345
+ // layouts.match resolution that onProcessed would do.
346
+ // Both `entity.layout` AND `entity.meta.layout` need
347
+ // to be set: the layouts plugin's onProcessed
348
+ // re-resolves entity.layout from entity.meta.layout
349
+ // on every cycle, and previewRender goes through
350
+ // the full lifecycle. Without overriding meta.layout
351
+ // the agent's `mcp-ui/post-approval` choice gets
352
+ // silently replaced by the production `post` layout.
353
+ const renderEntity = {
354
+ ...entity,
355
+ layout: matched,
356
+ meta: { ...(entity.meta || {}), layout: matched.name },
357
+ }
358
+ const { output } = await previewRender(renderEntity, {
359
+ save: false,
360
+ catalog: false,
361
+ })
362
+ const result = output?.result
363
+ if (result == null) {
364
+ return fail(`Render produced no output for ${entityId} via ${matched.id}. Check that the layout's template engine has a matching renderer plugin loaded.`)
365
+ }
366
+
367
+ const html = typeof result === 'string'
368
+ ? result
369
+ : Buffer.isBuffer(result)
370
+ ? result.toString('utf8')
371
+ : String(result)
372
+
373
+ const mcpUiMeta = matched.meta?.mcpUi ?? {}
374
+ logger.debug('MCP mikser_preview_ui rendered %s via %s (mode=%s, %d chars)',
375
+ entityId, matched.id, mode, html.length)
376
+
377
+ // Structured payload the iframe receives via
378
+ // ui/notifications/tool-result. The shell reads
379
+ // structuredContent.html and injects it into its
380
+ // #mikser-ui-root div; entityId + layoutId are what
381
+ // window.sendAction() forwards back to mikser_ui_action.
382
+ const structured = {
383
+ entityId,
384
+ layoutId: matched.id,
385
+ mode,
386
+ html,
387
+ mcpUi: {
388
+ layoutId: matched.id,
389
+ mode,
390
+ description: mcpUiMeta.description ?? null,
391
+ actions: mcpUiMeta.actions ?? [],
392
+ sandbox: mcpUiMeta.sandbox ?? ['allow-scripts'],
393
+ actionTool: 'mikser_ui_action',
394
+ },
395
+ }
396
+ return {
397
+ // content[0] is the fallback for hosts that don't
398
+ // implement MCP Apps. They'll display this as
399
+ // text — the layout's body HTML — so the user
400
+ // at least sees the rendered content even if
401
+ // the iframe doesn't load.
402
+ content: [
403
+ { type: 'text', text: html, mimeType: 'text/html' },
404
+ ],
405
+ // structuredContent — the spec-mandated way to
406
+ // deliver per-call data to a UI tool's iframe.
407
+ // Hosts pass this to the iframe via
408
+ // ui/notifications/tool-result after the shell
409
+ // (loaded from _meta.ui.resourceUri) completes
410
+ // ui/initialize.
411
+ structuredContent: structured,
412
+ // _meta.mcpUi kept for backward compatibility
413
+ // with hosts/tools that read it directly.
414
+ // structuredContent.mcpUi is the canonical copy
415
+ // going forward.
416
+ _meta: { mcpUi: structured.mcpUi },
417
+ }
418
+ } catch (err) {
419
+ logger.error('MCP mikser_preview_ui error: %s', err.message)
420
+ return fail(err.message)
421
+ }
422
+ },
423
+ )
424
+
425
+ // mikser_ui_action — app-callable tool that delivers a user
426
+ // click from inside an mcpUi iframe back to the agent (or to
427
+ // an external webhook handler).
428
+ //
429
+ // Visibility model (MCP Apps spec 2026-01-26):
430
+ // _meta.ui.visibility = ['app']
431
+ //
432
+ // means this tool is invisible to the agent — it's never
433
+ // listed in the model's tool surface — but iframes opened by
434
+ // mikser_preview_ui can invoke it over the host's AppBridge
435
+ // (tools/call over postMessage). The host bridges the call
436
+ // into a real MCP tools/call, and the agent sees the result
437
+ // as a separate tool turn in the conversation.
438
+ //
439
+ // Auth boundary: the action MUST appear in the layout's
440
+ // declared mcpUi.actions list. Unknown actions return an
441
+ // error result. There is no callId / random URL / signature
442
+ // on this channel — the iframe's only path to mikser is
443
+ // through the host's authenticated MCP transport, which is
444
+ // already trusted.
445
+ //
446
+ // Resolution: pure relay (return { entityId, action, payload }
447
+ // as the tool result) unless the layout declared
448
+ // mcpUi.handler.url, in which case mikser POSTs the action
449
+ // to that URL (HMAC-signed if handler.secret is set) and
450
+ // returns the handler's response. See forwardToHandler above.
451
+ mcp.registerTool(
452
+ 'mikser_ui_action',
453
+ {
454
+ description: 'Deliver a user action emitted from an mcpUi iframe. App-callable only — invisible to the agent, invoked exclusively by iframes opened via mikser_preview_ui. Validates the action against the layout\'s declared mcpUi.actions list, then either returns { entityId, action, payload } as a pure relay or forwards to the layout\'s handler.url webhook if one is declared.',
455
+ inputSchema: {
456
+ entityId: z.string().describe('Entity the action targets (the same id the iframe was rendered for).'),
457
+ layoutId: z.string().describe('Layout that rendered the iframe — used to look up the allowed-actions list and optional handler config.'),
458
+ action: z.string().describe('Action name. Must appear in the layout\'s mcpUi.actions list.'),
459
+ payload: z.record(z.any()).optional().describe('Structured payload — form fields, selected status, etc. Schema is layout-defined; mikser passes it through.'),
460
+ },
461
+ _meta: {
462
+ ui: {
463
+ // App-callable only — invisible to the model
464
+ // per MCP Apps spec. Hosts MUST NOT include
465
+ // it in the agent's tools/list response and
466
+ // MUST allow iframes opened via mikser_preview_ui
467
+ // to invoke it.
468
+ visibility: ['app'],
469
+ },
470
+ },
471
+ },
472
+ async ({ entityId, layoutId, action, payload = {} }) => {
473
+ const logger = useLogger()
474
+ const fail = (msg) => ({
475
+ isError: true,
476
+ content: [{ type: 'text', text: msg }],
477
+ })
478
+ const ok = (data) => ({
479
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
480
+ })
481
+
482
+ try {
483
+ const layout = await findEntity({ id: layoutId })
484
+ if (!layout || layout.collection !== 'layouts') {
485
+ return fail(`Layout not found or not a layout: ${layoutId}`)
486
+ }
487
+ const mcpUiMeta = layout.meta?.mcpUi
488
+ if (!mcpUiMeta) {
489
+ return fail(`Layout ${layoutId} does not declare mcpUi frontmatter — not eligible as an action source.`)
490
+ }
491
+ const allowed = mcpUiMeta.actions ?? []
492
+ if (!allowed.includes(action)) {
493
+ return fail(`Action "${action}" not in allowed list for ${layoutId}. Declared: [${allowed.join(', ')}]`)
494
+ }
495
+
496
+ const result = { entityId, action, payload }
497
+
498
+ if (mcpUiMeta.handler?.url) {
499
+ try {
500
+ const handlerResult = await forwardToHandler(mcpUiMeta.handler, {
501
+ ...result,
502
+ layoutId,
503
+ mode: mcpUiMeta.mode ?? 'preview',
504
+ })
505
+ logger.debug('MCP mikser_ui_action forwarded %s/%s → %s OK',
506
+ entityId, action, mcpUiMeta.handler.url)
507
+ return ok(handlerResult)
508
+ } catch (err) {
509
+ // Fail-safe: never lose the user's click.
510
+ // Surface handler failure to the agent as
511
+ // structured metadata alongside the relay
512
+ // payload so the agent can decide whether
513
+ // to retry or proceed without backend ack.
514
+ logger.warn('MCP mikser_ui_action handler failed (%s) — falling back to pure relay: %s',
515
+ mcpUiMeta.handler.url, err.message)
516
+ return ok({ ...result, handlerError: err.message })
517
+ }
518
+ }
519
+
520
+ logger.debug('MCP mikser_ui_action %s/%s (pure relay)', entityId, action)
521
+ return ok(result)
522
+ } catch (err) {
523
+ logger.error('MCP mikser_ui_action error: %s', err.message)
524
+ return fail(err.message)
525
+ }
526
+ },
527
+ )
528
+
529
+ const logger = useLogger()
530
+ logger.debug('MCP tools registered: mikser_preview_render + mikser_preview_ui + mikser_ui_action + mikser://mcp-ui/modes + ui://mikser/preview-ui-shell (mcp plugin)')
531
+ })
532
+ }