mikser-io 7.6.0 → 7.8.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/package.json +1 -1
- package/src/engine.js +29 -1
- package/src/mcp.js +147 -21
- package/src/plugins/api.js +50 -51
- package/src/plugins/layouts.js +6 -6
- package/src/plugins/preview.js +12 -12
- package/src/utils.js +66 -0
- package/ngrok.ymlt +0 -3
package/package.json
CHANGED
package/src/engine.js
CHANGED
|
@@ -133,6 +133,34 @@ export async function setup(options) {
|
|
|
133
133
|
: Number(runtime.options.server) || 3001
|
|
134
134
|
logger.info('Server starting on port %d', runtime.options.port)
|
|
135
135
|
|
|
136
|
+
// Trust-proxy: when mikser is behind a reverse proxy
|
|
137
|
+
// (nginx, Caddy, an Express app, ngrok with edge), the
|
|
138
|
+
// socket peer is the proxy — not the real client. Setting
|
|
139
|
+
// trust proxy makes Express's req.ip walk X-Forwarded-For
|
|
140
|
+
// back to the original requester, which is what mikser's
|
|
141
|
+
// loopback-only auth check compares against.
|
|
142
|
+
//
|
|
143
|
+
// Accepted values (Express semantics):
|
|
144
|
+
// true — trust every hop (only safe when
|
|
145
|
+
// the proxy strips/rewrites
|
|
146
|
+
// X-Forwarded-* headers)
|
|
147
|
+
// 'loopback' — trust 127.0.0.1, ::1, and other
|
|
148
|
+
// loopback addresses (correct for
|
|
149
|
+
// a proxy on the same host)
|
|
150
|
+
// '10.0.0.0/8' — trust a specific subnet
|
|
151
|
+
// false (default) — no trust; req.ip == socket peer
|
|
152
|
+
//
|
|
153
|
+
// Without this and behind a proxy, mikser sees every
|
|
154
|
+
// request as coming from the proxy's loopback address and
|
|
155
|
+
// unauthenticated requests through the proxy would pass
|
|
156
|
+
// the loopback gate. The startup warning below catches
|
|
157
|
+
// some of those misconfigurations.
|
|
158
|
+
const trustProxy = runtime.config.server?.trustProxy
|
|
159
|
+
if (trustProxy !== undefined) {
|
|
160
|
+
runtime.options.app.set('trust proxy', trustProxy)
|
|
161
|
+
logger.info('Server trust proxy: %s', String(trustProxy))
|
|
162
|
+
}
|
|
163
|
+
|
|
136
164
|
// CORS — a server exists to be fetched, and in dev the
|
|
137
165
|
// frontend is almost always on a different origin (a dev
|
|
138
166
|
// server on another port, a separate domain). So CORS is ON
|
|
@@ -212,8 +240,8 @@ export async function setup(options) {
|
|
|
212
240
|
// server is fully reachable over HTTP for AI clients.
|
|
213
241
|
if (runtime.options.mcp && runtime.options.mcpPath) {
|
|
214
242
|
const { mountMcpOnExpress } = await import('./mcp.js')
|
|
243
|
+
// mcp.js logs per-endpoint as it mounts — no extra log here.
|
|
215
244
|
await mountMcpOnExpress(runtime.options.app, runtime.options.mcp, runtime.options.mcpPath)
|
|
216
|
-
logger.info('MCP mounted: %s', runtime.options.mcpPath)
|
|
217
245
|
}
|
|
218
246
|
|
|
219
247
|
// Serve the output folder as the catch-all static route.
|
package/src/mcp.js
CHANGED
|
@@ -20,8 +20,26 @@
|
|
|
20
20
|
import { randomUUID } from 'node:crypto'
|
|
21
21
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
22
22
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
|
23
|
+
import { minimatch } from 'minimatch'
|
|
23
24
|
import packageInfo from '../package.json' with { type: 'json' }
|
|
24
25
|
import runtime from './runtime.js'
|
|
26
|
+
import { isLoopback } from './utils.js'
|
|
27
|
+
|
|
28
|
+
// Pattern matcher for endpoint tools/resources filters. Accepts
|
|
29
|
+
// '*', an array of patterns, or undefined (= allow all). Glob
|
|
30
|
+
// patterns like 'mikser_api_*' or 'mikser_*_render' work through
|
|
31
|
+
// minimatch — same library mikser uses for content matching, so
|
|
32
|
+
// the syntax is consistent across the codebase.
|
|
33
|
+
function matchesAny(name, patterns) {
|
|
34
|
+
if (patterns == null) return true
|
|
35
|
+
if (patterns === '*') return true
|
|
36
|
+
if (!Array.isArray(patterns)) return false
|
|
37
|
+
for (const p of patterns) {
|
|
38
|
+
if (p === '*') return true
|
|
39
|
+
if (minimatch(name, p)) return true
|
|
40
|
+
}
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
25
43
|
|
|
26
44
|
let pinoLevelToMcp = (pinoLevel) => {
|
|
27
45
|
if (pinoLevel >= 50) return 'error'
|
|
@@ -55,10 +73,25 @@ export function createMcpSubstrate() {
|
|
|
55
73
|
const LOG_BUFFER_CAP = 500
|
|
56
74
|
const logBuffer = []
|
|
57
75
|
|
|
58
|
-
function bind(server) {
|
|
59
|
-
|
|
60
|
-
for (const args of registrations.
|
|
61
|
-
|
|
76
|
+
function bind(server, filters = {}) {
|
|
77
|
+
const { allowedTools, allowedResources, allowedPrompts } = filters
|
|
78
|
+
for (const args of registrations.tools) {
|
|
79
|
+
if (!matchesAny(args[0], allowedTools)) continue
|
|
80
|
+
server.registerTool(...args)
|
|
81
|
+
}
|
|
82
|
+
for (const args of registrations.resources) {
|
|
83
|
+
// Resource registrations are (name, uri, config, handler).
|
|
84
|
+
// Filter on the URI since that's the addressable identifier
|
|
85
|
+
// (`mikser://lifecycle` reads more naturally as the filter
|
|
86
|
+
// target than the short `mikser-lifecycle` name).
|
|
87
|
+
const uri = typeof args[1] === 'string' ? args[1] : args[0]
|
|
88
|
+
if (!matchesAny(uri, allowedResources)) continue
|
|
89
|
+
server.registerResource(...args)
|
|
90
|
+
}
|
|
91
|
+
for (const args of registrations.prompts) {
|
|
92
|
+
if (!matchesAny(args[0], allowedPrompts)) continue
|
|
93
|
+
server.registerPrompt(...args)
|
|
94
|
+
}
|
|
62
95
|
}
|
|
63
96
|
|
|
64
97
|
const substrate = {
|
|
@@ -95,13 +128,20 @@ export function createMcpSubstrate() {
|
|
|
95
128
|
},
|
|
96
129
|
|
|
97
130
|
// Create a fresh McpServer pre-loaded with every recorded
|
|
98
|
-
// registration
|
|
99
|
-
|
|
131
|
+
// registration that passes the endpoint's filters. Called by
|
|
132
|
+
// the transport mount per new session.
|
|
133
|
+
//
|
|
134
|
+
// Filters take patterns (exact name or glob via minimatch):
|
|
135
|
+
// allowedTools: ['mikser_api_*', 'mikser_ping']
|
|
136
|
+
// allowedResources: ['mikser://lifecycle', 'mikser://logs/*']
|
|
137
|
+
// Omit a filter (or pass '*') to allow everything in that
|
|
138
|
+
// category — that's the backward-compat default.
|
|
139
|
+
_createServer({ allowedTools, allowedResources, allowedPrompts } = {}) {
|
|
100
140
|
const server = new McpServer(
|
|
101
141
|
{ name: 'mikser-io', version: packageInfo.version },
|
|
102
142
|
{ capabilities: { tools: {}, resources: {}, logging: {} } },
|
|
103
143
|
)
|
|
104
|
-
bind(server)
|
|
144
|
+
bind(server, { allowedTools, allowedResources, allowedPrompts })
|
|
105
145
|
return server
|
|
106
146
|
},
|
|
107
147
|
_attach(server) { activeServers.add(server) },
|
|
@@ -308,33 +348,99 @@ function serverInfo() {
|
|
|
308
348
|
serves: opts.outputFolder ?? null,
|
|
309
349
|
mcpPath: opts.mcpPath ?? null,
|
|
310
350
|
mcpUrl: base && opts.mcpPath ? `${base}${opts.mcpPath}` : null,
|
|
311
|
-
// Preview URLs are returned directly by
|
|
312
|
-
// plugin), so we don't advertise a path convention here —
|
|
313
|
-
// doing so would be a lie when the
|
|
351
|
+
// Preview URLs are returned directly by mikser_preview_render
|
|
352
|
+
// (preview plugin), so we don't advertise a path convention here —
|
|
353
|
+
// doing so would be a lie when the preview plugin isn't loaded.
|
|
314
354
|
}
|
|
315
355
|
}
|
|
316
356
|
|
|
317
357
|
/**
|
|
318
|
-
* Mount the MCP substrate on an Express app
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
358
|
+
* Mount the MCP substrate on an Express app. Two modes:
|
|
359
|
+
*
|
|
360
|
+
* 1. Single endpoint (backward compat): no `runtime.config.mcp.endpoints`
|
|
361
|
+
* → mounts one open endpoint at `defaultPath` (default `/mcp`) with
|
|
362
|
+
* all tools, all resources, no token. Matches the v7.0-7.6 shape.
|
|
363
|
+
*
|
|
364
|
+
* 2. Multiple endpoints: with `runtime.config.mcp.endpoints` set →
|
|
365
|
+
* each endpoint mounts at `<mcp.base>/<name>` (default base `/mcp`)
|
|
366
|
+
* with its own filters (`tools`, `resources`) and optional
|
|
367
|
+
* `token` for Bearer auth.
|
|
368
|
+
*
|
|
369
|
+
* Each endpoint is its own session map — sessions don't cross endpoints.
|
|
370
|
+
* Same noun and shape as the api plugin's `endpoints` config.
|
|
323
371
|
*/
|
|
324
|
-
export async function mountMcpOnExpress(app, substrate,
|
|
325
|
-
|
|
326
|
-
|
|
372
|
+
export async function mountMcpOnExpress(app, substrate, defaultPath = '/mcp') {
|
|
373
|
+
const endpoints = runtime.config.mcp?.endpoints
|
|
374
|
+
const base = runtime.config.mcp?.base ?? defaultPath
|
|
375
|
+
|
|
376
|
+
if (endpoints && Object.keys(endpoints).length > 0) {
|
|
377
|
+
for (const [name, ep] of Object.entries(endpoints)) {
|
|
378
|
+
mountEndpoint(app, substrate, `${base}/${name}`, ep, name)
|
|
379
|
+
}
|
|
380
|
+
} else {
|
|
381
|
+
// Backward-compat single endpoint. With no `mcp.endpoints`
|
|
382
|
+
// configured, mount one open + loopback-only endpoint — same
|
|
383
|
+
// safe default as a per-endpoint config with no token. The
|
|
384
|
+
// boot log line itself (from mountEndpoint) shows the state;
|
|
385
|
+
// no extra warning needed because the default IS the safe one.
|
|
386
|
+
mountEndpoint(app, substrate, defaultPath, {}, null)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function mountEndpoint(app, substrate, path, ep, endpointName) {
|
|
327
391
|
const transports = new Map()
|
|
392
|
+
const expectedAuth = ep.token ? `Bearer ${ep.token}` : null
|
|
328
393
|
|
|
329
394
|
async function handle(req, res, body) {
|
|
395
|
+
// Auth rule (uniform across mikser plugins):
|
|
396
|
+
// - Token presented and matches → allow (from anywhere)
|
|
397
|
+
// - Token presented and doesn't match → 401
|
|
398
|
+
// - No token presented → require loopback unless allowRemote
|
|
399
|
+
//
|
|
400
|
+
// This means an endpoint with a token can still be called from
|
|
401
|
+
// localhost without the token — the "trusted host" model. Same
|
|
402
|
+
// pattern Postgres trust-auth and Redis default use. If the host
|
|
403
|
+
// is compromised mikser's tools are the least of your worries.
|
|
404
|
+
const presented = req.headers.authorization
|
|
405
|
+
if (expectedAuth) {
|
|
406
|
+
if (presented && presented !== expectedAuth) {
|
|
407
|
+
res.status(401).json({
|
|
408
|
+
jsonrpc: '2.0',
|
|
409
|
+
error: { code: -32001, message: 'Invalid MCP token' },
|
|
410
|
+
id: null,
|
|
411
|
+
})
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
// No header presented falls through to the loopback check
|
|
415
|
+
// below — token-gated endpoints still accept loopback.
|
|
416
|
+
}
|
|
417
|
+
if (!presented || presented !== expectedAuth) {
|
|
418
|
+
if (!ep.allowRemote && !isLoopback(req.ip)) {
|
|
419
|
+
res.status(403).json({
|
|
420
|
+
jsonrpc: '2.0',
|
|
421
|
+
error: {
|
|
422
|
+
code: -32001,
|
|
423
|
+
message: expectedAuth
|
|
424
|
+
? 'Token required from non-loopback sources'
|
|
425
|
+
: 'Endpoint accepts loopback connections only — configure a token or set allowRemote: true to enable remote access',
|
|
426
|
+
},
|
|
427
|
+
id: null,
|
|
428
|
+
})
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
330
433
|
const sessionId = req.headers['mcp-session-id']
|
|
331
434
|
if (sessionId && transports.has(sessionId)) {
|
|
332
435
|
return transports.get(sessionId).handleRequest(req, res, body)
|
|
333
436
|
}
|
|
334
437
|
|
|
335
|
-
//
|
|
336
|
-
|
|
337
|
-
|
|
438
|
+
// New session — server filtered for this endpoint's surface.
|
|
439
|
+
const server = substrate._createServer({
|
|
440
|
+
allowedTools: ep.tools,
|
|
441
|
+
allowedResources: ep.resources,
|
|
442
|
+
allowedPrompts: ep.prompts,
|
|
443
|
+
})
|
|
338
444
|
const transport = new StreamableHTTPServerTransport({
|
|
339
445
|
sessionIdGenerator: () => randomUUID(),
|
|
340
446
|
onsessioninitialized: (id) => {
|
|
@@ -353,6 +459,26 @@ export async function mountMcpOnExpress(app, substrate, path = '/mcp') {
|
|
|
353
459
|
app.post(path, (req, res) => handle(req, res, req.body))
|
|
354
460
|
app.get(path, (req, res) => handle(req, res))
|
|
355
461
|
app.delete(path, (req, res) => handle(req, res))
|
|
462
|
+
|
|
463
|
+
const logger = runtime.engine?.logger
|
|
464
|
+
if (logger) {
|
|
465
|
+
const toolsLabel = ep.tools == null || ep.tools === '*'
|
|
466
|
+
? '*'
|
|
467
|
+
: Array.isArray(ep.tools) ? ep.tools.join(',') : String(ep.tools)
|
|
468
|
+
// Three reachability states: token (anyone with the token from
|
|
469
|
+
// anywhere), loopback-only (no token + no allowRemote, default),
|
|
470
|
+
// or REMOTE OPEN (no token + allowRemote, deliberate exposure).
|
|
471
|
+
// The all-caps "REMOTE OPEN" mirrors the boot warning style so
|
|
472
|
+
// an operator scanning startup output sees the risk.
|
|
473
|
+
const authLabel = ep.token
|
|
474
|
+
? 'token'
|
|
475
|
+
: (ep.allowRemote ? 'public, REMOTE OPEN' : 'public, loopback-only')
|
|
476
|
+
if (endpointName) {
|
|
477
|
+
logger.info('MCP endpoint mounted: %s (tools=[%s] [%s])', path, toolsLabel, authLabel)
|
|
478
|
+
} else {
|
|
479
|
+
logger.info('MCP mounted: %s [%s]', path, authLabel)
|
|
480
|
+
}
|
|
481
|
+
}
|
|
356
482
|
}
|
|
357
483
|
|
|
358
484
|
/**
|
package/src/plugins/api.js
CHANGED
|
@@ -5,6 +5,7 @@ import _ from 'lodash'
|
|
|
5
5
|
import sift from 'sift'
|
|
6
6
|
import { z } from 'zod'
|
|
7
7
|
import { useRenderer, useCollection } from '../api.js'
|
|
8
|
+
import { mimeForEntity, isLoopback } from '../utils.js'
|
|
8
9
|
|
|
9
10
|
// Mongo-style operators recognised in URL query params as `<path>.$<op>=...`.
|
|
10
11
|
// $in / $nin take comma-separated values; $exists takes a truthy/falsy
|
|
@@ -202,36 +203,8 @@ async function clearEndpointCache({ outputFolder, base, name, logger }) {
|
|
|
202
203
|
}
|
|
203
204
|
}
|
|
204
205
|
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
// (assigned by the layouts plugin), so we use it as the source of truth.
|
|
208
|
-
const MIME_BY_EXT = {
|
|
209
|
-
pdf: 'application/pdf',
|
|
210
|
-
html: 'text/html; charset=utf-8',
|
|
211
|
-
xml: 'application/xml; charset=utf-8',
|
|
212
|
-
xhtml: 'application/xhtml+xml; charset=utf-8',
|
|
213
|
-
rss: 'application/rss+xml; charset=utf-8',
|
|
214
|
-
atom: 'application/atom+xml; charset=utf-8',
|
|
215
|
-
json: 'application/json; charset=utf-8',
|
|
216
|
-
css: 'text/css; charset=utf-8',
|
|
217
|
-
js: 'application/javascript; charset=utf-8',
|
|
218
|
-
svg: 'image/svg+xml',
|
|
219
|
-
png: 'image/png',
|
|
220
|
-
jpg: 'image/jpeg',
|
|
221
|
-
jpeg: 'image/jpeg',
|
|
222
|
-
webp: 'image/webp',
|
|
223
|
-
gif: 'image/gif',
|
|
224
|
-
mp4: 'video/mp4',
|
|
225
|
-
webm: 'video/webm',
|
|
226
|
-
txt: 'text/plain; charset=utf-8',
|
|
227
|
-
md: 'text/markdown; charset=utf-8',
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
export function mimeForEntity(entity) {
|
|
231
|
-
if (!entity?.destination) return null
|
|
232
|
-
const ext = path.extname(entity.destination).toLowerCase().replace(/^\./, '')
|
|
233
|
-
return MIME_BY_EXT[ext] ?? null
|
|
234
|
-
}
|
|
206
|
+
// mimeForEntity lives in ../utils.js (pure helper, shared by several
|
|
207
|
+
// plugins). Imported at top of file.
|
|
235
208
|
|
|
236
209
|
// Decide how to send the render output over HTTP. Exported (and pure-ish)
|
|
237
210
|
// so tests can exercise the branching without spinning up a real server.
|
|
@@ -336,7 +309,7 @@ export default ({
|
|
|
336
309
|
// Preview workflow (render → cache → URL) lives in its own
|
|
337
310
|
// plugin (src/plugins/preview.js) as of v7.3.0. The api plugin
|
|
338
311
|
// stays focused on REST catalog access; preview is a separate
|
|
339
|
-
// domain. To use
|
|
312
|
+
// domain. To use mikser_preview_render, load `preview` alongside `api`.
|
|
340
313
|
|
|
341
314
|
// cachedEndpoints is hoisted above (shared with onFinalize).
|
|
342
315
|
// Per-endpoint setup loop pushes into it when cache: true is set.
|
|
@@ -430,10 +403,33 @@ export default ({
|
|
|
430
403
|
}
|
|
431
404
|
const cacheEnabled = ep.cache === true
|
|
432
405
|
|
|
406
|
+
// Uniform mikser auth rule (same as MCP endpoints):
|
|
407
|
+
// - Token presented and matches → allow (from anywhere)
|
|
408
|
+
// - Token presented and doesn't match → 401
|
|
409
|
+
// - No token presented → require loopback unless allowRemote
|
|
410
|
+
//
|
|
411
|
+
// Endpoints with a token are still reachable from loopback
|
|
412
|
+
// without the token — the "trusted local host" model. To
|
|
413
|
+
// require the token everywhere, run mikser bound to a
|
|
414
|
+
// non-loopback interface only (or behind a proxy that
|
|
415
|
+
// doesn't forward loopback origin).
|
|
416
|
+
const expectedAuth = ep.token ? `Bearer ${ep.token}` : null
|
|
433
417
|
const auth = (req, res, next) => {
|
|
434
|
-
|
|
435
|
-
if (
|
|
436
|
-
|
|
418
|
+
const presented = req.headers.authorization
|
|
419
|
+
if (expectedAuth && presented && presented !== expectedAuth) {
|
|
420
|
+
return res.status(401).json({ error: 'Unauthorized' })
|
|
421
|
+
}
|
|
422
|
+
if (presented === expectedAuth && expectedAuth) {
|
|
423
|
+
return next() // valid token from anywhere
|
|
424
|
+
}
|
|
425
|
+
if (ep.allowRemote || isLoopback(req.ip)) {
|
|
426
|
+
return next()
|
|
427
|
+
}
|
|
428
|
+
res.status(403).json({
|
|
429
|
+
error: expectedAuth
|
|
430
|
+
? 'Token required from non-loopback sources'
|
|
431
|
+
: 'Endpoint accepts loopback connections only — configure a token or set allowRemote: true to enable remote access',
|
|
432
|
+
})
|
|
437
433
|
}
|
|
438
434
|
|
|
439
435
|
const allow = (op) => (req, res, next) => {
|
|
@@ -637,9 +633,12 @@ export default ({
|
|
|
637
633
|
})
|
|
638
634
|
|
|
639
635
|
app.use(`${base}/${name}`, router)
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
636
|
+
// Mirror MCP's boot log shape — same three reachability states.
|
|
637
|
+
const authLabel = ep.token
|
|
638
|
+
? 'token'
|
|
639
|
+
: (ep.allowRemote ? 'public, REMOTE OPEN' : 'public, loopback-only')
|
|
640
|
+
logger.info('Api endpoint mounted: %s/%s (ops=[%s] [%s])',
|
|
641
|
+
base, name, [...allowedOps].join(','), authLabel)
|
|
643
642
|
}
|
|
644
643
|
|
|
645
644
|
// MCP tool registrations. MCP is in-process: whoever can reach
|
|
@@ -664,7 +663,7 @@ export default ({
|
|
|
664
663
|
})
|
|
665
664
|
|
|
666
665
|
mcp.simpleTool(
|
|
667
|
-
'
|
|
666
|
+
'mikser_api_list_entities',
|
|
668
667
|
'List 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 in the same envelope shape as the HTTP /entities endpoint.',
|
|
669
668
|
{
|
|
670
669
|
filter: z.record(z.any()).optional().describe('Mongo-style filter (sift-compatible). Defaults to no filter — every entity.'),
|
|
@@ -692,14 +691,14 @@ export default ({
|
|
|
692
691
|
hasNext: effectiveSkip + effectiveLimit < total,
|
|
693
692
|
})
|
|
694
693
|
} catch (err) {
|
|
695
|
-
logger.error('MCP
|
|
694
|
+
logger.error('MCP mikser_api_list_entities error: %s', err.message)
|
|
696
695
|
return fail(err.message)
|
|
697
696
|
}
|
|
698
697
|
},
|
|
699
698
|
)
|
|
700
699
|
|
|
701
700
|
mcp.simpleTool(
|
|
702
|
-
'
|
|
701
|
+
'mikser_api_read_entity',
|
|
703
702
|
'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.',
|
|
704
703
|
{
|
|
705
704
|
id: z.string().describe('Catalog id of the entity to read.'),
|
|
@@ -721,7 +720,7 @@ export default ({
|
|
|
721
720
|
// Heuristic — read content only for text-like
|
|
722
721
|
// formats. Binary types (png, pdf, mp4, etc.)
|
|
723
722
|
// get a marker so the caller knows to use a
|
|
724
|
-
// different tool (
|
|
723
|
+
// different tool (mikser_api_render, or fetch
|
|
725
724
|
// directly) rather than expecting bytes back.
|
|
726
725
|
const TEXT_EXTS = new Set([
|
|
727
726
|
'md', 'markdown', 'html', 'htm', 'xhtml',
|
|
@@ -740,20 +739,20 @@ export default ({
|
|
|
740
739
|
entity.contentError = err.message
|
|
741
740
|
}
|
|
742
741
|
} else {
|
|
743
|
-
entity.contentSkipped = `Non-text format (.${ext}). Use
|
|
742
|
+
entity.contentSkipped = `Non-text format (.${ext}). Use mikser_api_render to materialize output or read the file directly at entity.uri.`
|
|
744
743
|
}
|
|
745
744
|
}
|
|
746
745
|
|
|
747
746
|
return ok(entity)
|
|
748
747
|
} catch (err) {
|
|
749
|
-
logger.error('MCP
|
|
748
|
+
logger.error('MCP mikser_api_read_entity error: %s', err.message)
|
|
750
749
|
return fail(err.message)
|
|
751
750
|
}
|
|
752
751
|
},
|
|
753
752
|
)
|
|
754
753
|
|
|
755
754
|
mcp.simpleTool(
|
|
756
|
-
'
|
|
755
|
+
'mikser_api_update_entity',
|
|
757
756
|
'Create or update a content file inside a mikser collection. The file is written to disk and the next lifecycle cycle picks it up — same path the HTTP PUT /entities endpoint takes. Use this to author new documents, layouts, or other content from AI.',
|
|
758
757
|
{
|
|
759
758
|
collection: z.string().describe('Collection name (e.g. "documents", "layouts").'),
|
|
@@ -765,14 +764,14 @@ export default ({
|
|
|
765
764
|
await useCollection(runtime, collection).write(relativePath, content)
|
|
766
765
|
return ok({ ok: true, collection, relativePath })
|
|
767
766
|
} catch (err) {
|
|
768
|
-
logger.error('MCP
|
|
767
|
+
logger.error('MCP mikser_api_update_entity error: %s', err.message)
|
|
769
768
|
return fail(err.message)
|
|
770
769
|
}
|
|
771
770
|
},
|
|
772
771
|
)
|
|
773
772
|
|
|
774
773
|
mcp.simpleTool(
|
|
775
|
-
'
|
|
774
|
+
'mikser_api_delete_entity',
|
|
776
775
|
'Remove a content file from a mikser collection. Mirrors HTTP DELETE /entities — deletes the source file, and the next lifecycle cycle prunes its rendered outputs from the manifest.',
|
|
777
776
|
{
|
|
778
777
|
collection: z.string().describe('Collection name.'),
|
|
@@ -783,15 +782,15 @@ export default ({
|
|
|
783
782
|
await useCollection(runtime, collection).remove(relativePath)
|
|
784
783
|
return ok({ ok: true, collection, relativePath })
|
|
785
784
|
} catch (err) {
|
|
786
|
-
logger.error('MCP
|
|
785
|
+
logger.error('MCP mikser_api_delete_entity error: %s', err.message)
|
|
787
786
|
return fail(err.message)
|
|
788
787
|
}
|
|
789
788
|
},
|
|
790
789
|
)
|
|
791
790
|
|
|
792
791
|
mcp.simpleTool(
|
|
793
|
-
'
|
|
794
|
-
'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.',
|
|
792
|
+
'mikser_api_render',
|
|
793
|
+
'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).',
|
|
795
794
|
{
|
|
796
795
|
entity: z.record(z.any()).describe('Entity shape with at least { id, collection } and any meta/content the renderer needs.'),
|
|
797
796
|
options: z.record(z.any()).optional().describe('Renderer options: { save: false, catalog: false, renderer: "...", postprocessor: "..." }.'),
|
|
@@ -828,13 +827,13 @@ export default ({
|
|
|
828
827
|
}],
|
|
829
828
|
}
|
|
830
829
|
} catch (err) {
|
|
831
|
-
logger.error('MCP
|
|
830
|
+
logger.error('MCP mikser_api_render error: %s', err.message)
|
|
832
831
|
return fail(err.message)
|
|
833
832
|
}
|
|
834
833
|
},
|
|
835
834
|
)
|
|
836
835
|
|
|
837
|
-
logger.info('MCP tools registered:
|
|
836
|
+
logger.info('MCP tools registered: mikser_api_{list_entities,read_entity,update_entity,delete_entity,render} (api plugin)')
|
|
838
837
|
}
|
|
839
838
|
})
|
|
840
839
|
|
package/src/plugins/layouts.js
CHANGED
|
@@ -506,7 +506,7 @@ export default ({
|
|
|
506
506
|
}
|
|
507
507
|
})
|
|
508
508
|
|
|
509
|
-
//
|
|
509
|
+
// mikser_layouts_inspect lives in the layouts plugin (not core)
|
|
510
510
|
// because "what does a layout expect?" is layout-specific knowledge.
|
|
511
511
|
// Follows ADR-0006: domain logic → plugin; the MCP substrate stays
|
|
512
512
|
// in core.
|
|
@@ -518,10 +518,10 @@ export default ({
|
|
|
518
518
|
if (!runtime.options.mcp) return
|
|
519
519
|
const mcp = runtime.options.mcp
|
|
520
520
|
mcp.simpleTool(
|
|
521
|
-
'
|
|
521
|
+
'mikser_layouts_inspect',
|
|
522
522
|
'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.',
|
|
523
523
|
{
|
|
524
|
-
id: z.string().describe('Layout id, e.g. "/layouts/reports/royalty.html-pdf.liquid". Use
|
|
524
|
+
id: z.string().describe('Layout id, e.g. "/layouts/reports/royalty.html-pdf.liquid". Use mikser_api_list_entities with { collection: "layouts" } to discover ids.'),
|
|
525
525
|
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.'),
|
|
526
526
|
},
|
|
527
527
|
async ({ id, samples = 3 }) => {
|
|
@@ -576,13 +576,13 @@ export default ({
|
|
|
576
576
|
samples: sampleEntities,
|
|
577
577
|
notes: [
|
|
578
578
|
'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.',
|
|
579
|
-
'samples only includes entities with explicit meta.layout. Auto-matched layouts are not listed; use
|
|
579
|
+
'samples only includes entities with explicit meta.layout. Auto-matched layouts are not listed; use mikser_api_list_entities with a filename-pattern filter for those.',
|
|
580
580
|
],
|
|
581
581
|
}, null, 2),
|
|
582
582
|
}],
|
|
583
583
|
}
|
|
584
584
|
} catch (err) {
|
|
585
|
-
logger.error('MCP
|
|
585
|
+
logger.error('MCP mikser_layouts_inspect error: %s', err.message)
|
|
586
586
|
return {
|
|
587
587
|
isError: true,
|
|
588
588
|
content: [{ type: 'text', text: err.message }],
|
|
@@ -591,7 +591,7 @@ export default ({
|
|
|
591
591
|
},
|
|
592
592
|
)
|
|
593
593
|
const logger = useLogger()
|
|
594
|
-
logger.info('MCP tool registered:
|
|
594
|
+
logger.info('MCP tool registered: mikser_layouts_inspect (layouts plugin)')
|
|
595
595
|
})
|
|
596
596
|
|
|
597
597
|
return {
|
package/src/plugins/preview.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// 1. An in-memory cache (Map<filename, { bytes, mime, expiresAt, size }>)
|
|
5
5
|
// with LRU eviction past a configurable byte cap.
|
|
6
6
|
// 2. An Express GET /preview/:filename route that serves cache entries.
|
|
7
|
-
// 3. The
|
|
7
|
+
// 3. The mikser_preview_render MCP tool, registered on the substrate when
|
|
8
8
|
// --mcp is active.
|
|
9
9
|
//
|
|
10
10
|
// Lives outside the api plugin because preview is not a REST catalog
|
|
@@ -20,14 +20,14 @@
|
|
|
20
20
|
// Library-mode surface: this plugin exposes
|
|
21
21
|
// runtime.options.preview = { store, get, stats }
|
|
22
22
|
// so any plugin or programmatic caller can stash bytes and get a URL
|
|
23
|
-
// back without going through MCP. The
|
|
23
|
+
// back without going through MCP. The mikser_preview_render tool is a thin
|
|
24
24
|
// wrapper over this surface.
|
|
25
25
|
|
|
26
26
|
import path from 'node:path'
|
|
27
27
|
import { randomUUID } from 'node:crypto'
|
|
28
28
|
import { z } from 'zod'
|
|
29
29
|
import { useRenderer } from '../api.js'
|
|
30
|
-
import { mimeForEntity } from '
|
|
30
|
+
import { mimeForEntity } from '../utils.js'
|
|
31
31
|
|
|
32
32
|
export default ({
|
|
33
33
|
runtime,
|
|
@@ -138,11 +138,11 @@ export default ({
|
|
|
138
138
|
})
|
|
139
139
|
|
|
140
140
|
mcp.simpleTool(
|
|
141
|
-
'
|
|
142
|
-
'Render an entity through the engine pipeline AND surface the FINAL output as a clickable URL served by the running --server. Use this instead of
|
|
141
|
+
'mikser_preview_render',
|
|
142
|
+
'Render an entity through the engine pipeline AND surface the FINAL output as a clickable URL served by the running --server. Use this instead of mikser_api_render when the user needs to see the result in a browser. The URL serves the pipeline\'s final output — PDF for a `*.html-pdf.*` layout, MJML-derived HTML for `*.html-mjml.*`, etc. Requires --server. Previews live in memory (not on disk, never under outputFolder) and auto-expire — default 10 minutes, clamped 30..3600 seconds.',
|
|
143
143
|
{
|
|
144
|
-
entity: z.record(z.any()).describe('Entity shape with at least { id, collection } and any meta/content the renderer needs. Same shape as
|
|
145
|
-
options: z.record(z.any()).optional().describe('Renderer options. Same as
|
|
144
|
+
entity: z.record(z.any()).describe('Entity shape with at least { id, collection } and any meta/content the renderer needs. Same shape as mikser_api_render.'),
|
|
145
|
+
options: z.record(z.any()).optional().describe('Renderer options. Same as mikser_api_render, plus { expiresInSeconds: number = 600 } controlling preview TTL.'),
|
|
146
146
|
},
|
|
147
147
|
async ({ entity = {}, options = {} }) => {
|
|
148
148
|
const logger = useLogger()
|
|
@@ -156,7 +156,7 @@ export default ({
|
|
|
156
156
|
|
|
157
157
|
try {
|
|
158
158
|
if (!runtime.options.port) {
|
|
159
|
-
return fail('
|
|
159
|
+
return fail('mikser_preview_render requires --server to be running so the preview URL is reachable. Use mikser_api_render to get raw bytes inline instead.')
|
|
160
160
|
}
|
|
161
161
|
|
|
162
162
|
const { expiresInSeconds = config().defaultTtl, ...renderOptions } = options ?? {}
|
|
@@ -182,24 +182,24 @@ export default ({
|
|
|
182
182
|
const url = `http://localhost:${runtime.options.port}${cfg.path}/${filename}`
|
|
183
183
|
const bytes = Buffer.isBuffer(result) ? result.length : Buffer.byteLength(result)
|
|
184
184
|
|
|
185
|
-
logger.info('MCP
|
|
185
|
+
logger.info('MCP mikser_preview_render cached %s (%d bytes, ttl %ds): %s', filename, bytes, ttlSec, url)
|
|
186
186
|
|
|
187
187
|
return ok({
|
|
188
188
|
previewUrl: url,
|
|
189
189
|
mimeType: mime,
|
|
190
190
|
bytes,
|
|
191
191
|
expiresInSeconds: ttlSec,
|
|
192
|
-
instructions: 'Open previewUrl in a browser to view. The preview lives in mikser memory and auto-expires after expiresInSeconds — re-run
|
|
192
|
+
instructions: 'Open previewUrl in a browser to view. The preview lives in mikser memory and auto-expires after expiresInSeconds — re-run mikser_preview_render to refresh.',
|
|
193
193
|
})
|
|
194
194
|
} catch (err) {
|
|
195
|
-
logger.error('MCP
|
|
195
|
+
logger.error('MCP mikser_preview_render error: %s', err.message)
|
|
196
196
|
return fail(err.message)
|
|
197
197
|
}
|
|
198
198
|
},
|
|
199
199
|
)
|
|
200
200
|
|
|
201
201
|
const logger = useLogger()
|
|
202
|
-
logger.info('MCP tool registered:
|
|
202
|
+
logger.info('MCP tool registered: mikser_preview_render (preview plugin)')
|
|
203
203
|
})
|
|
204
204
|
|
|
205
205
|
return { name: 'preview' }
|
package/src/utils.js
CHANGED
|
@@ -6,6 +6,72 @@ import _ from 'lodash'
|
|
|
6
6
|
import { minimatch } from 'minimatch'
|
|
7
7
|
import path from 'path'
|
|
8
8
|
|
|
9
|
+
// Extension → mime type lookup for rendered outputs. Used anywhere an
|
|
10
|
+
// entity's destination is being served over HTTP (the api plugin's
|
|
11
|
+
// /render endpoint, the preview plugin's /preview route, anywhere
|
|
12
|
+
// else that produces Content-Type from an entity). Pure function; no
|
|
13
|
+
// engine state — lives here rather than inside one plugin so other
|
|
14
|
+
// plugins don't have to reach across the plugin folder for it.
|
|
15
|
+
const MIME_BY_EXT = {
|
|
16
|
+
pdf: 'application/pdf',
|
|
17
|
+
html: 'text/html; charset=utf-8',
|
|
18
|
+
xml: 'application/xml; charset=utf-8',
|
|
19
|
+
xhtml: 'application/xhtml+xml; charset=utf-8',
|
|
20
|
+
rss: 'application/rss+xml; charset=utf-8',
|
|
21
|
+
atom: 'application/atom+xml; charset=utf-8',
|
|
22
|
+
json: 'application/json; charset=utf-8',
|
|
23
|
+
css: 'text/css; charset=utf-8',
|
|
24
|
+
js: 'application/javascript; charset=utf-8',
|
|
25
|
+
svg: 'image/svg+xml',
|
|
26
|
+
png: 'image/png',
|
|
27
|
+
jpg: 'image/jpeg',
|
|
28
|
+
jpeg: 'image/jpeg',
|
|
29
|
+
webp: 'image/webp',
|
|
30
|
+
gif: 'image/gif',
|
|
31
|
+
mp4: 'video/mp4',
|
|
32
|
+
webm: 'video/webm',
|
|
33
|
+
txt: 'text/plain; charset=utf-8',
|
|
34
|
+
md: 'text/markdown; charset=utf-8',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function mimeForEntity(entity) {
|
|
38
|
+
if (!entity?.destination) return null
|
|
39
|
+
const ext = path.extname(entity.destination).toLowerCase().replace(/^\./, '')
|
|
40
|
+
return MIME_BY_EXT[ext] ?? null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// True when `ip` is a loopback address. Handles all three forms:
|
|
44
|
+
// - IPv4: anything in 127.0.0.0/8
|
|
45
|
+
// - IPv6: ::1
|
|
46
|
+
// - IPv4-mapped-in-IPv6: ::ffff:127.x.y.z (what dual-stack stacks return)
|
|
47
|
+
//
|
|
48
|
+
// Used by mikser's auth middleware to honor the "loopback connections are
|
|
49
|
+
// trusted; non-loopback connections must authenticate" rule across plugins.
|
|
50
|
+
// Pass `req.ip` rather than `req.socket.remoteAddress` — Express's req.ip
|
|
51
|
+
// walks `X-Forwarded-For` when trust proxy is configured, which is what
|
|
52
|
+
// reveals the real client through a properly-configured reverse proxy.
|
|
53
|
+
export function isLoopback(ip) {
|
|
54
|
+
if (!ip || typeof ip !== 'string') return false
|
|
55
|
+
const addr = ip.startsWith('::ffff:') ? ip.slice(7) : ip
|
|
56
|
+
if (addr === '::1') return true
|
|
57
|
+
if (addr === '127.0.0.1') return true
|
|
58
|
+
return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(addr)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Express middleware factory: 403s any request whose `req.ip` isn't
|
|
62
|
+
// loopback. Plugins use this to protect routes that should be reachable
|
|
63
|
+
// only from the local machine. Customize the response with `message`.
|
|
64
|
+
//
|
|
65
|
+
// For more nuanced policies (token gate + loopback fallback), plugins
|
|
66
|
+
// usually inline the check using isLoopback() directly — this factory is
|
|
67
|
+
// for the simple "this route is local-only, period" case.
|
|
68
|
+
export function loopbackOnly({ message = 'Endpoint accepts loopback connections only.' } = {}) {
|
|
69
|
+
return (req, res, next) => {
|
|
70
|
+
if (isLoopback(req.ip)) return next()
|
|
71
|
+
res.status(403).json({ error: message })
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
9
75
|
export class AbortError extends Error {
|
|
10
76
|
constructor(message) {
|
|
11
77
|
super();
|
package/ngrok.ymlt
DELETED