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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "7.6.0",
3
+ "version": "7.8.0",
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.
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,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
  /**
@@ -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
- // MIME type lookup used when streaming a postprocessor's output back over
206
- // HTTP. The renderer's output extension lives on entity.destination
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 mikser_preview, load `preview` alongside `api`.
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
- if (!ep.token) return next()
435
- if (req.headers.authorization === `Bearer ${ep.token}`) return next()
436
- 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
+ })
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
- logger.info('Api endpoint mounted: %s/%s (ops=[%s] %s)',
641
- base, name, [...allowedOps].join(','),
642
- 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
+ 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
- 'mikser_list_entities',
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 mikser_list_entities error: %s', err.message)
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
- 'mikser_read_entity',
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 (mikser_render, or fetch
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 mikser_render to materialize output or read the file directly at entity.uri.`
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 mikser_read_entity error: %s', err.message)
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
- 'mikser_update_entity',
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 mikser_update_entity error: %s', err.message)
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
- 'mikser_delete_entity',
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 mikser_delete_entity error: %s', err.message)
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
- 'mikser_render',
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 mikser_render error: %s', err.message)
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: list/read/update/delete/render (api plugin)')
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
 
@@ -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,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 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'
27
27
  import { randomUUID } from 'node:crypto'
28
28
  import { z } from 'zod'
29
29
  import { useRenderer } from '../api.js'
30
- import { mimeForEntity } from './api.js'
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
- '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.',
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 mikser_render.'),
145
- options: z.record(z.any()).optional().describe('Renderer options. Same as mikser_render, plus { expiresInSeconds: number = 600 } controlling preview TTL.'),
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('mikser_preview requires --server to be running so the preview URL is reachable. Use mikser_render to get raw bytes inline instead.')
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 mikser_preview cached %s (%d bytes, ttl %ds): %s', filename, bytes, ttlSec, url)
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 mikser_preview to refresh.',
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 mikser_preview error: %s', err.message)
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: mikser_preview (preview plugin)')
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
@@ -1,3 +0,0 @@
1
- version: 3
2
- agent:
3
- authtoken: 3EcICPaDtSN54h63Y5v8g2zKOJ9_5oMnCdXyPmMmKVXH9ba1c