mikser-io 7.12.0 → 8.0.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 CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  Built for Node.js around a strict lifecycle and a composable plugin system. Every document, asset, and template flows through the same pipeline; plugins hook in at any phase. The same engine runs a single markdown blog and a multi-language publishing platform with PDF / email / AI-augmented asset pipelines — same lifecycle, more plugins.
12
12
 
13
- It's MIT-licensed, runs on Node 18+, has zero hosted dependencies, and the entire content tree it manages is a folder of `.md` and `.yml` files you can copy, diff, and version-control. **The portability promise is the architecture, not a feature.**
13
+ It's MIT-licensed, runs on Node, has zero hosted dependencies, and the entire content tree it manages is a folder of `.md` and `.yml` files you can copy, diff, and version-control. **The portability promise is the architecture, not a feature.**
14
14
 
15
15
  > **New to mikser?** Read the [Architecture Overview](./documentation/overview.md) — one document, end-to-end walkthrough of how a file becomes a deployed page across all twenty lifecycle phases. It's the doc most projects need first.
16
16
 
@@ -84,7 +84,7 @@ Add `--mcp` to your mikser command and any MCP-speaking client — Claude Deskto
84
84
  - read every entity in the catalog
85
85
  - write new content files (markdown, layouts, configuration) — writes land on disk and the next cycle picks them up
86
86
  - render any layout for preview without touching the output folder
