mikser-io 7.12.1 → 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 +1 -1
- package/package.json +1 -1
- package/src/mcp.js +83 -25
- package/src/plugins/preview.js +194 -5
package/README.md
CHANGED
|
@@ -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 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
|
|
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
|
|
package/package.json
CHANGED
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)
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
513
|
+
substrate.attach(server)
|
|
449
514
|
},
|
|
450
515
|
})
|
|
451
516
|
transport.onclose = () => {
|
|
452
517
|
if (transport.sessionId) transports.delete(transport.sessionId)
|
|
453
|
-
substrate.
|
|
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.)
|
package/src/plugins/preview.js
CHANGED
|
@@ -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,
|
|
@@ -361,9 +435,12 @@ export default ({
|
|
|
361
435
|
// surface it as text/preformatted.
|
|
362
436
|
{ type: 'text', text: html, mimeType: 'text/html' },
|
|
363
437
|
],
|
|
364
|
-
// Side-channel metadata so hosts
|
|
365
|
-
//
|
|
366
|
-
//
|
|
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).
|
|
367
444
|
_meta: {
|
|
368
445
|
mcpUi: {
|
|
369
446
|
layoutId: matched.id,
|
|
@@ -371,6 +448,14 @@ export default ({
|
|
|
371
448
|
description: mcpUiMeta.description ?? null,
|
|
372
449
|
actions: mcpUiMeta.actions ?? [],
|
|
373
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',
|
|
374
459
|
},
|
|
375
460
|
},
|
|
376
461
|
}
|
|
@@ -381,8 +466,112 @@ export default ({
|
|
|
381
466
|
},
|
|
382
467
|
)
|
|
383
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
|
+
|
|
384
573
|
const logger = useLogger()
|
|
385
|
-
logger.debug('MCP
|
|
574
|
+
logger.debug('MCP tools registered: mikser_preview_ui + mikser_ui_action + mikser://mcp-ui/modes resource (preview plugin)')
|
|
386
575
|
})
|
|
387
576
|
|
|
388
577
|
return { name: 'preview' }
|