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/LICENSE +21 -0
- package/README.md +46 -0
- package/index.js +1065 -0
- package/package.json +47 -0
- package/preview.js +532 -0
- package/public/preview-ui-shell.html +147 -0
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
|
+
}
|