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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "7.6.1",
3
+ "version": "7.8.1",
4
4
  "description": "<p align=\"center\"> <img src=\"mikser-lockup-stacked.svg\" alt=\"mikser\" width=\"198\" /> </p>",
5
5
  "main": "index.js",
6
6
  "scripts": {
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 as /', runtime.options.outputFolder.replace(runtime.options.workingFolder + '/', ''))
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 on port %d', runtime.options.port)
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
- for (const args of registrations.tools) server.registerTool(...args)
60
- for (const args of registrations.resources) server.registerResource(...args)
61
- for (const args of registrations.prompts) server.registerPrompt(...args)
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. Called by the transport mount per new session.
99
- _createServer() {
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 mikser_preview (api
312
- // plugin), so we don't advertise a path convention here —
313
- // doing so would be a lie when the api plugin isn't loaded.
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 at the given path
319
- * (default `/mcp`). Each connecting client gets its own
320
- * McpServer + StreamableHTTPServerTransport pair (SDK constraint).
321
- * Sessions are tracked by `mcp-session-id` header initialize
322
- * requests open a new session; subsequent requests echo their id.
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, path = '/mcp') {
325
- // sessionId transport. POSTs with an existing id route back
326
- // to that transport so the SDK's session state stays consistent.
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
- // No matching session — assume a new initialize request
336
- // (the SDK will reject if it isn't actually an initialize).
337
- const server = substrate._createServer()
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
  /**
@@ -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 mikser_preview, load `preview` alongside `api`.
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
- if (!ep.token) return next()
408
- if (req.headers.authorization === `Bearer ${ep.token}`) return next()
409
- res.status(401).json({ error: 'Unauthorized' })
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
- logger.info('Api endpoint mounted: %s/%s (ops=[%s] %s)',
614
- base, name, [...allowedOps].join(','),
615
- ep.token ? '[token]' : '[public]')
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
- 'mikser_list_entities',
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 mikser_list_entities error: %s', err.message)
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
- 'mikser_read_entity',
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 (mikser_render, or fetch
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 mikser_render to materialize output or read the file directly at entity.uri.`
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 mikser_read_entity error: %s', err.message)
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
- 'mikser_update_entity',
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 mikser_update_entity error: %s', err.message)
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
- 'mikser_delete_entity',
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 mikser_delete_entity error: %s', err.message)
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
- 'mikser_render',
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 mikser_render error: %s', err.message)
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: list/read/update/delete/render (api plugin)')
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
 
@@ -506,7 +506,7 @@ export default ({
506
506
  }
507
507
  })
508
508
 
509
- // mikser_inspect_layout lives in the layouts plugin (not core)
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
- 'mikser_inspect_layout',
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 mikser_list_entities with { collection: "layouts" } to discover ids.'),
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 mikser_list_entities with a filename-pattern filter for those.',
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 mikser_inspect_layout error: %s', err.message)
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: mikser_inspect_layout (layouts plugin)')
594
+ logger.info('MCP tool registered: mikser_layouts_inspect (layouts plugin)')
595
595
  })
596
596
 
597
597
  return {
@@ -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 mikser_preview MCP tool, registered on the substrate when
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 mikser_preview tool is a thin
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
- logger.info('Preview route mounted: %s (cache cap: %d MB)', cfg.path, Math.round(cfg.maxBytes / 1024 / 1024))
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
- 'mikser_preview',
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_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.',
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 mikser_render.'),
145
- options: z.record(z.any()).optional().describe('Renderer options. Same as mikser_render, plus { expiresInSeconds: number = 600 } controlling preview TTL.'),
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('mikser_preview requires --server to be running so the preview URL is reachable. Use mikser_render to get raw bytes inline instead.')
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 mikser_preview cached %s (%d bytes, ttl %ds): %s', filename, bytes, ttlSec, url)
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 mikser_preview to refresh.',
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 mikser_preview error: %s', err.message)
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: mikser_preview (preview plugin)')
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
@@ -1,3 +0,0 @@
1
- version: 3
2
- agent:
3
- authtoken: 3EcICPaDtSN54h63Y5v8g2zKOJ9_5oMnCdXyPmMmKVXH9ba1c