mikser-io-mcp 1.22.0 → 2.0.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.
Files changed (3) hide show
  1. package/index.js +66 -25
  2. package/package.json +2 -2
  3. package/preview.js +10 -8
package/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // MCP substrate for mikser-io. The mcp plugin exposes
2
- // `runtime.options.mcp` (a substrate object) at factory time so other
2
+ // the 'mcp' service (a substrate object) at factory time so other
3
3
  // plugins can register tools / resources / prompts against it at their
4
4
  // onLoaded hook with the same shape as the SDK's McpServer.
5
5
  //
@@ -44,6 +44,7 @@ import {
44
44
  explain,
45
45
  buildReport,
46
46
  requestReport,
47
+ faults,
47
48
  nextCycleId,
48
49
  whenCycleCompletes,
49
50
  cycleHistory,
@@ -62,6 +63,8 @@ import {
62
63
  useProvenance,
63
64
  sourcesBehind,
64
65
  sourcesOf,
66
+ provideService,
67
+ useService,
65
68
  registerTool as coreRegisterTool,
66
69
  toolNames as coreToolNames,
67
70
  toolSchema as coreToolSchema,
@@ -244,7 +247,7 @@ function wrapMutatingHandler(handler) {
244
247
  // here so every mutating tool answers the same way, and only when the
245
248
  // set actually recorded something: an id for a call that wrote
246
249
  // nothing is a handle to nothing.
247
- return attachChangeSet(result, id)
250
+ return await attachChangeSet(result, id)
248
251
  }
249
252
  }
250
253
 
@@ -258,15 +261,15 @@ function actingPrincipal() {
258
261
  // everything else agree about who is calling.
259
262
  const principal = authContext.getStore()?.principal
260
263
  if (!principal) return 'agent'
261
- const role = actingRole(principal.roles ?? [], runtime.options.roles?.catalogue ?? {})
264
+ const role = actingRole(principal.roles ?? [], useService('roles')?.catalogue ?? {})
262
265
  const subject = principal.subject ?? 'agent'
263
266
  return role ? `${subject} (${role})` : subject
264
267
  }
265
268
 
266
269
  // Fold the id into an MCP result's JSON text block, leaving anything else —
267
270
  // an image, an error, a non-JSON body — exactly as the tool produced it.
268
- function attachChangeSet(result, id) {
269
- if (!findChangeSet(id)) return result
271
+ async function attachChangeSet(result, id) {
272
+ if (!await findChangeSet(id)) return result
270
273
  const content = result?.content
271
274
  if (result?.isError || !Array.isArray(content)) return result
272
275
  return {
@@ -332,11 +335,24 @@ export function createMcpSubstrate() {
332
335
  const schema = coreToolSchema(name)
333
336
  if (!schema) continue
334
337
  try {
335
- server.registerTool(
336
- exposed,
337
- { description: schema.description, inputSchema: zodShapeFrom(schema.inputSchema) },
338
- (args) => coreInvokeTool(name, args),
339
- )
338
+ // normalizeInputSchema, not zodShapeFrom: a plugin registering
339
+ // against the engine may describe its parameters EITHER in the
340
+ // engine's neutral vocabulary or in zod. zodShapeFrom assumes
341
+ // neutral, so a real zod shape came through it as a bag of
342
+ // optional strings — every constraint dropped, no error, and a
343
+ // tool that looked registered but refused nothing.
344
+ let inputSchema = normalizeInputSchema(schema.inputSchema)
345
+ let handler = (args) => coreInvokeTool(name, args)
346
+ // `mutates: true` reaches here now that the engine registry
347
+ // carries the whole definition. Without this a mutating tool
348
+ // registered against the engine would take no changeSet, and
349
+ // its writes would be unattributable — the one failure nothing
350
+ // downstream can repair.
351
+ if (schema.mutates) {
352
+ inputSchema = withChangeSetParams(inputSchema)
353
+ handler = wrapMutatingHandler(handler)
354
+ }
355
+ server.registerTool(exposed, { description: schema.description, inputSchema }, handler)
340
356
  bound.tools++
341
357
  } catch (err) {
342
358
  runtime.engine?.logger?.debug(
@@ -735,7 +751,7 @@ function stalePackages(workingFolder) {
735
751
  substrate.registerTool(
736
752
  'mikser_ping',
737
753
  {
738
- description: 'Return mikser engine identity, current lifecycle phase, and (if --server is on) where the HTTP server is reachable. Use to confirm the connection is live before issuing other tool calls and to learn the base URL for preview outputs.\n\n`plugins` lists what this mikser is built from — every installed mikser package with its purpose, version and links, and `active: true` on the ones actually running. Read it once to know what the system can do before reasoning about what it should.\n\nCheck `stale` before trusting any other tool, and before reporting a bug: it lists mikser packages installed SINCE this process booted, whose code is therefore not the code answering you. A running process never re-reads node_modules, and --watch does not change that — it reloads content, not dependencies. When `stale` is non-empty the fix is a restart, not a bug report.\n\nThe `auth` block names the ROLE this session acts as, what it may write and what it may only read — and `auth.roles` lists EVERY role on this site with the same reach, the acting one marked. That is INFORMATIONAL: report what you cannot do and stop. There is no way to request or change a role and none will be added — the listing names a person to ask, not a privilege to obtain.',
754
+ description: 'Return mikser engine identity, current lifecycle phase, and (if --server is on) where the HTTP server is reachable. Use to confirm the connection is live before issuing other tool calls and to learn the base URL for preview outputs.\n\n`plugins` lists what this mikser is built from — every installed mikser package with its purpose, version and links, and `active: true` on the ones actually running. Read it once to know what the system can do before reasoning about what it should.\n\nCheck `faults` before reading any empty result as a fact about the site: it is absent unless a subsystem has reported that it cannot work, and while it is present a tool may be answering emptily because it is unable to answer rather than because there is nothing to say. Each entry names the condition, when it was first and last seen, and how often — report it and stop rather than working around it.\n\nCheck `stale` before trusting any other tool, and before reporting a bug: it lists mikser packages installed SINCE this process booted, whose code is therefore not the code answering you. A running process never re-reads node_modules, and --watch does not change that — it reloads content, not dependencies. When `stale` is non-empty the fix is a restart, not a bug report.\n\nThe `auth` block names the ROLE this session acts as, what it may write and what it may only read — and `auth.roles` lists EVERY role on this site with the same reach, the acting one marked. That is INFORMATIONAL: report what you cannot do and stop. There is no way to request or change a role and none will be added — the listing names a person to ask, not a privilege to obtain.',
739
755
  inputSchema: {},
740
756
  },
741
757
  async () => ({
@@ -752,6 +768,13 @@ function stalePackages(workingFolder) {
752
768
  // Empty is the normal case and means what it says: the code
753
769
  // answering you is the code on disk.
754
770
  ...stalePackages(runtime.options.workingFolder),
771
+ // Subsystems that have reported themselves unable to
772
+ // work. Absent is the normal case, and the reason this is
773
+ // here at all: a tool answering [] because it is BROKEN
774
+ // and one answering [] because nothing matched are the
775
+ // same answer, and the log line that said which is a
776
+ // channel an agent never reads.
777
+ ...faultStatus(),
755
778
  // What this mikser is made of. An agent can otherwise see
756
779
  // what it may write and nothing about the machine doing
757
780
  // the writing — and a capability like `drive:layouts`
@@ -820,8 +843,8 @@ function authStatus() {
820
843
  const authority = describeAuthority({
821
844
  capabilities: principal.capabilities,
822
845
  roles: principal.roles ?? [],
823
- catalogue: runtime.options.roles?.catalogue ?? {},
824
- summaries: runtime.options.roles?.summaries ?? {},
846
+ catalogue: useService('roles')?.catalogue ?? {},
847
+ summaries: useService('roles')?.summaries ?? {},
825
848
  })
826
849
  const exp = principal.claims?.exp
827
850
  if (!exp) {
@@ -1047,6 +1070,17 @@ async function readIfText(uri) {
1047
1070
  // Kept as a function rather than a const so each call re-reads
1048
1071
  // runtime.options — covers the case where --server flips on after
1049
1072
  // the substrate was created (rare but possible programmatically).
1073
+ // Subsystems that have reported themselves unable to work, if any.
1074
+ //
1075
+ // Omitted entirely when there are none, so presence is meaningful and a
1076
+ // healthy ping stays the short document it was. `last` is the field that says
1077
+ // whether a fault is still live: one seen only at boot and one firing every
1078
+ // cycle read the same by presence alone.
1079
+ function faultStatus() {
1080
+ const open = faults()
1081
+ return open.length ? { faults: open } : {}
1082
+ }
1083
+
1050
1084
  function serverInfo() {
1051
1085
  const opts = runtime.options
1052
1086
  const hasInternalServer = opts.server != null && opts.port != null
@@ -1478,7 +1512,7 @@ function pinoLevelNumber(name) {
1478
1512
  //
1479
1513
  // The factory creates the substrate SYNCHRONOUSLY so other plugins
1480
1514
  // listed AFTER 'mcp' in the array can register tools/resources at
1481
- // their own onLoaded hook with `if (!runtime.options.mcp) return`
1515
+ // their own onLoaded hook by asking core for the 'mcp' service
1482
1516
  // gating already in place from the in-core era. The plugin MUST be
1483
1517
  // FIRST in the user's plugins array — that's a documented invariant
1484
1518
  // in this repo's README.
@@ -1520,12 +1554,19 @@ export function mcp(options = {}) {
1520
1554
  add(runtime.options.corsExposeHeaders, ['mcp-session-id', 'mcp-protocol-version'])
1521
1555
  }
1522
1556
 
1523
- // Create substrate synchronously. Other plugins' factories /
1524
- // onLoaded hooks check `if (!runtime.options.mcp) return` before
1525
- // calling `mcp.simpleTool(...)` — so the substrate has to be on
1526
- // runtime.options.mcp by the time those run. The mcp plugin MUST
1527
- // be FIRST in the user's plugins array for that contract to hold.
1528
- runtime.options.mcp = createMcpSubstrate()
1557
+ // Offered as a service rather than assigned to runtime.options.mcp.
1558
+ //
1559
+ // Tools do not need this at all any more — a plugin registers those
1560
+ // against the engine's registry and this package binds them into each
1561
+ // session. What is left is genuinely MCP's: resources and prompts, which
1562
+ // core deliberately has no vocabulary for.
1563
+ //
1564
+ // A consumer asks core for it from a hook, by which time every factory
1565
+ // has run — which retires the rule this comment used to carry, that the
1566
+ // mcp plugin MUST be FIRST in the user's plugins array. Nothing enforced
1567
+ // that; put it second and the tools silently vanished.
1568
+ const substrate = createMcpSubstrate()
1569
+ provideService('mcp', substrate, { plugin: 'mikser-io-mcp' })
1529
1570
  // mikser_build_report needs the cycle recorded, and recording is off
1530
1571
  // unless a reader asks for it — see report.js requestReport.
1531
1572
  requestReport()
@@ -1546,7 +1587,7 @@ export function mcp(options = {}) {
1546
1587
  // reference (held by plugins, render workers, useLogger
1547
1588
  // consumers) gains the side-channel automatically.
1548
1589
  if (runtime.engine?.logger) {
1549
- wireLoggerToMcp(runtime.engine.logger, runtime.options.mcp)
1590
+ wireLoggerToMcp(runtime.engine.logger, substrate)
1550
1591
  logger.debug('MCP logger wiring active')
1551
1592
  }
1552
1593
 
@@ -1556,7 +1597,7 @@ export function mcp(options = {}) {
1556
1597
  if (runtime.options.app && runtime.options.mcpPath) {
1557
1598
  await mountMcpOnExpress(
1558
1599
  runtime.options.app,
1559
- runtime.options.mcp,
1600
+ substrate,
1560
1601
  runtime.options.mcpPath,
1561
1602
  )
1562
1603
  }
@@ -1571,7 +1612,7 @@ export function mcp(options = {}) {
1571
1612
  // are silently skipped.
1572
1613
  onLoaded(() => {
1573
1614
  const logger = useLogger()
1574
- const mcp = runtime.options.mcp
1615
+ const mcp = substrate
1575
1616
  if (!runtime.refs) {
1576
1617
  logger.debug('mikser_refs_* tools skipped: runtime.refs not initialized')
1577
1618
  return
@@ -1976,7 +2017,7 @@ export function mcp(options = {}) {
1976
2017
  })
1977
2018
 
1978
2019
  // mikser_layouts_inspect moved out — registered by mikser-io-layouts
1979
- // directly against `runtime.options.mcp`, same pattern vector and
2020
+ // directly against the substrate, same pattern vector and
1980
2021
  // schemas use. Domain-owned tools live with the domain plugin; mcp
1981
2022
  // is registry + transport, not a tool catalog.
1982
2023
 
@@ -1998,7 +2039,7 @@ export function mcp(options = {}) {
1998
2039
  //
1999
2040
  onLoaded(() => {
2000
2041
  const logger = useLogger()
2001
- const mcp = runtime.options.mcp
2042
+ const mcp = substrate
2002
2043
 
2003
2044
  const { render: mcpRender } = useRenderer(runtime, {
2004
2045
  defaultTimeout: options.renderTimeout ?? 30_000,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-mcp",
3
- "version": "1.22.0",
3
+ "version": "2.0.0",
4
4
  "description": "MCP (Model Context Protocol) substrate and tools for mikser-io. Extracted from core to iterate on its own release cadence.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -42,7 +42,7 @@
42
42
  "minimatch": "^10.0.3"
43
43
  },
44
44
  "peerDependencies": {
45
- "mikser-io": "^9.53.0",
45
+ "mikser-io": "^10.0.0",
46
46
  "zod": "^4.0.0"
47
47
  },
48
48
  "devDependencies": {
package/preview.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // the in-memory preview cache.
8
8
  //
9
9
  // The cache itself lives in mikser-io's preview plugin
10
- // (runtime.options.preview.{store, get, stats, config}). This module
10
+ // (the `preview` service: {store, get, stats, config}). This module
11
11
  // reaches into it via that surface — no cross-plugin imports.
12
12
 
13
13
  import path from 'node:path'
@@ -15,7 +15,7 @@ import { readFileSync } from 'node:fs'
15
15
  import { fileURLToPath } from 'node:url'
16
16
  import { randomUUID, createHmac } from 'node:crypto'
17
17
  import { z } from 'zod'
18
- import { useRenderer, mimeForEntity, matchEntity } from 'mikser-io'
18
+ import { useRenderer, mimeForEntity, matchEntity, useService } from 'mikser-io'
19
19
 
20
20
  // Resolve a path relative to this module's file. Used to find the
21
21
  // public/ folder regardless of where the package is installed (works
@@ -122,7 +122,7 @@ const PREVIEW_UI_SHELL_HTML = readFileSync(path.join(__dirname, "public", "previ
122
122
  // Plugin function — invoked by mikser-io-mcp/index.js's factory after
123
123
  // the substrate is set up. Registers the MCP-UI surface (shell + modes
124
124
  // resource + preview_ui + ui_action) AND the preview-render tool (which
125
- // reaches into runtime.options.preview for the cache).
125
+ // asks core for the preview service).
126
126
  //
127
127
  // Not a default export plugin in the mikser sense — this is internal
128
128
  // composition. The mcp plugin's index.js is what mikser loads; this
@@ -135,15 +135,17 @@ export default ({
135
135
  findEntities,
136
136
  }) => {
137
137
  onLoaded(() => {
138
- if (!runtime.options.mcp) return
139
- const mcp = runtime.options.mcp
138
+ // Asked for from a hook, so every factory has run — including this
139
+ // package's own, which provides it.
140
+ const mcp = useService('mcp')
141
+ if (!mcp) return
140
142
  const { render: previewRender } = useRenderer(runtime, {
141
143
  defaultTimeout: runtime.config.preview?.renderTimeout ?? 30_000,
142
144
  })
143
145
 
144
146
  // mikser_preview_render — render an entity through the pipeline,
145
147
  // stash the bytes in the in-memory preview cache (provided by
146
- // mikser-io core's preview plugin via runtime.options.preview),
148
+ // mikser-io core's preview plugin, offered as the `preview` service),
147
149
  // and return a clickable URL. Requires --server (or any
148
150
  // engine-supplied Express app) so the URL is reachable.
149
151
  mcp.simpleTool(
@@ -155,7 +157,7 @@ export default ({
155
157
  },
156
158
  async ({ entity = {}, options = {} }) => {
157
159
  const logger = useLogger()
158
- const preview = runtime.options.preview
160
+ const preview = useService('preview')
159
161
  const ok = (data) => ({
160
162
  content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
161
163
  })
@@ -176,7 +178,7 @@ export default ({
176
178
  return fail('mikser_preview_render requires either --url <public-url> or --server to be set so the preview URL is reachable. Use mikser_render to get raw bytes inline instead.')
177
179
  }
178
180
  if (!preview) {
179
- return fail('mikser_preview_render requires the preview cache. Ensure mikser-io core is loaded and runtime.options.preview is available.')
181
+ return fail('mikser_preview_render requires the preview cache. Ensure the preview plugin from mikser-io core is in your plugins array.')
180
182
  }
181
183
 
182
184
  const cfg = preview.config()