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/index.js ADDED
@@ -0,0 +1,1065 @@
1
+ // MCP substrate for mikser-io. The mcp plugin exposes
2
+ // `runtime.options.mcp` (a substrate object) at factory time so other
3
+ // plugins can register tools / resources / prompts against it at their
4
+ // onLoaded hook with the same shape as the SDK's McpServer.
5
+ //
6
+ // Operating model:
7
+ // - One shared mikser engine, many observing clients.
8
+ // - Per-session McpServer + per-session transport (required by the
9
+ // SDK: a single Server instance can't be initialized twice).
10
+ // The substrate maintains a registry of tool/resource/prompt
11
+ // declarations and replays them onto every new session's server,
12
+ // so plugins register ONCE.
13
+ // - Broadcast logging: every connected client gets every log line.
14
+ // Client-side filtering is honored via the SDK's per-session
15
+ // `logging/setLevel` state.
16
+ //
17
+ // MCP ships as a plugin (not in core) so its release cadence can move
18
+ // at the pace of the MCP spec / SDKs / host clients without dragging
19
+ // engine releases. See mikser-io's ADR-0006 for the rule (test #5,
20
+ // release-cadence) that put it here.
21
+ import { randomUUID } from 'node:crypto'
22
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
23
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
24
+ import { minimatch } from 'minimatch'
25
+ import { z } from 'zod'
26
+ import {
27
+ runtime,
28
+ isLoopback,
29
+ refExists,
30
+ mimeForEntity,
31
+ readEntityContent,
32
+ useRenderer,
33
+ useCollection,
34
+ queryEntities,
35
+ readEntity,
36
+ } from 'mikser-io'
37
+ import packageInfo from 'mikser-io/package.json' with { type: 'json' }
38
+ import previewPlugin from './preview.js'
39
+
40
+ // Pattern matcher for endpoint tools/resources filters. Accepts
41
+ // '*', an array of patterns, or undefined (= allow all). Glob
42
+ // patterns like 'mikser_refs_*' or 'mikser_*_entity' work through
43
+ // minimatch — same library mikser uses for content matching, so
44
+ // the syntax is consistent across the codebase.
45
+ function matchesAny(name, patterns) {
46
+ if (patterns == null) return true
47
+ if (patterns === '*') return true
48
+ if (!Array.isArray(patterns)) return false
49
+ for (const p of patterns) {
50
+ if (p === '*') return true
51
+ if (minimatch(name, p)) return true
52
+ }
53
+ return false
54
+ }
55
+
56
+ let pinoLevelToMcp = (pinoLevel) => {
57
+ if (pinoLevel >= 50) return 'error'
58
+ if (pinoLevel >= 40) return 'warning'
59
+ if (pinoLevel >= 30) return 'info'
60
+ if (pinoLevel >= 20) return 'debug'
61
+ return 'debug'
62
+ }
63
+
64
+ /**
65
+ * Build the MCP substrate. The returned object exposes the same
66
+ * registerTool / registerResource / registerPrompt shape as
67
+ * @modelcontextprotocol/sdk's McpServer, so plugins use it as a
68
+ * drop-in. Internally it records each registration and replays them
69
+ * on every new per-session Server.
70
+ */
71
+ export function createMcpSubstrate() {
72
+ // Recorded registrations, replayed on each new session server so
73
+ // late-arriving clients see the same tool surface as early ones.
74
+ const registrations = { tools: [], resources: [], prompts: [] }
75
+ // Per-session McpServer instances currently connected to a
76
+ // transport. Used to fan log notifications and list-changed
77
+ // events out to every active client.
78
+ const activeServers = new Set()
79
+ // Rolling buffer of recent log lines, surfaced via the
80
+ // mikser://logs/recent resource. Sized to cover one or two
81
+ // typical lifecycle cycles — large enough to debug a render
82
+ // failure that scrolled off the live stream, small enough that
83
+ // keeping it in memory isn't a concern. Tail-truncated; oldest
84
+ // line drops when the cap is exceeded.
85
+ const LOG_BUFFER_CAP = 500
86
+ const logBuffer = []
87
+
88
+ function bind(server, filters = {}) {
89
+ const { allowedTools, allowedResources, allowedPrompts } = filters
90
+ const bound = { tools: 0, resources: 0, prompts: 0 }
91
+ for (const args of registrations.tools) {
92
+ if (!matchesAny(args[0], allowedTools)) continue
93
+ server.registerTool(...args)
94
+ bound.tools++
95
+ }
96
+ for (const args of registrations.resources) {
97
+ // Resource registrations are (name, uri, config, handler).
98
+ // Filter on the URI since that's the addressable identifier
99
+ // (`mikser://lifecycle` reads more naturally as the filter
100
+ // target than the short `mikser-lifecycle` name).
101
+ const uri = typeof args[1] === 'string' ? args[1] : args[0]
102
+ if (!matchesAny(uri, allowedResources)) continue
103
+ server.registerResource(...args)
104
+ bound.resources++
105
+ }
106
+ for (const args of registrations.prompts) {
107
+ if (!matchesAny(args[0], allowedPrompts)) continue
108
+ server.registerPrompt(...args)
109
+ bound.prompts++
110
+ }
111
+ return bound
112
+ }
113
+
114
+ const substrate = {
115
+ // The SDK's register* methods take different argument counts
116
+ // (3 for tools, 4 for resources, 3 for prompts). We spread the
117
+ // recorded args verbatim — substrate doesn't peek at the
118
+ // shape, it just records and replays.
119
+ registerTool(...args) {
120
+ registrations.tools.push(args)
121
+ const name = args[0]
122
+ let replayed = 0
123
+ const replayErrors = []
124
+ for (const s of activeServers) {
125
+ try { s.registerTool(...args); replayed++ }
126
+ catch (err) { replayErrors.push(err.message) }
127
+ }
128
+ const log = runtime.engine?.logger
129
+ if (log) {
130
+ log.debug('MCP substrate: registered tool %s (total=%d, live-replayed=%d/%d)',
131
+ name, registrations.tools.length, replayed, activeServers.size)
132
+ for (const msg of replayErrors) {
133
+ log.debug('MCP substrate: live-replay of tool %s failed on a session server: %s', name, msg)
134
+ }
135
+ }
136
+ return substrate
137
+ },
138
+ registerResource(...args) {
139
+ registrations.resources.push(args)
140
+ const uri = typeof args[1] === 'string' ? args[1] : args[0]
141
+ let replayed = 0
142
+ const replayErrors = []
143
+ for (const s of activeServers) {
144
+ try { s.registerResource(...args); replayed++ }
145
+ catch (err) { replayErrors.push(err.message) }
146
+ }
147
+ const log = runtime.engine?.logger
148
+ if (log) {
149
+ log.debug('MCP substrate: registered resource %s (total=%d, live-replayed=%d/%d)',
150
+ uri, registrations.resources.length, replayed, activeServers.size)
151
+ for (const msg of replayErrors) {
152
+ log.debug('MCP substrate: live-replay of resource %s failed on a session server: %s', uri, msg)
153
+ }
154
+ }
155
+ return substrate
156
+ },
157
+ registerPrompt(...args) {
158
+ registrations.prompts.push(args)
159
+ const name = args[0]
160
+ let replayed = 0
161
+ const replayErrors = []
162
+ for (const s of activeServers) {
163
+ try { s.registerPrompt(...args); replayed++ }
164
+ catch (err) { replayErrors.push(err.message) }
165
+ }
166
+ const log = runtime.engine?.logger
167
+ if (log) {
168
+ log.debug('MCP substrate: registered prompt %s (total=%d, live-replayed=%d/%d)',
169
+ name, registrations.prompts.length, replayed, activeServers.size)
170
+ for (const msg of replayErrors) {
171
+ log.debug('MCP substrate: live-replay of prompt %s failed on a session server: %s', name, msg)
172
+ }
173
+ }
174
+ return substrate
175
+ },
176
+
177
+ // Convenience helper for the common case: 3-arg tool with
178
+ // description and input schema.
179
+ simpleTool(name, description, inputSchema, handler) {
180
+ return substrate.registerTool(name, { description, inputSchema }, handler)
181
+ },
182
+
183
+ // Create a fresh McpServer pre-loaded with every recorded
184
+ // registration that passes the endpoint's filters. Called by
185
+ // the transport mount per new session.
186
+ //
187
+ // Filters take patterns (exact name or glob via minimatch):
188
+ // allowedTools: ['mikser_refs_*', 'mikser_ping']
189
+ // allowedResources: ['mikser://lifecycle', 'mikser://logs/*']
190
+ // Omit a filter (or pass '*') to allow everything in that
191
+ // category — that's the backward-compat default.
192
+ createServer({ allowedTools, allowedResources, allowedPrompts } = {}) {
193
+ const server = new McpServer(
194
+ { name: 'mikser-io', version: packageInfo.version },
195
+ { capabilities: { tools: {}, resources: {}, logging: {} } },
196
+ )
197
+ const bound = bind(server, { allowedTools, allowedResources, allowedPrompts })
198
+ runtime.engine?.logger?.debug(
199
+ 'MCP session server created (tools=%d/%d, resources=%d/%d, prompts=%d/%d)',
200
+ bound.tools, registrations.tools.length,
201
+ bound.resources, registrations.resources.length,
202
+ bound.prompts, registrations.prompts.length,
203
+ )
204
+ return server
205
+ },
206
+ attach(server) {
207
+ activeServers.add(server)
208
+ runtime.engine?.logger?.debug(
209
+ 'MCP session attached — active clients: %d', activeServers.size)
210
+ },
211
+ detach(server) {
212
+ activeServers.delete(server)
213
+ runtime.engine?.logger?.debug(
214
+ 'MCP session detached — active clients: %d', activeServers.size)
215
+ },
216
+ activeServerCount() { return activeServers.size },
217
+
218
+ // Send a logging-message notification to every connected
219
+ // client. The SDK's per-session level filtering applies.
220
+ broadcastLog(params) {
221
+ for (const s of activeServers) {
222
+ try {
223
+ // sendLoggingMessage is async; fire-and-forget so
224
+ // one slow client can't stall the rest. Errors
225
+ // are swallowed — log loss on a side channel is
226
+ // less important than not killing engine logs.
227
+ s.sendLoggingMessage(params).catch(() => {})
228
+ } catch { /* swallow */ }
229
+ }
230
+ },
231
+
232
+ // Called by wireLoggerToMcp on every log call. Records a
233
+ // monotonic seq number so clients can poll "give me lines
234
+ // since seq=N" against mikser://logs/recent.
235
+ recordLogLine(line) {
236
+ logBuffer.push({
237
+ seq: (logBuffer.length === 0 ? 1 : logBuffer[logBuffer.length - 1].seq + 1),
238
+ t: runtime.engine?.now ? runtime.engine.now() : null,
239
+ ...line,
240
+ })
241
+ // Tail-truncate so memory stays bounded.
242
+ if (logBuffer.length > LOG_BUFFER_CAP) {
243
+ logBuffer.splice(0, logBuffer.length - LOG_BUFFER_CAP)
244
+ }
245
+ },
246
+ recentLogLines(limit = LOG_BUFFER_CAP) {
247
+ const n = Math.min(LOG_BUFFER_CAP, Math.max(1, limit))
248
+ return logBuffer.slice(-n)
249
+ },
250
+ }
251
+
252
+ // Built-in introspection resources. Read-only views into the
253
+ // running engine — let an AI ask "what's the current phase?",
254
+ // "what config is loaded?", "what just happened?" without
255
+ // needing a custom tool.
256
+ //
257
+ // The SDK's registerResource signature is:
258
+ // registerResource(name, uriOrTemplate, metadata, handler)
259
+ // Static resources pass a URI string; the handler returns
260
+ // { contents: [{ uri, mimeType, text }] }.
261
+
262
+ substrate.registerResource(
263
+ 'mikser-lifecycle',
264
+ 'mikser://lifecycle',
265
+ {
266
+ title: 'Current lifecycle phase',
267
+ description: 'The phase the engine is currently executing. Null when between phases.',
268
+ mimeType: 'application/json',
269
+ },
270
+ async (uri) => ({
271
+ contents: [{
272
+ uri: uri.href,
273
+ mimeType: 'application/json',
274
+ text: JSON.stringify({
275
+ phase: runtime.phase ?? null,
276
+ started: runtime.started === true,
277
+ stamp: runtime.stamp,
278
+ processTime: runtime.processTime ?? null,
279
+ }, null, 2),
280
+ }],
281
+ }),
282
+ )
283
+
284
+ substrate.registerResource(
285
+ 'mikser-runtime',
286
+ 'mikser://runtime',
287
+ {
288
+ title: 'Engine runtime options',
289
+ description: 'Resolved runtime.options — working folder, output folder, server port, plugin list, etc. Excludes the live engine handles (logger, queue, workers).',
290
+ mimeType: 'application/json',
291
+ },
292
+ async (uri) => ({
293
+ contents: [{
294
+ uri: uri.href,
295
+ mimeType: 'application/json',
296
+ text: JSON.stringify({
297
+ options: runtime.options,
298
+ started: runtime.started === true,
299
+ phase: runtime.phase ?? null,
300
+ }, null, 2),
301
+ }],
302
+ }),
303
+ )
304
+
305
+ substrate.registerResource(
306
+ 'mikser-config',
307
+ 'mikser://config',
308
+ {
309
+ title: 'Effective mikser config',
310
+ description: 'The merged config object as plugins see it (runtime.config). Includes per-plugin keys.',
311
+ mimeType: 'application/json',
312
+ },
313
+ async (uri) => ({
314
+ contents: [{
315
+ uri: uri.href,
316
+ mimeType: 'application/json',
317
+ text: JSON.stringify(runtime.config, null, 2),
318
+ }],
319
+ }),
320
+ )
321
+
322
+ substrate.registerResource(
323
+ 'mikser-logs-recent',
324
+ 'mikser://logs/recent',
325
+ {
326
+ title: 'Recent engine log lines',
327
+ description: `Rolling buffer of the most recent log lines (up to ${LOG_BUFFER_CAP}). Useful for debugging a render or postprocess failure that scrolled past the live notifications stream.`,
328
+ mimeType: 'application/json',
329
+ },
330
+ async (uri) => ({
331
+ contents: [{
332
+ uri: uri.href,
333
+ mimeType: 'application/json',
334
+ text: JSON.stringify({ lines: substrate.recentLogLines() }, null, 2),
335
+ }],
336
+ }),
337
+ )
338
+
339
+ // mikser://server — single-shot answer to "where do I put output
340
+ // so the user can see it?" Combines server state (running? on what
341
+ // URL?) and the path conventions agents should write to for
342
+ // preview-style outputs.
343
+ substrate.registerResource(
344
+ 'mikser-server',
345
+ 'mikser://server',
346
+ {
347
+ title: 'HTTP server location and preview conventions',
348
+ description: 'Where the running engine is reachable (URL, MCP path, preview path prefix) and what folder it serves. The single resource an agent needs to answer "where can the user see this output?"',
349
+ mimeType: 'application/json',
350
+ },
351
+ async (uri) => ({
352
+ contents: [{
353
+ uri: uri.href,
354
+ mimeType: 'application/json',
355
+ text: JSON.stringify(serverInfo(), null, 2),
356
+ }],
357
+ }),
358
+ )
359
+
360
+ // Built-in liveness/identity tool. Also ensures tools/list works
361
+ // before any plugin has registered (McpServer only advertises
362
+ // tools/list capability after at least one registration).
363
+ substrate.registerTool(
364
+ 'mikser_ping',
365
+ {
366
+ description: 'Return mikser engine identity, current lifecycle phase, and (if --server is on) where the HTTP server is reachable. Use to confirm the connection is live before issuing other tool calls and to learn the base URL for preview outputs.',
367
+ inputSchema: {},
368
+ },
369
+ async () => ({
370
+ content: [{
371
+ type: 'text',
372
+ text: JSON.stringify({
373
+ name: 'mikser-io',
374
+ version: packageInfo.version,
375
+ started: runtime.started === true,
376
+ phase: runtime.phase ?? null,
377
+ workingFolder: runtime.options.workingFolder,
378
+ outputFolder: runtime.options.outputFolder,
379
+ activeClients: substrate.activeServerCount(),
380
+ server: serverInfo(),
381
+ }, null, 2),
382
+ }],
383
+ }),
384
+ )
385
+
386
+ return substrate
387
+ }
388
+
389
+ // Derive a stable snapshot of "where outputs are visible to the user."
390
+ // Three cases:
391
+ // 1. --server is on → engine owns Express, knows port → full URL
392
+ // 2. external app → caller supplied runtime.options.app; URL not
393
+ // visible to engine (port unknown), but the
394
+ // outputFolder and path conventions are still
395
+ // useful for preview-writing tools
396
+ // 3. no server → only the static folder layout applies; an
397
+ // agent should not try to advertise a URL
398
+ //
399
+ // Kept as a function rather than a const so each call re-reads
400
+ // runtime.options — covers the case where --server flips on after
401
+ // the substrate was created (rare but possible programmatically).
402
+ function serverInfo() {
403
+ const opts = runtime.options
404
+ const hasInternalServer = opts.server != null && opts.port != null
405
+ const hasExternalApp = opts.app && !hasInternalServer
406
+
407
+ const base = hasInternalServer
408
+ ? `http://localhost:${opts.port}`
409
+ : null
410
+
411
+ return {
412
+ running: hasInternalServer ? 'internal' : (hasExternalApp ? 'external' : 'none'),
413
+ port: opts.port ?? null,
414
+ url: base,
415
+ serves: opts.outputFolder ?? null,
416
+ mcpPath: opts.mcpPath ?? null,
417
+ mcpUrl: base && opts.mcpPath ? `${base}${opts.mcpPath}` : null,
418
+ // Preview URLs are returned directly by mikser_preview_render
419
+ // (preview plugin), so we don't advertise a path convention here —
420
+ // doing so would be a lie when the preview plugin isn't loaded.
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Mount the MCP substrate on an Express app. Two modes:
426
+ *
427
+ * 1. Single endpoint (backward compat): no `runtime.config.mcp.endpoints`
428
+ * → mounts one open endpoint at `defaultPath` (default `/mcp`) with
429
+ * all tools, all resources, no token. Matches the v7.0-7.6 shape.
430
+ *
431
+ * 2. Multiple endpoints: with `runtime.config.mcp.endpoints` set →
432
+ * each endpoint mounts at `<mcp.base>/<name>` (default base `/mcp`)
433
+ * with its own filters (`tools`, `resources`) and optional
434
+ * `token` for Bearer auth.
435
+ *
436
+ * Each endpoint is its own session map — sessions don't cross endpoints.
437
+ * Same noun and shape as the api plugin's `endpoints` config.
438
+ */
439
+ export async function mountMcpOnExpress(app, substrate, defaultPath = '/mcp') {
440
+ const endpoints = runtime.config.mcp?.endpoints
441
+ const base = runtime.config.mcp?.base ?? defaultPath
442
+
443
+ runtime.engine?.logger?.debug(
444
+ 'MCP mounting on Express (base=%s, endpoints=%d)',
445
+ base, endpoints ? Object.keys(endpoints).length : 1)
446
+
447
+ if (endpoints && Object.keys(endpoints).length > 0) {
448
+ for (const [name, ep] of Object.entries(endpoints)) {
449
+ mountEndpoint(app, substrate, `${base}/${name}`, ep, name)
450
+ }
451
+ } else {
452
+ // Backward-compat single endpoint. With no `mcp.endpoints`
453
+ // configured, mount one open + loopback-only endpoint — same
454
+ // safe default as a per-endpoint config with no token. The
455
+ // boot log line itself (from mountEndpoint) shows the state;
456
+ // no extra warning needed because the default IS the safe one.
457
+ mountEndpoint(app, substrate, defaultPath, {}, null)
458
+ }
459
+ }
460
+
461
+ function mountEndpoint(app, substrate, path, ep, endpointName) {
462
+ const transports = new Map()
463
+ const expectedAuth = ep.token ? `Bearer ${ep.token}` : null
464
+
465
+ async function handle(req, res, body) {
466
+ // Auth rule (uniform across mikser plugins):
467
+ // - Token presented and matches → allow (from anywhere)
468
+ // - Token presented and doesn't match → 401
469
+ // - No token presented → require loopback unless allowRemote
470
+ //
471
+ // This means an endpoint with a token can still be called from
472
+ // localhost without the token — the "trusted host" model. Same
473
+ // pattern Postgres trust-auth and Redis default use. If the host
474
+ // is compromised mikser's tools are the least of your worries.
475
+ const presented = req.headers.authorization
476
+ if (expectedAuth) {
477
+ if (presented && presented !== expectedAuth) {
478
+ runtime.engine?.logger?.debug(
479
+ 'MCP auth denied at %s: invalid token (ip=%s)', path, req.ip)
480
+ res.status(401).json({
481
+ jsonrpc: '2.0',
482
+ error: { code: -32001, message: 'Invalid MCP token' },
483
+ id: null,
484
+ })
485
+ return
486
+ }
487
+ // No header presented falls through to the loopback check
488
+ // below — token-gated endpoints still accept loopback.
489
+ }
490
+ if (!presented || presented !== expectedAuth) {
491
+ if (!ep.allowRemote && !isLoopback(req.ip)) {
492
+ runtime.engine?.logger?.debug(
493
+ 'MCP auth denied at %s: non-loopback without token (ip=%s)', path, req.ip)
494
+ res.status(403).json({
495
+ jsonrpc: '2.0',
496
+ error: {
497
+ code: -32001,
498
+ message: expectedAuth
499
+ ? 'Token required from non-loopback sources'
500
+ : 'Endpoint accepts loopback connections only — configure a token or set allowRemote: true to enable remote access',
501
+ },
502
+ id: null,
503
+ })
504
+ return
505
+ }
506
+ }
507
+
508
+ const sessionId = req.headers['mcp-session-id']
509
+ if (sessionId && transports.has(sessionId)) {
510
+ return transports.get(sessionId).handleRequest(req, res, body)
511
+ }
512
+
513
+ // New session — server filtered for this endpoint's surface.
514
+ runtime.engine?.logger?.debug(
515
+ 'MCP new session at %s (ip=%s, method=%s)', path, req.ip, req.method)
516
+ const server = substrate.createServer({
517
+ allowedTools: ep.tools,
518
+ allowedResources: ep.resources,
519
+ allowedPrompts: ep.prompts,
520
+ })
521
+ const transport = new StreamableHTTPServerTransport({
522
+ sessionIdGenerator: () => randomUUID(),
523
+ onsessioninitialized: (id) => {
524
+ transports.set(id, transport)
525
+ substrate.attach(server)
526
+ },
527
+ })
528
+ transport.onclose = () => {
529
+ if (transport.sessionId) transports.delete(transport.sessionId)
530
+ substrate.detach(server)
531
+ }
532
+ await server.connect(transport)
533
+ return transport.handleRequest(req, res, body)
534
+ }
535
+
536
+ app.post(path, (req, res) => handle(req, res, req.body))
537
+ app.get(path, (req, res) => handle(req, res))
538
+ app.delete(path, (req, res) => handle(req, res))
539
+
540
+ const logger = runtime.engine?.logger
541
+ if (logger) {
542
+ const toolsLabel = ep.tools == null || ep.tools === '*'
543
+ ? '*'
544
+ : Array.isArray(ep.tools) ? ep.tools.join(',') : String(ep.tools)
545
+ // Three reachability states: token (anyone with the token from
546
+ // anywhere), loopback-only (no token + no allowRemote, default),
547
+ // or REMOTE OPEN (no token + allowRemote, deliberate exposure).
548
+ // The all-caps "REMOTE OPEN" mirrors the boot warning style so
549
+ // an operator scanning startup output sees the risk.
550
+ const authLabel = ep.token
551
+ ? 'token'
552
+ : (ep.allowRemote ? 'public, REMOTE OPEN' : 'public, loopback-only')
553
+ // Print as a full URL when we know the port so the operator can
554
+ // copy/click straight from the log. Falls back to bare path for
555
+ // external-app setups where the engine doesn't own the listener.
556
+ const location = runtime.options.port
557
+ ? `http://localhost:${runtime.options.port}${path}`
558
+ : path
559
+ if (endpointName) {
560
+ logger.info('MCP endpoint mounted: %s (tools=[%s] [%s])', location, toolsLabel, authLabel)
561
+ } else {
562
+ logger.info('MCP mounted: %s [%s]', location, authLabel)
563
+ }
564
+ }
565
+ }
566
+
567
+ /**
568
+ * Wrap a pino logger so every call also broadcasts a
569
+ * `notifications/message` to MCP clients AND appends to the
570
+ * substrate's rolling buffer (read via mikser://logs/recent).
571
+ * Wraps in place: returns the same logger reference, with
572
+ * `fatal/error/warn/info/debug/trace` replaced by versions that
573
+ * call the original AND fan out via the substrate.
574
+ *
575
+ * Wrapping (vs. swapping in a pino multistream) lets us keep the
576
+ * existing logger reference that the rest of the engine, plugins,
577
+ * and render workers already hold — no second logger to thread
578
+ * through, no race during initialization.
579
+ */
580
+ export function wireLoggerToMcp(logger, substrate) {
581
+ const levels = ['fatal', 'error', 'warn', 'info', 'debug', 'trace']
582
+ for (const level of levels) {
583
+ const original = logger[level]?.bind(logger)
584
+ if (!original) continue
585
+ logger[level] = (...args) => {
586
+ original(...args)
587
+ try {
588
+ const data = extractData(args)
589
+ const mcpLevel = pinoLevelToMcp(pinoLevelNumber(level))
590
+ substrate.recordLogLine?.({ level: mcpLevel, data })
591
+ substrate.broadcastLog({
592
+ level: mcpLevel,
593
+ logger: 'mikser',
594
+ data,
595
+ })
596
+ } catch { /* swallow — keep stdout pipeline working */ }
597
+ }
598
+ }
599
+ // No wire-up confirmation here — the wrapper has a load-bearing
600
+ // 1-broadcast-per-log-call invariant and a "skip missing methods"
601
+ // invariant. Operators still see the substrate's debug coverage
602
+ // (registrations, session lifecycle, auth deny) once the engine
603
+ // logger flows through. The "MCP mounted: …" info line at boot
604
+ // is the user-visible "ready" signal.
605
+ return logger
606
+ }
607
+
608
+ // Reduce pino-style call args to a single { msg, ...fields } payload
609
+ // for the MCP notification's `data` field. Mirrors pino's own argument
610
+ // handling: leading object = fields, trailing string = msg template,
611
+ // remaining args = printf params.
612
+ function extractData(args) {
613
+ if (args.length === 0) return { msg: '' }
614
+ const [first, ...rest] = args
615
+ if (typeof first === 'object' && first !== null) {
616
+ const template = typeof rest[0] === 'string' ? rest[0] : ''
617
+ const params = rest.slice(1)
618
+ const msg = template ? format(template, params) : (rest[0] !== undefined ? String(rest[0]) : '')
619
+ return { ...first, msg }
620
+ }
621
+ return { msg: format(String(first), rest) }
622
+ }
623
+
624
+ // %s / %d / %j formatting, matching pino's printf-style behavior.
625
+ function format(template, args) {
626
+ if (typeof template !== 'string') return String(template)
627
+ let i = 0
628
+ return template.replace(/%[sdjoO]/g, (token) => {
629
+ if (i >= args.length) return token
630
+ const a = args[i++]
631
+ if (token === '%s') return String(a)
632
+ if (token === '%d') return Number(a).toString()
633
+ return typeof a === 'object' ? JSON.stringify(a) : String(a)
634
+ })
635
+ }
636
+
637
+ function pinoLevelNumber(name) {
638
+ return { trace: 10, debug: 20, info: 30, warn: 40, error: 50, fatal: 60 }[name] ?? 30
639
+ }
640
+
641
+ // Plugin entry. Loaded by mikser when 'mcp' appears in mikser.config.js
642
+ // plugins array. Activation is by presence — no CLI flag.
643
+ //
644
+ // The factory creates the substrate SYNCHRONOUSLY so other plugins
645
+ // listed AFTER 'mcp' in the array can register tools/resources at
646
+ // their own onLoaded hook with `if (!runtime.options.mcp) return`
647
+ // gating already in place from the in-core era. The plugin MUST be
648
+ // FIRST in the user's plugins array — that's a documented invariant
649
+ // in this repo's README.
650
+ //
651
+ // Configuration via mikser.config.js:
652
+ // plugins: ['mcp', ...],
653
+ // mcp: {
654
+ // path: '/mcp', // default; mount path for the transport
655
+ // endpoints: { ... } // same shape as the previous mcp.endpoints
656
+ // }
657
+ //
658
+ // If runtime.config.mcp is absent, the plugin runs as a no-op (loads
659
+ // but creates no substrate, mounts no transport). Lets users include
660
+ // 'mcp' in plugins without forcing them to also provide config.
661
+ export default (core) => {
662
+ // Use the runtime singleton imported above for substrate-internal
663
+ // code paths (createMcpSubstrate etc. reference it directly via
664
+ // closure). The factory arg `core.runtime` is the same object;
665
+ // they're both mikser-io's exported runtime singleton.
666
+ const { onLoaded, useLogger } = core
667
+
668
+ if (!runtime.config.mcp) {
669
+ useLogger?.()?.debug('mikser-io-mcp loaded but runtime.config.mcp is absent — no substrate created')
670
+ return { name: 'mcp' }
671
+ }
672
+
673
+ // Contribute MCP transport headers to engine's CORS arrays so
674
+ // browser-side MCP clients (basic-host, mcp-ui, etc.) can read
675
+ // mcp-session-id from initialize responses and send it back on
676
+ // follow-up requests. Idempotent — guards against double-load.
677
+ if (runtime.options.corsAllowHeaders) {
678
+ const add = (arr, items) => items.forEach(h => arr.includes(h) || arr.push(h))
679
+ add(runtime.options.corsAllowHeaders, ['mcp-session-id', 'mcp-protocol-version', 'last-event-id'])
680
+ add(runtime.options.corsExposeHeaders, ['mcp-session-id', 'mcp-protocol-version'])
681
+ }
682
+
683
+ // Create substrate synchronously. Other plugins' factories /
684
+ // onLoaded hooks check `if (!runtime.options.mcp) return` before
685
+ // calling `mcp.simpleTool(...)` — so the substrate has to be on
686
+ // runtime.options.mcp by the time those run. The mcp plugin MUST
687
+ // be FIRST in the user's plugins array for that contract to hold.
688
+ runtime.options.mcp = createMcpSubstrate()
689
+ runtime.options.mcpPath = runtime.config.mcp.path ?? '/mcp'
690
+
691
+ // Compose the MCP-UI surface in the same package — shell
692
+ // resource, mikser_preview_ui, mikser_ui_action, mikser_preview_render,
693
+ // forwardToHandler, the mcp-ui/modes discovery resource.
694
+ // Pass the full core args through so previewPlugin gets
695
+ // findEntity / findEntities too.
696
+ previewPlugin(core)
697
+
698
+ onLoaded(async () => {
699
+ const logger = useLogger()
700
+
701
+ // Wire the engine's pino logger to broadcast MCP notifications
702
+ // for every log call. Wraps in-place so the existing logger
703
+ // reference (held by plugins, render workers, useLogger
704
+ // consumers) gains the side-channel automatically.
705
+ if (runtime.engine?.logger) {
706
+ wireLoggerToMcp(runtime.engine.logger, runtime.options.mcp)
707
+ logger.debug('MCP logger wiring active')
708
+ }
709
+
710
+ // Mount HTTP transport when an Express app is available.
711
+ // Without --server (no app) the substrate still works for
712
+ // embedders calling registerTool / broadcastLog directly.
713
+ if (runtime.options.app && runtime.options.mcpPath) {
714
+ await mountMcpOnExpress(
715
+ runtime.options.app,
716
+ runtime.options.mcp,
717
+ runtime.options.mcpPath,
718
+ )
719
+ }
720
+ })
721
+
722
+ // mikser_refs_* tool surface. Pure transport over runtime.refs — the
723
+ // engine owns the inverse-reference index (mikser-io/src/refs.js);
724
+ // this just wraps four queries and one resource in the MCP shape.
725
+ // runtime.refs is created in refs.js's onInitialized hook (runs
726
+ // before any onLoaded), so the surface is guaranteed present here.
727
+ // Guarded anyway: if the engine boots without refs the registrations
728
+ // are silently skipped.
729
+ onLoaded(() => {
730
+ const logger = useLogger()
731
+ const mcp = runtime.options.mcp
732
+ if (!runtime.refs) {
733
+ logger.debug('mikser_refs_* tools skipped: runtime.refs not initialized')
734
+ return
735
+ }
736
+
737
+ const ok = (data) => ({
738
+ content: [{
739
+ type: 'text',
740
+ text: typeof data === 'string' ? data : JSON.stringify(data, null, 2),
741
+ }],
742
+ })
743
+ const fail = (msg) => ({ isError: true, content: [{ type: 'text', text: msg }] })
744
+
745
+ mcp.simpleTool(
746
+ 'mikser_refs_inbound',
747
+ 'List entities that reference the given href. Returns every (entity id, field path) pair pointing at this target. Use for "what would break if I delete this?" or "show me everything that mentions /authors/dick." The query is exact-match against the canonical ref value as written in source $-keys.',
748
+ {
749
+ ref: z.string().describe('Reference value to look up. Match is exact on the source-file form (e.g. "/authors/dick"). Hrefs, not catalog ids.'),
750
+ },
751
+ async ({ ref }) => {
752
+ try {
753
+ if (!ref) return fail('ref is required')
754
+ const entries = runtime.refs.inboundFor(ref)
755
+ return ok({ ref, count: entries.length, entries })
756
+ } catch (err) {
757
+ logger.error('MCP mikser_refs_inbound error: %s', err.message)
758
+ return fail(err.message)
759
+ }
760
+ },
761
+ )
762
+
763
+ mcp.simpleTool(
764
+ 'mikser_refs_outbound',
765
+ 'List the references emitted by the given entity. Returns every $-keyed field on the entity and the ref string it carries. Use for "what does this entity link to?" or "show me the relationship graph rooted at /blog/launch.md."',
766
+ {
767
+ id: z.string().describe('Catalog id of the source entity (e.g. "/documents/blog/launch.md").'),
768
+ },
769
+ async ({ id }) => {
770
+ try {
771
+ if (!id) return fail('id is required')
772
+ const entries = runtime.refs.outboundFor(id)
773
+ return ok({ id, count: entries.length, entries })
774
+ } catch (err) {
775
+ logger.error('MCP mikser_refs_outbound error: %s', err.message)
776
+ return fail(err.message)
777
+ }
778
+ },
779
+ )
780
+
781
+ mcp.simpleTool(
782
+ 'mikser_refs_broken',
783
+ 'List all references that do not currently resolve to an entity. Walks every ref in the inverse index and tests each via the catalog. Use for build-time health checks, editor "broken links" panels, or CI gates.',
784
+ {},
785
+ async () => {
786
+ try {
787
+ const broken = []
788
+ for (const ref of runtime.refs.allRefs()) {
789
+ const exists = await refExists(ref)
790
+ if (!exists) {
791
+ broken.push({ ref, sources: runtime.refs.inboundFor(ref) })
792
+ }
793
+ }
794
+ return ok({ count: broken.length, broken })
795
+ } catch (err) {
796
+ logger.error('MCP mikser_refs_broken error: %s', err.message)
797
+ return fail(err.message)
798
+ }
799
+ },
800
+ )
801
+
802
+ mcp.simpleTool(
803
+ 'mikser_refs_rename',
804
+ 'Rewrite every reference to `from` so it points at `to`. Walks the inverse index for `from`, opens each referencing source file, and rewrites the `$`-keyed value via writeEntity. The watcher picks up each rewrite and the catalog re-syncs on the next cycle. Returns the list of (entity id, fields) pairs that were updated. Idempotent — calling twice with the same args is a no-op on the second call because the first call drained the inbound list.',
805
+ {
806
+ from: z.string().describe('Old reference value as written in source files (e.g. "/authors/dick").'),
807
+ to: z.string().describe('New reference value to write in its place (e.g. "/authors/dick-marinov").'),
808
+ },
809
+ async ({ from, to }) => {
810
+ try {
811
+ const result = await runtime.refs.rename({ from, to })
812
+ return ok(result)
813
+ } catch (err) {
814
+ logger.error('MCP mikser_refs_rename error: %s', err.message)
815
+ return fail(err.message)
816
+ }
817
+ },
818
+ )
819
+
820
+ try {
821
+ mcp.registerResource(
822
+ 'mikser-refs-index',
823
+ 'mikser://refs/index',
824
+ {
825
+ title: 'Reverse-reference index',
826
+ description: 'Read-only snapshot of the engine\'s inverse-reference index. Lists every reference in the catalog and the entities that emit it.',
827
+ mimeType: 'application/json',
828
+ },
829
+ async (uri) => ({
830
+ contents: [{
831
+ uri: uri.href,
832
+ mimeType: 'application/json',
833
+ text: JSON.stringify({
834
+ stats: runtime.refs.size(),
835
+ refs: runtime.refs.allRefs().map(ref => ({
836
+ ref,
837
+ sources: runtime.refs.inboundFor(ref),
838
+ })),
839
+ }, null, 2),
840
+ }],
841
+ }),
842
+ )
843
+ } catch (err) {
844
+ logger.debug('mikser://refs/index registration skipped: %s', err.message)
845
+ }
846
+
847
+ logger.debug('MCP tools registered: mikser_refs_{inbound,outbound,broken,rename} (mcp plugin)')
848
+ })
849
+
850
+ // mikser_layouts_inspect — wraps runtime.options.layouts.inspect()
851
+ // (exposed by the core layouts plugin) in the MCP tool envelope.
852
+ // The domain knowledge — what counts as a layout, how to parse
853
+ // liquid/handlebars/eta references — lives in the layouts plugin;
854
+ // this is just the schema + transport + author-hint notes.
855
+ onLoaded(() => {
856
+ const logger = useLogger()
857
+ const mcp = runtime.options.mcp
858
+ if (!runtime.options.layouts?.inspect) {
859
+ logger.debug('mikser_layouts_inspect skipped: runtime.options.layouts.inspect not exposed (layouts plugin not loaded?)')
860
+ return
861
+ }
862
+
863
+ mcp.simpleTool(
864
+ 'mikser_layouts_inspect',
865
+ 'Inspect a layout: template source, variables it references, the postprocessor it produces, and sample entities currently using it. Use this to answer "what data does this layout need?" before drafting a preview render — saves a guess-and-render-empty cycle.',
866
+ {
867
+ id: z.string().describe('Layout id, e.g. "/layouts/reports/royalty.html-pdf.liquid". Use mikser_query_entities with { collection: "layouts" } to discover ids.'),
868
+ samples: z.number().int().min(0).max(10).optional().describe('How many existing entities currently using this layout to include as data-shape examples. Default 3. Only entities with explicit meta.layout match; auto-matched layouts are not surfaced.'),
869
+ },
870
+ async ({ id, samples = 3 }) => {
871
+ try {
872
+ const result = await runtime.options.layouts.inspect(id, { samples })
873
+ return {
874
+ content: [{
875
+ type: 'text',
876
+ text: JSON.stringify({
877
+ ...result,
878
+ notes: [
879
+ 'references.variables is a naive regex pass across liquid/handlebars/eta — false positives possible, but covers the common `{{ document.meta.X }}` and `<%= entity.X %>` patterns.',
880
+ 'samples only includes entities with explicit meta.layout. Auto-matched layouts are not listed; use mikser_query_entities with a filename-pattern filter for those.',
881
+ ],
882
+ }, null, 2),
883
+ }],
884
+ }
885
+ } catch (err) {
886
+ logger.error('MCP mikser_layouts_inspect error: %s', err.message)
887
+ return {
888
+ isError: true,
889
+ content: [{ type: 'text', text: err.message }],
890
+ }
891
+ }
892
+ },
893
+ )
894
+
895
+ logger.debug('MCP tool registered: mikser_layouts_inspect (mcp plugin)')
896
+ })
897
+
898
+ // Catalog + render tool surface. Five tools wrapping the engine's
899
+ // catalog operations (queryEntities / readEntity from
900
+ // mikser-io/src/catalog.js, useCollection.write/.remove for the
901
+ // mutating pair) and a render tool backed by an mcp-owned renderer
902
+ // instance.
903
+ //
904
+ // Catalog ops are direct mikser-io imports — no plugin-surface
905
+ // dependency, no presence check. The catalog is created during
906
+ // onInitialized (lifecycle phase before any onLoaded), so by the
907
+ // time this hook fires it's always ready.
908
+ //
909
+ // Render gets its own renderer here with `runtime.config.mcp.renderTimeout`
910
+ // (or 30s default) — independent of `runtime.config.api.renderTimeout`
911
+ // which governs HTTP endpoint behavior. MCP request lifecycles and
912
+ // HTTP request lifecycles can want different timeouts.
913
+ //
914
+ onLoaded(() => {
915
+ const logger = useLogger()
916
+ const mcp = runtime.options.mcp
917
+
918
+ const { render: mcpRender } = useRenderer(runtime, {
919
+ defaultTimeout: runtime.config.mcp?.renderTimeout ?? 30_000,
920
+ })
921
+
922
+ const ok = (data) => ({
923
+ content: [{
924
+ type: 'text',
925
+ text: typeof data === 'string' ? data : JSON.stringify(data, null, 2),
926
+ }],
927
+ })
928
+ const fail = (msg) => ({ isError: true, content: [{ type: 'text', text: msg }] })
929
+
930
+ mcp.simpleTool(
931
+ 'mikser_query_entities',
932
+ 'Query entities from mikser\'s catalog with optional filter / sort / projection. Use this for "show me all documents about X" or "what entities are in collection Y." Returns paginated results. Pass `expand` to inline referenced entities (per ADR-0007): paths like "author", "author.organization", or "sections.*.image" walk through $-keyed reference fields and replace the ref string with the resolved entity in one round-trip.',
933
+ {
934
+ filter: z.record(z.any()).optional().describe('Mongo-style filter (sift-compatible). Defaults to no filter — every entity.'),
935
+ sort: z.record(z.number()).optional().describe('Sort spec, e.g. { "meta.date": -1, "name": 1 }.'),
936
+ fields: z.array(z.string()).optional().describe('Dotted-path projection. Omit to return whole entities.'),
937
+ skip: z.number().int().min(0).optional().describe('Skip N items.'),
938
+ limit: z.number().int().min(1).max(100).optional().describe('Page size, defaults to 25, capped at 100.'),
939
+ expand: z.array(z.string()).optional().describe('Inline-expand referenced entities. Each entry is a dotted path that walks through $-keyed reference fields, replacing the ref string with the resolved entity. Use `*` for array iteration. Examples: ["author"], ["author.organization"], ["sections.*.image"]. Default caps: maxDepth 5, maxPaths 20, maxResolved 100 per request.'),
940
+ },
941
+ async ({ filter, sort, fields, skip, limit, expand }) => {
942
+ try {
943
+ return ok(await queryEntities({ filter, sort, fields, skip, limit, expand }))
944
+ } catch (err) {
945
+ logger.error('MCP mikser_query_entities error: %s', err.message)
946
+ return fail(err.message)
947
+ }
948
+ },
949
+ )
950
+
951
+ mcp.simpleTool(
952
+ 'mikser_read_entity',
953
+ 'Read a single entity by its catalog id (e.g. "/documents/about.md"). Returns the full entity record or null when not found. Pass include: ["content"] to also fetch the source file content from disk — useful for reading a layout template, document frontmatter+body, or any text-format source without dropping out to the filesystem. Binary formats (png/pdf/mp4/etc.) get a `contentSkipped` hint pointing at mikser_render instead of decoded bytes. Pass `expand` to inline referenced entities in the response (per ADR-0007): paths like "author", "author.organization", or "sections.*.image" replace the ref string with the resolved entity in one trip.',
954
+ {
955
+ id: z.string().describe('Catalog id of the entity to read.'),
956
+ include: z.array(z.enum(['content'])).optional().describe('Optional list of extra fields to populate. Currently only "content" is supported: reads the file at entity.uri as utf8 and attaches it as .content. Binary formats get `contentSkipped` instead of garbage utf8.'),
957
+ expand: z.array(z.string()).optional().describe('Inline-expand referenced entities. Each entry is a dotted path through $-keyed reference fields. Use `*` for array iteration. Examples: ["author"], ["author.organization"], ["sections.*.image"]. Same caps as mikser_query_entities.'),
958
+ },
959
+ async ({ id, include, expand }) => {
960
+ try {
961
+ const entity = await readEntity({ id, expand })
962
+ if (entity && include?.includes('content')) {
963
+ // readEntityContent gates on text-extension and
964
+ // attaches one of { content, contentError,
965
+ // contentSkipped } — single source of truth on
966
+ // what "load this entity's content" means,
967
+ // shared across any consumer that wants the
968
+ // text/binary gate.
969
+ Object.assign(entity, await readEntityContent(entity))
970
+ }
971
+ return ok(entity)
972
+ } catch (err) {
973
+ logger.error('MCP mikser_read_entity error: %s', err.message)
974
+ return fail(err.message)
975
+ }
976
+ },
977
+ )
978
+
979
+ mcp.simpleTool(
980
+ 'mikser_update_entity',
981
+ 'Create or update a content file inside a mikser collection. The file is written to disk and the next lifecycle cycle picks it up. Use this to author new documents, layouts, or other content from AI.',
982
+ {
983
+ collection: z.string().describe('Collection name (e.g. "documents", "layouts").'),
984
+ relativePath: z.string().describe('Path relative to the collection folder (e.g. "blog/2026-06-02-launch.md").'),
985
+ content: z.string().optional().describe('File content to write. Frontmatter is parsed by the corresponding plugin.'),
986
+ },
987
+ async ({ collection, relativePath, content = '' }) => {
988
+ try {
989
+ await useCollection(runtime, collection).write(relativePath, content)
990
+ return ok({ ok: true, collection, relativePath })
991
+ } catch (err) {
992
+ logger.error('MCP mikser_update_entity error: %s', err.message)
993
+ return fail(err.message)
994
+ }
995
+ },
996
+ )
997
+
998
+ mcp.simpleTool(
999
+ 'mikser_delete_entity',
1000
+ 'Remove a content file from a mikser collection. Deletes the source file; the next lifecycle cycle prunes its rendered outputs from the manifest.',
1001
+ {
1002
+ collection: z.string().describe('Collection name.'),
1003
+ relativePath: z.string().describe('Path relative to the collection folder.'),
1004
+ },
1005
+ async ({ collection, relativePath }) => {
1006
+ try {
1007
+ await useCollection(runtime, collection).remove(relativePath)
1008
+ return ok({ ok: true, collection, relativePath })
1009
+ } catch (err) {
1010
+ logger.error('MCP mikser_delete_entity error: %s', err.message)
1011
+ return fail(err.message)
1012
+ }
1013
+ },
1014
+ )
1015
+
1016
+ mcp.simpleTool(
1017
+ 'mikser_render',
1018
+ 'Render a transient entity through the engine pipeline (parse → layouts → resources → render → postprocess) and return the FINAL produced bytes. Use this for "preview this layout against this data" without writing the entity to disk. The returned bytes are the pipeline\'s final output — PDF for a `*.html-pdf.*` layout, MJML-derived HTML for `*.html-mjml.*`, etc. Set options.save=false to skip the disk write; options.catalog=false to prune the catalog row after rendering. For a clickable preview URL instead of raw bytes, use mikser_preview_render (preview plugin).',
1019
+ {
1020
+ entity: z.record(z.any()).describe('Entity shape with at least { id, collection } and any meta/content the renderer needs.'),
1021
+ options: z.record(z.any()).optional().describe('Renderer options: { save: false, catalog: false, renderer: "...", postprocessor: "..." }.'),
1022
+ },
1023
+ async ({ entity = {}, options = {} }) => {
1024
+ try {
1025
+ const { output, entity: rendered } = await mcpRender(entity, options)
1026
+ const result = output?.result
1027
+ if (result == null) {
1028
+ return ok({ ok: true, entity: rendered, output: null })
1029
+ }
1030
+ const mime = mimeForEntity(rendered) ?? 'application/octet-stream'
1031
+ if (Buffer.isBuffer(result)) {
1032
+ return {
1033
+ content: [{
1034
+ type: 'resource',
1035
+ resource: {
1036
+ uri: `mikser://render/${rendered.id ?? 'inline'}`,
1037
+ mimeType: mime,
1038
+ blob: result.toString('base64'),
1039
+ },
1040
+ }],
1041
+ }
1042
+ }
1043
+ // String result — most renderers (HTML, MJML, etc.).
1044
+ return {
1045
+ content: [{
1046
+ type: 'resource',
1047
+ resource: {
1048
+ uri: `mikser://render/${rendered.id ?? 'inline'}`,
1049
+ mimeType: mime,
1050
+ text: String(result),
1051
+ },
1052
+ }],
1053
+ }
1054
+ } catch (err) {
1055
+ logger.error('MCP mikser_render error: %s', err.message)
1056
+ return fail(err.message)
1057
+ }
1058
+ },
1059
+ )
1060
+
1061
+ logger.debug('MCP tools registered: mikser_{query_entities,read_entity,update_entity,delete_entity,render} (mcp plugin)')
1062
+ })
1063
+
1064
+ return { name: 'mcp' }
1065
+ }