mikser-io 7.6.1 → 7.8.1
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 +33 -3
- package/src/mcp.js +153 -21
- package/src/plugins/api.js +53 -22
- package/src/plugins/layouts.js +6 -6
- package/src/plugins/preview.js +17 -12
- package/src/utils.js +32 -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.
|
|
@@ -222,11 +250,13 @@ export async function setup(options) {
|
|
|
222
250
|
// plugin's router falls through to the static handler and
|
|
223
251
|
// gets served from <outputFolder>.
|
|
224
252
|
runtime.options.app.use(express.static(runtime.options.outputFolder))
|
|
225
|
-
logger.info('Serving %s
|
|
253
|
+
logger.info('Serving %s at http://localhost:%d/',
|
|
254
|
+
runtime.options.outputFolder.replace(runtime.options.workingFolder + '/', ''),
|
|
255
|
+
runtime.options.port)
|
|
226
256
|
|
|
227
257
|
await new Promise(resolve => {
|
|
228
258
|
runtime.options.app.listen(runtime.options.port, () => {
|
|
229
|
-
logger.info('Server listening
|
|
259
|
+
logger.info('Server listening: http://localhost:%d', runtime.options.port)
|
|
230
260
|
resolve()
|
|
231
261
|
})
|
|
232
262
|
})
|
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,32 @@ 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
|
+
// Print as a full URL when we know the port so the operator can
|
|
477
|
+
// copy/click straight from the log. Falls back to bare path for
|
|
478
|
+
// external-app setups where the engine doesn't own the listener.
|
|
479
|
+
const location = runtime.options.port
|
|
480
|
+
? `http://localhost:${runtime.options.port}${path}`
|
|
481
|
+
: path
|
|
482
|
+
if (endpointName) {
|
|
483
|
+
logger.info('MCP endpoint mounted: %s (tools=[%s] [%s])', location, toolsLabel, authLabel)
|
|
484
|
+
} else {
|
|
485
|
+
logger.info('MCP mounted: %s [%s]', location, authLabel)
|
|
486
|
+
}
|
|
487
|
+
}
|
|
356
488
|
}
|
|
357
489
|
|
|
358
490
|
/**
|
package/src/plugins/api.js
CHANGED
|
@@ -5,7 +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 } from '../utils.js'
|
|
8
|
+
import { mimeForEntity, isLoopback } from '../utils.js'
|
|
9
9
|
|
|
10
10
|
// Mongo-style operators recognised in URL query params as `<path>.$<op>=...`.
|
|
11
11
|
// $in / $nin take comma-separated values; $exists takes a truthy/falsy
|
|
@@ -309,7 +309,7 @@ export default ({
|
|
|
309
309
|
// Preview workflow (render → cache → URL) lives in its own
|
|
310
310
|
// plugin (src/plugins/preview.js) as of v7.3.0. The api plugin
|
|
311
311
|
// stays focused on REST catalog access; preview is a separate
|
|
312
|
-
// domain. To use
|
|
312
|
+
// domain. To use mikser_preview_render, load `preview` alongside `api`.
|
|
313
313
|
|
|
314
314
|
// cachedEndpoints is hoisted above (shared with onFinalize).
|
|
315
315
|
// Per-endpoint setup loop pushes into it when cache: true is set.
|
|
@@ -403,10 +403,33 @@ export default ({
|
|
|
403
403
|
}
|
|
404
404
|
const cacheEnabled = ep.cache === true
|
|
405
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
|
|
406
417
|
const auth = (req, res, next) => {
|
|
407
|
-
|
|
408
|
-
if (
|
|
409
|
-
|
|
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
|
+
})
|
|
410
433
|
}
|
|
411
434
|
|
|
412
435
|
const allow = (op) => (req, res, next) => {
|
|
@@ -610,9 +633,17 @@ export default ({
|
|
|
610
633
|
})
|
|
611
634
|
|
|
612
635
|
app.use(`${base}/${name}`, router)
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
+
// Full URL when the engine owns the listener (port is known).
|
|
641
|
+
// Falls back to the path alone for external-app setups.
|
|
642
|
+
const location = runtime.options.port
|
|
643
|
+
? `http://localhost:${runtime.options.port}${base}/${name}`
|
|
644
|
+
: `${base}/${name}`
|
|
645
|
+
logger.info('Api endpoint mounted: %s (ops=[%s] [%s])',
|
|
646
|
+
location, [...allowedOps].join(','), authLabel)
|
|
616
647
|
}
|
|
617
648
|
|
|
618
649
|
// MCP tool registrations. MCP is in-process: whoever can reach
|
|
@@ -637,7 +668,7 @@ export default ({
|
|
|
637
668
|
})
|
|
638
669
|
|
|
639
670
|
mcp.simpleTool(
|
|
640
|
-
'
|
|
671
|
+
'mikser_api_list_entities',
|
|
641
672
|
'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.',
|
|
642
673
|
{
|
|
643
674
|
filter: z.record(z.any()).optional().describe('Mongo-style filter (sift-compatible). Defaults to no filter — every entity.'),
|
|
@@ -665,14 +696,14 @@ export default ({
|
|
|
665
696
|
hasNext: effectiveSkip + effectiveLimit < total,
|
|
666
697
|
})
|
|
667
698
|
} catch (err) {
|
|
668
|
-
logger.error('MCP
|
|
699
|
+
logger.error('MCP mikser_api_list_entities error: %s', err.message)
|
|
669
700
|
return fail(err.message)
|
|
670
701
|
}
|
|
671
702
|
},
|
|
672
703
|
)
|
|
673
704
|
|
|
674
705
|
mcp.simpleTool(
|
|
675
|
-
'
|
|
706
|
+
'mikser_api_read_entity',
|
|
676
707
|
'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.',
|
|
677
708
|
{
|
|
678
709
|
id: z.string().describe('Catalog id of the entity to read.'),
|
|
@@ -694,7 +725,7 @@ export default ({
|
|
|
694
725
|
// Heuristic — read content only for text-like
|
|
695
726
|
// formats. Binary types (png, pdf, mp4, etc.)
|
|
696
727
|
// get a marker so the caller knows to use a
|
|
697
|
-
// different tool (
|
|
728
|
+
// different tool (mikser_api_render, or fetch
|
|
698
729
|
// directly) rather than expecting bytes back.
|
|
699
730
|
const TEXT_EXTS = new Set([
|
|
700
731
|
'md', 'markdown', 'html', 'htm', 'xhtml',
|
|
@@ -713,20 +744,20 @@ export default ({
|
|
|
713
744
|
entity.contentError = err.message
|
|
714
745
|
}
|
|
715
746
|
} else {
|
|
716
|
-
entity.contentSkipped = `Non-text format (.${ext}). Use
|
|
747
|
+
entity.contentSkipped = `Non-text format (.${ext}). Use mikser_api_render to materialize output or read the file directly at entity.uri.`
|
|
717
748
|
}
|
|
718
749
|
}
|
|
719
750
|
|
|
720
751
|
return ok(entity)
|
|
721
752
|
} catch (err) {
|
|
722
|
-
logger.error('MCP
|
|
753
|
+
logger.error('MCP mikser_api_read_entity error: %s', err.message)
|
|
723
754
|
return fail(err.message)
|
|
724
755
|
}
|
|
725
756
|
},
|
|
726
757
|
)
|
|
727
758
|
|
|
728
759
|
mcp.simpleTool(
|
|
729
|
-
'
|
|
760
|
+
'mikser_api_update_entity',
|
|
730
761
|
'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.',
|
|
731
762
|
{
|
|
732
763
|
collection: z.string().describe('Collection name (e.g. "documents", "layouts").'),
|
|
@@ -738,14 +769,14 @@ export default ({
|
|
|
738
769
|
await useCollection(runtime, collection).write(relativePath, content)
|
|
739
770
|
return ok({ ok: true, collection, relativePath })
|
|
740
771
|
} catch (err) {
|
|
741
|
-
logger.error('MCP
|
|
772
|
+
logger.error('MCP mikser_api_update_entity error: %s', err.message)
|
|
742
773
|
return fail(err.message)
|
|
743
774
|
}
|
|
744
775
|
},
|
|
745
776
|
)
|
|
746
777
|
|
|
747
778
|
mcp.simpleTool(
|
|
748
|
-
'
|
|
779
|
+
'mikser_api_delete_entity',
|
|
749
780
|
'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.',
|
|
750
781
|
{
|
|
751
782
|
collection: z.string().describe('Collection name.'),
|
|
@@ -756,15 +787,15 @@ export default ({
|
|
|
756
787
|
await useCollection(runtime, collection).remove(relativePath)
|
|
757
788
|
return ok({ ok: true, collection, relativePath })
|
|
758
789
|
} catch (err) {
|
|
759
|
-
logger.error('MCP
|
|
790
|
+
logger.error('MCP mikser_api_delete_entity error: %s', err.message)
|
|
760
791
|
return fail(err.message)
|
|
761
792
|
}
|
|
762
793
|
},
|
|
763
794
|
)
|
|
764
795
|
|
|
765
796
|
mcp.simpleTool(
|
|
766
|
-
'
|
|
767
|
-
'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.',
|
|
797
|
+
'mikser_api_render',
|
|
798
|
+
'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).',
|
|
768
799
|
{
|
|
769
800
|
entity: z.record(z.any()).describe('Entity shape with at least { id, collection } and any meta/content the renderer needs.'),
|
|
770
801
|
options: z.record(z.any()).optional().describe('Renderer options: { save: false, catalog: false, renderer: "...", postprocessor: "..." }.'),
|
|
@@ -801,13 +832,13 @@ export default ({
|
|
|
801
832
|
}],
|
|
802
833
|
}
|
|
803
834
|
} catch (err) {
|
|
804
|
-
logger.error('MCP
|
|
835
|
+
logger.error('MCP mikser_api_render error: %s', err.message)
|
|
805
836
|
return fail(err.message)
|
|
806
837
|
}
|
|
807
838
|
},
|
|
808
839
|
)
|
|
809
840
|
|
|
810
|
-
logger.info('MCP tools registered:
|
|
841
|
+
logger.info('MCP tools registered: mikser_api_{list_entities,read_entity,update_entity,delete_entity,render} (api plugin)')
|
|
811
842
|
}
|
|
812
843
|
})
|
|
813
844
|
|
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,7 +20,7 @@
|
|
|
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'
|
|
@@ -124,7 +124,12 @@ export default ({
|
|
|
124
124
|
}
|
|
125
125
|
res.type(entry.mime).send(entry.bytes)
|
|
126
126
|
})
|
|
127
|
-
|
|
127
|
+
// Full URL when port is known; bare path otherwise. The /:filename
|
|
128
|
+
// segment is left off — it's filled at request time per preview.
|
|
129
|
+
const location = runtime.options.port
|
|
130
|
+
? `http://localhost:${runtime.options.port}${cfg.path}`
|
|
131
|
+
: cfg.path
|
|
132
|
+
logger.info('Preview route mounted: %s (cache cap: %d MB)', location, Math.round(cfg.maxBytes / 1024 / 1024))
|
|
128
133
|
})
|
|
129
134
|
|
|
130
135
|
// Gating on runtime.options.mcp inside onLoaded matches the route-
|
|
@@ -138,11 +143,11 @@ export default ({
|
|
|
138
143
|
})
|
|
139
144
|
|
|
140
145
|
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
|
|
146
|
+
'mikser_preview_render',
|
|
147
|
+
'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
148
|
{
|
|
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
|
|
149
|
+
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.'),
|
|
150
|
+
options: z.record(z.any()).optional().describe('Renderer options. Same as mikser_api_render, plus { expiresInSeconds: number = 600 } controlling preview TTL.'),
|
|
146
151
|
},
|
|
147
152
|
async ({ entity = {}, options = {} }) => {
|
|
148
153
|
const logger = useLogger()
|
|
@@ -156,7 +161,7 @@ export default ({
|
|
|
156
161
|
|
|
157
162
|
try {
|
|
158
163
|
if (!runtime.options.port) {
|
|
159
|
-
return fail('
|
|
164
|
+
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
165
|
}
|
|
161
166
|
|
|
162
167
|
const { expiresInSeconds = config().defaultTtl, ...renderOptions } = options ?? {}
|
|
@@ -182,24 +187,24 @@ export default ({
|
|
|
182
187
|
const url = `http://localhost:${runtime.options.port}${cfg.path}/${filename}`
|
|
183
188
|
const bytes = Buffer.isBuffer(result) ? result.length : Buffer.byteLength(result)
|
|
184
189
|
|
|
185
|
-
logger.info('MCP
|
|
190
|
+
logger.info('MCP mikser_preview_render cached %s (%d bytes, ttl %ds): %s', filename, bytes, ttlSec, url)
|
|
186
191
|
|
|
187
192
|
return ok({
|
|
188
193
|
previewUrl: url,
|
|
189
194
|
mimeType: mime,
|
|
190
195
|
bytes,
|
|
191
196
|
expiresInSeconds: ttlSec,
|
|
192
|
-
instructions: 'Open previewUrl in a browser to view. The preview lives in mikser memory and auto-expires after expiresInSeconds — re-run
|
|
197
|
+
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
198
|
})
|
|
194
199
|
} catch (err) {
|
|
195
|
-
logger.error('MCP
|
|
200
|
+
logger.error('MCP mikser_preview_render error: %s', err.message)
|
|
196
201
|
return fail(err.message)
|
|
197
202
|
}
|
|
198
203
|
},
|
|
199
204
|
)
|
|
200
205
|
|
|
201
206
|
const logger = useLogger()
|
|
202
|
-
logger.info('MCP tool registered:
|
|
207
|
+
logger.info('MCP tool registered: mikser_preview_render (preview plugin)')
|
|
203
208
|
})
|
|
204
209
|
|
|
205
210
|
return { name: 'preview' }
|
package/src/utils.js
CHANGED
|
@@ -40,6 +40,38 @@ export function mimeForEntity(entity) {
|
|
|
40
40
|
return MIME_BY_EXT[ext] ?? null
|
|
41
41
|
}
|
|
42
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
|
+
|
|
43
75
|
export class AbortError extends Error {
|
|
44
76
|
constructor(message) {
|
|
45
77
|
super();
|
package/ngrok.ymlt
DELETED