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/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
|
+
}
|