87
- - **surface rendered UI inline in the conversation** — `mikser_preview_ui` runs an entity through a layout that declares `mcpUi` frontmatter and returns the HTML as a UI block the host can show; an approve/reject button on that block sends the result straight back to the agent
87
+ - **surface interactive UI inline in the conversation** — you author the UI as a normal mikser layout with YAML frontmatter (`mcpUi: { mode, actions }`). The agent reads `mikser://mcp-ui/modes` to discover what UIs your project supports, calls `mikser_preview_ui` to render one against an entity, and the host displays the result as a sandboxed iframe in the chat. Buttons in the UI deliver their click back as a separate MCP tool turn — the iframe sends a JSON-RPC `tools/call` to the host over `postMessage` per the [MCP Apps spec](https://github.com/modelcontextprotocol/ext-apps), which the host bridges into a real `mikser_ui_action` invocation. The agent sees a structured `{action, entityId, payload}` result; if you declared `mcpUi.handler.url`, mikser forwards the action to your webhook first and uses its response. No separate UI framework, no glue code; layouts are still just layouts
88
88
  - watch every build log as it streams past
89
89
  - introspect engine state — current lifecycle phase, effective config, recent log buffer
90
90
 
@@ -103,7 +103,7 @@ When an AI agent edits ten files, the next question is: *did it do what I asked?
103
103
  - **"Did I update every article that needed it?"** — semantic search finds anything that still matches the old tone or phrasing the agent was supposed to change.
104
104
  - **"What else mentions this person, product, or topic?"** — mikser knows how content references content. "Show me every page that mentions Dick" returns the list instantly, no full-tree scan.
105
105
  - **"Did anything break?"** — if a reference points at something that no longer exists, mikser surfaces it as a warning. The build either completes cleanly or doesn't.
106
- - **"Can I see what this looks like before publishing?"** — render any single page or section on demand, no full rebuild, no staging deploy.
106
+ - **"Can I see what this looks like before publishing?"** — render any single page or section on demand, no full rebuild, no staging deploy. With an `mcpUi` layout, the agent surfaces the rendered preview *inside the chat* with approve/reject controls; one click sends the result back as the tool response.
107
107
  - **"What changed since I last looked?"** — `git diff`. The catalog is plain files, so the audit trail is the same one your engineers already use for code.
108
108
  - **"Roll back this batch?"** — `git checkout`. Atomic. No database migration to undo, no version-history-feature to learn.
109
109
 
@@ -153,7 +153,7 @@ The engine is what stays stable — the lifecycle, the catalog, the file-based c
153
153
 
154
154
  ## Client SDKs
155
155
 
156
- The `api`, `vector`, and `schemas` plugins are paired with client-side SDKs so a frontend (or another Node app) can talk to a running mikser server without rolling its own `fetch` glue or type contracts. Zero dependencies, runs in browsers / Node 18+ / Deno / Bun / Workers.
156
+ The `api`, `vector`, and `schemas` plugins are paired with client-side SDKs so a frontend (or another Node app) can talk to a running mikser server without rolling its own `fetch` glue or type contracts. Zero dependencies, runs in browsers / Node / Deno / Bun / Workers.
157
157
 
158
158
  **Transport-level:**
159
159
 
@@ -190,7 +190,7 @@ For a working starter — config with a real plugin set, sample `documents/`, ex
190
190
 
191
191
  - **Lifecycle** — Processing runs through fixed phases: initialize → load → import → process → persist → render → finalize. Plugins hook into any phase.
192
192
  - **Entities** — Everything is an entity (document, file, layout, asset). Entities flow through the journal and are tracked in the catalog.
193
- - **References between entities** — Front-matter keys starting with `$` (e.g. `$author: /authors/dick`) are references. The engine projects them to plain keys (`meta.author`) for templates and SDK consumers; the schemas plugin auto-validates them; the api can `expand` them inline for one-trip graph fetches. The engine also maintains an inverse-reference index at `runtime.refs.*` (graph queries, rename cascade, live-expand subscriptions, MCP tools `mikser_refs_inbound` / `_outbound` / `_broken` / `_rename`). See [ADR-0007](./documentation/decisions/0007-references-declaration-and-expansion.md).
193
+ - **References between entities** — A front-matter key starting with `$` (e.g. `$author: /authors/dick`) points at another entity. The engine knows the whole graph: templates can follow the links, the schemas plugin checks they resolve, and a single query can pull referenced entities along inline instead of one round trip per link. See [ADR-0007](./documentation/decisions/0007-references-declaration-and-expansion.md).
194
194
  - **Plugins** — Functionality is delivered via plugins. Built-in plugins handle common sources (documents, files, layouts, assets). Custom plugins can be added to any project.
195
195
  - **Runtime Singleton** — A plain module-level object holds all global state and coordinates the lifecycle. The ES module cache guarantees every importer gets the same instance.
196
196
  - **Watch Mode** — In watch mode, file changes trigger incremental re-processing without restarting.
@@ -221,7 +221,7 @@ What you get from how this project is built:
221
221
 
222
222
  The earliest version of mikser was inspired by [DocPad](https://github.com/docpad/docpad) (Benjamin Lupton, with Michael Duane Mooring and Rob Loach). DocPad's "freeway, not a box" philosophy — files on disk, any pre-processor or template engine, plugin-by-convention extension — shaped how mikser started.
223
223
 
224
- Mikser itself has a previous chapter: the [legacy 7.x line](https://github.com/almero-digital-marketing/mikser) (last release 2022) introduced the real-time SSG model the current engine still carries forward. The redesign dropped MongoDB (the catalog lives in-process now, not in a database), modernized to Node 18+ ESM with a structured 20-phase lifecycle, added the live SSE channel that powers the framework SDKs, and replaced cluster-based rendering with an async worker pool. Same intent — content as files, real-time previews, multi-format output at scale — clearer foundations.
224
+ Mikser itself has a previous chapter: the [legacy 7.x line](https://github.com/almero-digital-marketing/mikser) (last release 2022) introduced the real-time SSG model the current engine still carries forward. The redesign dropped MongoDB (the catalog lives in-process now, not in a database), modernized to Node ESM with a structured 20-phase lifecycle, added the live SSE channel that powers the framework SDKs, and replaced cluster-based rendering with an async worker pool. Same intent — content as files, real-time previews, multi-format output at scale — clearer foundations.
225
225
 
226
226
  ## Documentation Index
227
227
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "7.12.0",
3
+ "version": "8.0.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": {
package/src/mcp.js CHANGED
@@ -75,9 +75,11 @@ export function createMcpSubstrate() {
75
75
 
76
76
  function bind(server, filters = {}) {
77
77
  const { allowedTools, allowedResources, allowedPrompts } = filters
78
+ const bound = { tools: 0, resources: 0, prompts: 0 }
78
79
  for (const args of registrations.tools) {
79
80
  if (!matchesAny(args[0], allowedTools)) continue
80
81
  server.registerTool(...args)
82
+ bound.tools++
81
83
  }
82
84
  for (const args of registrations.resources) {
83
85
  // Resource registrations are (name, uri, config, handler).
@@ -87,11 +89,14 @@ export function createMcpSubstrate() {
87
89
  const uri = typeof args[1] === 'string' ? args[1] : args[0]
88
90
  if (!matchesAny(uri, allowedResources)) continue
89
91
  server.registerResource(...args)
92
+ bound.resources++
90
93
  }
91
94
  for (const args of registrations.prompts) {
92
95
  if (!matchesAny(args[0], allowedPrompts)) continue
93
96
  server.registerPrompt(...args)
97
+ bound.prompts++
94
98
  }
99
+ return bound
95
100
  }
96
101
 
97
102
  const substrate = {
@@ -101,22 +106,58 @@ export function createMcpSubstrate() {
101
106
  // shape, it just records and replays.
102
107
  registerTool(...args) {
103
108
  registrations.tools.push(args)
109
+ const name = args[0]
110
+ let replayed = 0
111
+ const replayErrors = []
104
112
  for (const s of activeServers) {
105
- try { s.registerTool(...args) } catch { /* dup, etc. */ }
113
+ try { s.registerTool(...args); replayed++ }
114
+ catch (err) { replayErrors.push(err.message) }
115
+ }
116
+ const log = runtime.engine?.logger
117
+ if (log) {
118
+ log.debug('MCP substrate: registered tool %s (total=%d, live-replayed=%d/%d)',
119
+ name, registrations.tools.length, replayed, activeServers.size)
120
+ for (const msg of replayErrors) {
121
+ log.debug('MCP substrate: live-replay of tool %s failed on a session server: %s', name, msg)
122
+ }
106
123
  }
107
124
  return substrate
108
125
  },
109
126
  registerResource(...args) {
110
127
  registrations.resources.push(args)
128
+ const uri = typeof args[1] === 'string' ? args[1] : args[0]
129
+ let replayed = 0
130
+ const replayErrors = []
111
131
  for (const s of activeServers) {
112
- try { s.registerResource(...args) } catch { /* dup */ }
132
+ try { s.registerResource(...args); replayed++ }
133
+ catch (err) { replayErrors.push(err.message) }
134
+ }
135
+ const log = runtime.engine?.logger
136
+ if (log) {
137
+ log.debug('MCP substrate: registered resource %s (total=%d, live-replayed=%d/%d)',
138
+ uri, registrations.resources.length, replayed, activeServers.size)
139
+ for (const msg of replayErrors) {
140
+ log.debug('MCP substrate: live-replay of resource %s failed on a session server: %s', uri, msg)
141
+ }
113
142
  }
114
143
  return substrate
115
144
  },
116
145
  registerPrompt(...args) {
117
146
  registrations.prompts.push(args)
147
+ const name = args[0]
148
+ let replayed = 0
149
+ const replayErrors = []
118
150
  for (const s of activeServers) {
119
- try { s.registerPrompt(...args) } catch { /* dup */ }
151
+ try { s.registerPrompt(...args); replayed++ }
152
+ catch (err) { replayErrors.push(err.message) }
153
+ }
154
+ const log = runtime.engine?.logger
155
+ if (log) {
156
+ log.debug('MCP substrate: registered prompt %s (total=%d, live-replayed=%d/%d)',
157
+ name, registrations.prompts.length, replayed, activeServers.size)
158
+ for (const msg of replayErrors) {
159
+ log.debug('MCP substrate: live-replay of prompt %s failed on a session server: %s', name, msg)
160
+ }
120
161
  }
121
162
  return substrate
122
163
  },
@@ -136,17 +177,31 @@ export function createMcpSubstrate() {
136
177
  // allowedResources: ['mikser://lifecycle', 'mikser://logs/*']
137
178
  // Omit a filter (or pass '*') to allow everything in that
138
179
  // category — that's the backward-compat default.
139
- _createServer({ allowedTools, allowedResources, allowedPrompts } = {}) {
180
+ createServer({ allowedTools, allowedResources, allowedPrompts } = {}) {
140
181
  const server = new McpServer(
141
182
  { name: 'mikser-io', version: packageInfo.version },
142
183
  { capabilities: { tools: {}, resources: {}, logging: {} } },
143
184
  )
144
- bind(server, { allowedTools, allowedResources, allowedPrompts })
185
+ const bound = bind(server, { allowedTools, allowedResources, allowedPrompts })
186
+ runtime.engine?.logger?.debug(
187
+ 'MCP session server created (tools=%d/%d, resources=%d/%d, prompts=%d/%d)',
188
+ bound.tools, registrations.tools.length,
189
+ bound.resources, registrations.resources.length,
190
+ bound.prompts, registrations.prompts.length,
191
+ )
145
192
  return server
146
193
  },
147
- _attach(server) { activeServers.add(server) },
148
- _detach(server) { activeServers.delete(server) },
149
- _activeServerCount() { return activeServers.size },
194
+ attach(server) {
195
+ activeServers.add(server)
196
+ runtime.engine?.logger?.debug(
197
+ 'MCP session attached — active clients: %d', activeServers.size)
198
+ },
199
+ detach(server) {
200
+ activeServers.delete(server)
201
+ runtime.engine?.logger?.debug(
202
+ 'MCP session detached — active clients: %d', activeServers.size)
203
+ },
204
+ activeServerCount() { return activeServers.size },
150
205
 
151
206
  // Send a logging-message notification to every connected
152
207
  // client. The SDK's per-session level filtering applies.
@@ -309,7 +364,7 @@ export function createMcpSubstrate() {
309
364
  phase: runtime.phase ?? null,
310
365
  workingFolder: runtime.options.workingFolder,
311
366
  outputFolder: runtime.options.outputFolder,
312
- activeClients: substrate._activeServerCount(),
367
+ activeClients: substrate.activeServerCount(),
313
368
  server: serverInfo(),
314
369
  }, null, 2),
315
370
  }],
@@ -373,6 +428,10 @@ export async function mountMcpOnExpress(app, substrate, defaultPath = '/mcp') {
373
428
  const endpoints = runtime.config.mcp?.endpoints
374
429
  const base = runtime.config.mcp?.base ?? defaultPath
375
430
 
431
+ runtime.engine?.logger?.debug(
432
+ 'MCP mounting on Express (base=%s, endpoints=%d)',
433
+ base, endpoints ? Object.keys(endpoints).length : 1)
434
+
376
435
  if (endpoints && Object.keys(endpoints).length > 0) {
377
436
  for (const [name, ep] of Object.entries(endpoints)) {
378
437
  mountEndpoint(app, substrate, `${base}/${name}`, ep, name)
@@ -404,6 +463,8 @@ function mountEndpoint(app, substrate, path, ep, endpointName) {
404
463
  const presented = req.headers.authorization
405
464
  if (expectedAuth) {
406
465
  if (presented && presented !== expectedAuth) {
466
+ runtime.engine?.logger?.debug(
467
+ 'MCP auth denied at %s: invalid token (ip=%s)', path, req.ip)
407
468
  res.status(401).json({
408
469
  jsonrpc: '2.0',
409
470
  error: { code: -32001, message: 'Invalid MCP token' },
@@ -416,6 +477,8 @@ function mountEndpoint(app, substrate, path, ep, endpointName) {
416
477
  }
417
478
  if (!presented || presented !== expectedAuth) {
418
479
  if (!ep.allowRemote && !isLoopback(req.ip)) {
480
+ runtime.engine?.logger?.debug(
481
+ 'MCP auth denied at %s: non-loopback without token (ip=%s)', path, req.ip)
419
482
  res.status(403).json({
420
483
  jsonrpc: '2.0',
421
484
  error: {
@@ -436,7 +499,9 @@ function mountEndpoint(app, substrate, path, ep, endpointName) {
436
499
  }
437
500
 
438
501
  // New session — server filtered for this endpoint's surface.
439
- const server = substrate._createServer({
502
+ runtime.engine?.logger?.debug(
503
+ 'MCP new session at %s (ip=%s, method=%s)', path, req.ip, req.method)
504
+ const server = substrate.createServer({
440
505
  allowedTools: ep.tools,
441
506
  allowedResources: ep.resources,
442
507
  allowedPrompts: ep.prompts,
@@ -445,12 +510,12 @@ function mountEndpoint(app, substrate, path, ep, endpointName) {
445
510
  sessionIdGenerator: () => randomUUID(),
446
511
  onsessioninitialized: (id) => {
447
512
  transports.set(id, transport)
448
- substrate._attach(server)
513
+ substrate.attach(server)
449
514
  },
450
515
  })
451
516
  transport.onclose = () => {
452
517
  if (transport.sessionId) transports.delete(transport.sessionId)
453
- substrate._detach(server)
518
+ substrate.detach(server)
454
519
  }
455
520
  await server.connect(transport)
456
521
  return transport.handleRequest(req, res, body)
@@ -519,6 +584,12 @@ export function wireLoggerToMcp(logger, substrate) {
519
584
  } catch { /* swallow — keep stdout pipeline working */ }
520
585
  }
521
586
  }
587
+ // No wire-up confirmation here — the wrapper has a load-bearing
588
+ // 1-broadcast-per-log-call invariant and a "skip missing methods"
589
+ // invariant. Operators still see the substrate's debug coverage
590
+ // (registrations, session lifecycle, auth deny) once the engine
591
+ // logger flows through. The "MCP mounted: …" info line at boot
592
+ // is the user-visible "ready" signal.
522
593
  return logger
523
594
  }
524
595
 
@@ -554,16 +625,3 @@ function format(template, args) {
554
625
  function pinoLevelNumber(name) {
555
626
  return { trace: 10, debug: 20, info: 30, warn: 40, error: 50, fatal: 60 }[name] ?? 30
556
627
  }
557
-
558
- // (whenMcpActive was removed in v7.4.0 — plugins now use the same
559
- // inline pattern as Express route registration: gate on the runtime
560
- // option inside whichever lifecycle hook makes sense.
561
- //
562
- // onLoaded(async () => {
563
- // if (runtime.options.mcp) {
564
- // runtime.options.mcp.simpleTool(...)
565
- // }
566
- // })
567
- //
568
- // Reveals timing, matches the Express pattern, doesn't lock the
569
- // registration into onLoaded.)
@@ -403,7 +403,12 @@ export default ({
403
403
 
404
404
  if (data?.pages) {
405
405
  if (!_.endsWith(entity.name, entity.format)) {
406
- for (let page = 0; page < data.pages - 1; page++) {
406
+ // Loop bound is `< data.pages` (not `data.pages - 1`).
407
+ // With 4 pages and the old bound, iteration only ran
408
+ // page=0,1,2 and the 4th page was silently dropped —
409
+ // the sitemap claimed "Page X of 4" but the destination
410
+ // for page 4 was never produced.
411
+ for (let page = 0; page < data.pages; page++) {
407
412
  const pageEntity = _.cloneDeep(entity)
408
413
  pageEntity.pages = data.pages
409
414
  if (page) {
@@ -24,11 +24,85 @@
24
24
  // wrapper over this surface.
25
25
 
26
26
  import path from 'node:path'
27
- import { randomUUID } from 'node:crypto'
27
+ import { randomUUID, createHmac } from 'node:crypto'
28
28
  import { z } from 'zod'
29
29
  import { useRenderer } from '../api.js'
30
30
  import { mimeForEntity, matchEntity } from '../utils.js'
31
31
 
32
+ // Forward an MCP-UI action to an external handler URL. Returns the
33
+ // handler's JSON response, which becomes the tool result. Throws on
34
+ // network error, non-2xx status, timeout, or invalid response shape —
35
+ // callers fall back to pure-relay on throw.
36
+ //
37
+ // HMAC signing: when handler.secret is set, we sign the request body
38
+ // with sha256(secret) and pass it in X-Mikser-Signature. Receivers
39
+ // MUST verify before processing. When secret is unset, no signature
40
+ // is sent (acceptable for dev; not recommended in production — see
41
+ // ADR-0008).
42
+ //
43
+ // Extracted as a standalone function so it can be unit-tested with a
44
+ // mock URL and so mikser_ui_action's main path stays readable.
45
+ export async function forwardToHandler(handler, body) {
46
+ const { url, secret, timeout = 5000 } = handler
47
+ if (!url) throw new Error('forwardToHandler: handler.url is required')
48
+
49
+ const json = JSON.stringify({
50
+ ...body,
51
+ // Timestamp is set inside the forward, not by the caller, so
52
+ // a stale callback that came in via a slow network still has
53
+ // a fresh timestamp on the outgoing forward. Receivers that
54
+ // care can put their own.
55
+ timestamp: new Date().toISOString(),
56
+ })
57
+
58
+ const headers = {
59
+ 'content-type': 'application/json',
60
+ 'x-mikser-layout-id': body.layoutId ?? '',
61
+ 'x-mikser-mode': body.mode ?? '',
62
+ 'x-mikser-request-id': randomUUID(),
63
+ }
64
+ if (secret) {
65
+ const sig = createHmac('sha256', secret).update(json).digest('hex')
66
+ headers['x-mikser-signature'] = `sha256=${sig}`
67
+ }
68
+
69
+ const ac = new AbortController()
70
+ const timer = setTimeout(() => ac.abort(), timeout)
71
+
72
+ let res
73
+ try {
74
+ res = await fetch(url, {
75
+ method: 'POST',
76
+ headers,
77
+ body: json,
78
+ signal: ac.signal,
79
+ })
80
+ } catch (err) {
81
+ clearTimeout(timer)
82
+ if (err.name === 'AbortError') {
83
+ throw new Error(`Handler timeout (${timeout}ms) — ${url}`)
84
+ }
85
+ throw new Error(`Handler unreachable: ${err.message} — ${url}`)
86
+ }
87
+ clearTimeout(timer)
88
+
89
+ if (!res.ok) {
90
+ const text = await res.text().catch(() => '')
91
+ throw new Error(`Handler ${res.status} ${res.statusText} — ${text.slice(0, 200)}`)
92
+ }
93
+
94
+ const ct = res.headers.get('content-type') ?? ''
95
+ if (ct.includes('application/json')) {
96
+ return await res.json()
97
+ }
98
+ // Non-JSON response — wrap as a structured result so the agent
99
+ // sees something meaningful. The handler is technically off-spec
100
+ // here (ADR-0008 says return JSON), but we don't punish callers
101
+ // for a casual `res.send('ok')` from the handler side.
102
+ const text = await res.text()
103
+ return { ok: true, handlerResponse: text }
104
+ }
105
+
32
106
  export default ({
33
107
  runtime,
34
108
  onLoaded,
@@ -322,7 +396,18 @@ export default ({
322
396
 
323
397
  // Force the chosen layout — bypass autoLayouts /
324
398
  // layouts.match resolution that onProcessed would do.
325
- const renderEntity = { ...entity, layout: matched }
399
+ // Both `entity.layout` AND `entity.meta.layout` need
400
+ // to be set: the layouts plugin's onProcessed
401
+ // re-resolves entity.layout from entity.meta.layout
402
+ // on every cycle, and previewRender goes through
403
+ // the full lifecycle. Without overriding meta.layout
404
+ // the agent's `mcp-ui/post-approval` choice gets
405
+ // silently replaced by the production `post` layout.
406
+ const renderEntity = {
407
+ ...entity,
408
+ layout: matched,
409
+ meta: { ...(entity.meta || {}), layout: matched.name },
410
+ }
326
411
  const { output } = await previewRender(renderEntity, {
327
412
  save: false,
328
413
  catalog: false,
@@ -350,9 +435,12 @@ export default ({
350
435
  // surface it as text/preformatted.
351
436
  { type: 'text', text: html, mimeType: 'text/html' },
352
437
  ],
353
- // Side-channel metadata so hosts that wire up
354
- // postMessage back-channels know which actions
355
- // the UI emits and which sandbox flags to apply.
438
+ // Side-channel metadata so hosts know what to
439
+ // do with the rendered UI. Per the MCP Apps
440
+ // spec (2026-01-26), the iframe speaks JSON-RPC
441
+ // back to the host via postMessage; clicks
442
+ // resolve as tools/call against `actionTool`
443
+ // (registered with visibility=['app'] below).
356
444
  _meta: {
357
445
  mcpUi: {
358
446
  layoutId: matched.id,
@@ -360,6 +448,14 @@ export default ({
360
448
  description: mcpUiMeta.description ?? null,
361
449
  actions: mcpUiMeta.actions ?? [],
362
450
  sandbox: mcpUiMeta.sandbox ?? ['allow-scripts'],
451
+ // The app-callable tool the iframe
452
+ // invokes for each user click. Hosts
453
+ // that bridge iframe tools/call route
454
+ // this through the existing MCP
455
+ // transport — the click becomes a
456
+ // separate tool turn in the agent's
457
+ // conversation.
458
+ actionTool: 'mikser_ui_action',
363
459
  },
364
460
  },
365
461
  }
@@ -370,8 +466,112 @@ export default ({
370
466
  },
371
467
  )
372
468
 
469
+ // mikser_ui_action — app-callable tool that delivers a user
470
+ // click from inside an mcpUi iframe back to the agent (or to
471
+ // an external webhook handler).
472
+ //
473
+ // Visibility model (MCP Apps spec 2026-01-26):
474
+ // _meta.ui.visibility = ['app']
475
+ //
476
+ // means this tool is invisible to the agent — it's never
477
+ // listed in the model's tool surface — but iframes opened by
478
+ // mikser_preview_ui can invoke it over the host's AppBridge
479
+ // (tools/call over postMessage). The host bridges the call
480
+ // into a real MCP tools/call, and the agent sees the result
481
+ // as a separate tool turn in the conversation.
482
+ //
483
+ // Auth boundary: the action MUST appear in the layout's
484
+ // declared mcpUi.actions list. Unknown actions return an
485
+ // error result. There is no callId / random URL / signature
486
+ // on this channel — the iframe's only path to mikser is
487
+ // through the host's authenticated MCP transport, which is
488
+ // already trusted.
489
+ //
490
+ // Resolution: pure relay (return { entityId, action, payload }
491
+ // as the tool result) unless the layout declared
492
+ // mcpUi.handler.url, in which case mikser POSTs the action
493
+ // to that URL (HMAC-signed if handler.secret is set) and
494
+ // returns the handler's response. See forwardToHandler above.
495
+ mcp.registerTool(
496
+ 'mikser_ui_action',
497
+ {
498
+ 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.',
499
+ inputSchema: {
500
+ entityId: z.string().describe('Entity the action targets (the same id the iframe was rendered for).'),
501
+ layoutId: z.string().describe('Layout that rendered the iframe — used to look up the allowed-actions list and optional handler config.'),
502
+ action: z.string().describe('Action name. Must appear in the layout\'s mcpUi.actions list.'),
503
+ payload: z.record(z.any()).optional().describe('Structured payload — form fields, selected status, etc. Schema is layout-defined; mikser passes it through.'),
504
+ },
505
+ _meta: {
506
+ ui: {
507
+ // App-callable only — invisible to the model
508
+ // per MCP Apps spec. Hosts MUST NOT include
509
+ // it in the agent's tools/list response and
510
+ // MUST allow iframes opened via mikser_preview_ui
511
+ // to invoke it.
512
+ visibility: ['app'],
513
+ },
514
+ },
515
+ },
516
+ async ({ entityId, layoutId, action, payload = {} }) => {
517
+ const logger = useLogger()
518
+ const fail = (msg) => ({
519
+ isError: true,
520
+ content: [{ type: 'text', text: msg }],
521
+ })
522
+ const ok = (data) => ({
523
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
524
+ })
525
+
526
+ try {
527
+ const layout = await findEntity({ id: layoutId })
528
+ if (!layout || layout.collection !== 'layouts') {
529
+ return fail(`Layout not found or not a layout: ${layoutId}`)
530
+ }
531
+ const mcpUiMeta = layout.meta?.mcpUi
532
+ if (!mcpUiMeta) {
533
+ return fail(`Layout ${layoutId} does not declare mcpUi frontmatter — not eligible as an action source.`)
534
+ }
535
+ const allowed = mcpUiMeta.actions ?? []
536
+ if (!allowed.includes(action)) {
537
+ return fail(`Action "${action}" not in allowed list for ${layoutId}. Declared: [${allowed.join(', ')}]`)
538
+ }
539
+
540
+ const result = { entityId, action, payload }
541
+
542
+ if (mcpUiMeta.handler?.url) {
543
+ try {
544
+ const handlerResult = await forwardToHandler(mcpUiMeta.handler, {
545
+ ...result,
546
+ layoutId,
547
+ mode: mcpUiMeta.mode ?? 'preview',
548
+ })
549
+ logger.debug('MCP mikser_ui_action forwarded %s/%s → %s OK',
550
+ entityId, action, mcpUiMeta.handler.url)
551
+ return ok(handlerResult)
552
+ } catch (err) {
553
+ // Fail-safe: never lose the user's click.
554
+ // Surface handler failure to the agent as
555
+ // structured metadata alongside the relay
556
+ // payload so the agent can decide whether
557
+ // to retry or proceed without backend ack.
558
+ logger.warn('MCP mikser_ui_action handler failed (%s) — falling back to pure relay: %s',
559
+ mcpUiMeta.handler.url, err.message)
560
+ return ok({ ...result, handlerError: err.message })
561
+ }
562
+ }
563
+
564
+ logger.debug('MCP mikser_ui_action %s/%s (pure relay)', entityId, action)
565
+ return ok(result)
566
+ } catch (err) {
567
+ logger.error('MCP mikser_ui_action error: %s', err.message)
568
+ return fail(err.message)
569
+ }
570
+ },
571
+ )
572
+
373
573
  const logger = useLogger()
374
- logger.debug('MCP tool registered: mikser_preview_ui + mikser://mcp-ui/modes resource (preview plugin)')
574
+ logger.debug('MCP tools registered: mikser_preview_ui + mikser_ui_action + mikser://mcp-ui/modes resource (preview plugin)')
375
575
  })
376
576
 
377
577
  return { name: 'preview' }
@@ -13,4 +13,33 @@ export function load({ runtime }) {
13
13
  runtime.glob = (pattern, options = {}) => {
14
14
  return globby.sync(pattern, options)
15
15
  }
16
- }
16
+
17
+ // Stringify an arbitrary value as a JSON literal. Use with the
18
+ // triple-stash form ({{{json …}}}) when embedding inside a
19
+ // <script> block or HTML attribute — Handlebars's HTML-escape
20
+ // would otherwise turn quotes into &quot; and break the literal.
21
+ //
22
+ // <script>const id = {{{json document.id}}};</script>
23
+ //
24
+ // No SafeString wrap on purpose — that would silently make
25
+ // double-stash also output raw, which is the foot-gun when the
26
+ // template author thought they were getting escaping.
27
+ runtime.json = (value) => JSON.stringify(value)
28
+
29
+ // Build an array from positional args. Lets a template construct
30
+ // an empty array ({{array}}) or a literal list ({{array 1 2 3}})
31
+ // without a custom helper. Handy as the fallback value in
32
+ // expressions like (default document.meta.tags (array)).
33
+ //
34
+ // Handlebars passes a trailing `options` object to every helper
35
+ // call; we strip it so the array doesn't end up with a stray
36
+ // hash/data/fn object as its last element.
37
+ runtime.array = (...args) => {
38
+ if (args.length && typeof args[args.length - 1] === 'object'
39
+ && args[args.length - 1] !== null
40
+ && 'hash' in args[args.length - 1]) {
41
+ args = args.slice(0, -1)
42
+ }
43
+ return args
44
+ }
45
+ }