mikser-io-mcp 2.0.0 → 11.0.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.
Files changed (3) hide show
  1. package/README.md +31 -3
  2. package/index.js +80 -5
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -7,7 +7,7 @@ MCP (Model Context Protocol) substrate and tools for [mikser-io](https://github.
7
7
  - **The MCP substrate** — `createMcpSubstrate`, per-session McpServer + transport via `mountMcpOnExpress`, the pino-to-MCP log bridge `wireLoggerToMcp`. Other plugins compose against `runtime.options.mcp` to register their own tools and resources.
8
8
  - **Built-in resources** — `mikser://config`, `mikser://lifecycle`, `mikser://logs`, `mikser://server`. Read-only introspection any MCP client can use.
9
9
  - **Built-in tools**
10
- - *Catalog* — `mikser_query_entities`, `mikser_read_entity`, `mikser_update_entity`, `mikser_delete_entity`, `mikser_render`, over the engine's public catalog API.
10
+ - *Catalog* — `mikser_query_entities`, `mikser_read_entity`, `mikser_edit_entity`, `mikser_update_entity`, `mikser_delete_entity`, `mikser_render`, over the engine's public catalog API.
11
11
  - *Finding things* — `mikser_search` locates a string across entity meta, source files, and (with `in: ["output"]`) the BUILT files, reporting occurrences per page — and with `attribute: true`, which source emitted each hit. That is how you find content you can only describe by what it says, and how you size a change to anything shared before making it.
12
12
  - *Working backwards* — `mikser_which` takes a built destination and returns the source that produced it: the field path and line/column a value was written at, or the line in file content where it appears — each occurrence flagged by whether the string BEGINS its line, which separates a declaration from a use in any text format without a per-language grammar. Each answer is labelled by how it was reached — `meta-field` and `source-content` are RECORDED (the engine's own `refClosure` says this render consumed that entity, and the position comes from parsing its source), `scan` is not. It reaches values that appear nowhere in a page's own document, which is most of a shared nav or footer.
13
13
  - *References* — `mikser_refs_inbound` / `mikser_refs_outbound` / `mikser_refs_broken` / `mikser_refs_rename`, from `runtime.refs`.
@@ -40,8 +40,36 @@ piping into `jq` works; exit status is 0 / 1 (the tool reported an error) /
40
40
 
41
41
  ## Editing content
42
42
 
43
- `mikser_update_entity` writes the WHOLE file — there is no partial-edit or
44
- patch mode. Three fields make that safe to do without a shell on the box:
43
+ Two tools write source files, and which one you reach for is the whole point.
44
+
45
+ `mikser_edit_entity` changes PART of a file: you name the text to replace and
46
+ everything else is left exactly as it is, byte for byte. Use it for any change
47
+ to an existing file.
48
+
49
+ ```js
50
+ await mikser_edit_entity({
51
+ id: '/documents/pricing.md',
52
+ find: 'price: 1200', // must match exactly once
53
+ replace: 'price: 1400',
54
+ })
55
+ ```
56
+
57
+ `find` must appear exactly once, or the edit is refused and told how many times
58
+ it appeared — extend it with the surrounding lines, or pass `all: true` for a
59
+ rename that really should hit every one. An anchor that appears nowhere is
60
+ refused too: the file is not what you read. And the RESULT must still parse in
61
+ the file's format — a broken YAML block is caught before it lands, which is the
62
+ check a whole-file write has no way to make. None of those come back as
63
+ errors; each is a result carrying what the next attempt needs.
64
+
65
+ This exists because rewriting a whole file to change one line means re-emitting
66
+ every other line, and a line dropped on the way looks downstream exactly like a
67
+ line someone deleted on purpose. `ifChecksum` catches a stale read; nothing
68
+ catches a lossy write.
69
+
70
+ `mikser_update_entity` writes the WHOLE file. Use it to CREATE a file, or when
71
+ you are genuinely replacing most of one. Three fields make that safe to do
72
+ without a shell on the box:
45
73
 
46
74
  ```js
47
75
  const page = await mikser_read_entity({ id: '/styles/tokens/buttons.css', include: ['content', 'positions'] })
package/index.js CHANGED
@@ -50,9 +50,12 @@ import {
50
50
  cycleHistory,
51
51
  checksum as fileChecksumOf,
52
52
  writeEntitySource,
53
+ editEntitySource,
53
54
  withChangeSet,
55
+ withPrincipal,
54
56
  describeAuthority,
55
57
  inventory,
58
+ loadedPlugins,
56
59
  actingRole,
57
60
  explainRefusal,
58
61
  findChangeSet,
@@ -237,7 +240,14 @@ function wrapMutatingHandler(handler) {
237
240
  // more calls can join it, and only it knows when that stops.
238
241
  closeOnReturn: !changeSet,
239
242
  },
240
- () => handler(toolArgs, ...rest),
243
+ // The REAL principal, alongside the display string above. Those
244
+ // are two different things and the difference matters: the string
245
+ // is for the log, and asking `hasCapability` about it reads
246
+ // `undefined.capabilities`, takes the not-capability-scoped
247
+ // branch, and returns true for everything. Establishing the
248
+ // object is what lets the write primitives refuse.
249
+ () => withPrincipal(authContext.getStore()?.principal ?? null,
250
+ () => handler(toolArgs, ...rest)),
241
251
  )
242
252
  // Report the id back, whatever shape the tool's own result takes.
243
253
  //
@@ -751,7 +761,7 @@ function stalePackages(workingFolder) {
751
761
  substrate.registerTool(
752
762
  'mikser_ping',
753
763
  {
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.',
764
+ 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`loaded` is what this runtime is actually RUNNING — recorded as each plugin loads, not worked out afterwards from what it happens to expose. Every loaded plugin is in it. `package` names the one it came from; `package: null` means the plugin is running and did not say what it is, which is NOT the same as not running and must not be read as it. Nothing is ever missing from this list because it could not be detected.\n\n`installed` is a different question: every mikser package on disk with its purpose, version and links — what the site COULD use, including what it is not using. A package in `installed` and absent from `loaded` is not loaded.\n\nRead `loaded` to know what the system does, and `installed` to know what it could do. Earlier versions merged the two into one list with an `active` flag whose absence meant either \'not running\' or \'could not tell\' — it reported git sync and schema validation as off while both were running.\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.',
755
765
  inputSchema: {},
756
766
  },
757
767
  async () => ({
@@ -784,7 +794,8 @@ function stalePackages(workingFolder) {
784
794
  // `active` marks the ones actually running: a package in
785
795
  // node_modules that no config loads explains nothing about
786
796
  // this site's behaviour.
787
- plugins: inventory(),
797
+ loaded: loadedPlugins(),
798
+ installed: inventory(),
788
799
  server: serverInfo(),
789
800
  // Whether this connection is authenticated and for how
790
801
  // much longer, so a long task can be sequenced rather
@@ -2183,7 +2194,8 @@ export function mcp(options = {}) {
2183
2194
 
2184
2195
  mcp.simpleTool(
2185
2196
  'mikser_update_entity',
2186
- 'Create or update a content file inside a mikser collection. Writes the WHOLE file — there is no partial-edit or patch mode, so send the complete intended contents. The file lands on disk and the next lifecycle cycle picks it up.\n\n'
2197
+ 'Create or update a content file inside a mikser collection. Writes the WHOLE file: send the complete intended contents, because anything you leave out is deleted.\n\n'
2198
+ + 'Use this to CREATE a file, or when you are rewriting most of one. To change part of an existing file use mikser_edit_entity instead — it changes only the text you name, so a long file cannot lose a line you did not mean to touch.\n\n'
2187
2199
  + 'Pass `ifChecksum` with the checksum you got from mikser_read_entity to make the write conditional: if the file has changed since you read it the write is REFUSED and the response carries `currentChecksum`, so a blind whole-file rewrite cannot silently discard someone else\'s edit. Without it the write is unconditional.\n\n'
2188
2200
  + 'The response returns the resulting `checksum` (pass it as the next `ifChecksum`), the `cycleId` your write will be picked up by, and `siblingDestinations` when another file could render to the same place (e.g. index.md beside index.yml — whichever renders last wins and the other output is discarded).\n\n'
2189
2201
  + 'Pass `await: true` to block until that cycle finishes and get its build report back, so one call tells you what your edit invalidated instead of writing and guessing.',
@@ -2239,6 +2251,69 @@ export function mcp(options = {}) {
2239
2251
  { mutates: true },
2240
2252
  )
2241
2253
 
2254
+ mcp.simpleTool(
2255
+ 'mikser_edit_entity',
2256
+ 'Change PART of an existing content file, by naming the text to change. Everything you do not name is '
2257
+ + 'left exactly as it is — byte for byte, including whitespace, line endings and anything further down '
2258
+ + 'the file you never read.\n\n'
2259
+ + 'Prefer this over mikser_update_entity for any change to an existing file. A whole-file write makes you '
2260
+ + 're-emit the entire document to change one line, and a line dropped on the way looks downstream exactly '
2261
+ + 'like a line someone deleted on purpose.\n\n'
2262
+ + '`find` must appear EXACTLY ONCE. If it appears more than once the edit is refused and the response '
2263
+ + 'says how many times: extend `find` with the surrounding lines until it is unique, or pass `all: true` '
2264
+ + 'to change every occurrence. If it appears nowhere the edit is refused too — the file is not what you '
2265
+ + 'read, so re-read it rather than guessing.\n\n'
2266
+ + 'The result must also still PARSE in the file\'s format (YAML frontmatter, a .yml data file, whatever '
2267
+ + 'the site has registered). If it would not, nothing is written and the response carries the parser\'s '
2268
+ + 'complaint. This is the check a whole-file write cannot offer.\n\n'
2269
+ + 'Copy `find` from mikser_read_entity output verbatim, including indentation. An anchor assembled from '
2270
+ + 'memory is the usual reason for a refusal.',
2271
+ {
2272
+ id: z.string().optional().describe('Catalog id of the entity to edit (e.g. "/blog/launch.md"). Alternative to collection + relativePath.'),
2273
+ collection: z.string().optional().describe('Collection name (e.g. "documents"). Required unless `id` is given.'),
2274
+ relativePath: z.string().optional().describe('Path relative to the collection folder. Required unless `id` is given.'),
2275
+ find: z.string().describe('The EXACT text to replace, copied from the file. Must match once (or pass `all`). Include enough surrounding lines to be unique — a bare word usually is not.'),
2276
+ replace: z.string().optional().describe('The text to put in its place. Omit or pass "" to DELETE the matched text. Match the surrounding indentation; nothing reindents it for you.'),
2277
+ all: z.boolean().optional().describe('Replace every occurrence instead of refusing an ambiguous one. Use for a rename that really should hit all of them; the response reports `replacements`.'),
2278
+ ifChecksum: z.string().optional().describe('Precondition: only edit if the file\'s current DISK checksum equals this. Use `diskChecksum` from mikser_read_entity. Optional here — the anchor is itself a check on the file being what you read.'),
2279
+ await: z.boolean().optional().describe('Block until the cycle that picks up this edit completes, and return its build report as `report`.'),
2280
+ dryRun: z.boolean().optional().describe('Write NOTHING, but still resolve the anchor: refusals are reported exactly as they would be. Returns `wouldAffect` — every destination that would re-render.'),
2281
+ },
2282
+ async ({ id, collection, relativePath, find, replace = '', all, ifChecksum, await: awaitCycle, dryRun }) => {
2283
+ try {
2284
+ if (!dryRun) {
2285
+ const refusal = refuseIfExpiringWithin(
2286
+ awaitCycle ? 120 : 15,
2287
+ awaitCycle ? 'an edit that then waits for a build cycle' : 'an edit')
2288
+ if (refusal) return refusal
2289
+ }
2290
+
2291
+ const result = await editEntitySource({
2292
+ id, collection, relativePath, find, replace, all, ifChecksum, dryRun, awaitCycle,
2293
+ })
2294
+
2295
+ // The anchor refusals are NOT errors. Each one carries what
2296
+ // the next attempt needs — the occurrence count, the
2297
+ // parser's complaint, the current checksum — and an error
2298
+ // string is where that goes to die. A caller that gets
2299
+ // `refused: 'anchor-ambiguous'` with `occurrences: 4` can
2300
+ // fix its own anchor; one that gets a red failure retries
2301
+ // the same call or falls back to rewriting the whole file,
2302
+ // which is the outcome this tool exists to avoid.
2303
+ const recoverable = ['anchor-not-found', 'anchor-ambiguous',
2304
+ 'would-not-parse', 'checksum-mismatch']
2305
+ if (!result.ok && !recoverable.includes(result.refused)) {
2306
+ return fail(result.error ?? result.refused)
2307
+ }
2308
+ return ok(result)
2309
+ } catch (err) {
2310
+ logger.error('MCP mikser_edit_entity error: %s', err.message)
2311
+ return fail(err.message)
2312
+ }
2313
+ },
2314
+ { mutates: true },
2315
+ )
2316
+
2242
2317
  mcp.simpleTool(
2243
2318
  'mikser_delete_entity',
2244
2319
  'Remove a content file from a mikser collection. Deletes the source file; the next lifecycle cycle prunes its rendered outputs from the manifest.\n\n'
@@ -2327,6 +2402,6 @@ export function mcp(options = {}) {
2327
2402
  logger.debug('MCP tools registered: mikser_{query_entities,read_entity,update_entity,delete_entity,render} (mcp plugin)')
2328
2403
  })
2329
2404
 
2330
- return { name: 'mcp' }
2405
+ return { name: 'mcp', module: import.meta.url }
2331
2406
  }
2332
2407
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-mcp",
3
- "version": "2.0.0",
3
+ "version": "11.0.1",
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,11 +42,11 @@
42
42
  "minimatch": "^10.0.3"
43
43
  },
44
44
  "peerDependencies": {
45
- "mikser-io": "^10.0.0",
45
+ "mikser-io": "^11.0.1",
46
46
  "zod": "^4.0.0"
47
47
  },
48
48
  "devDependencies": {
49
- "mikser-io": "file:../mikser-io",
49
+ "mikser-io": "^11.0.1",
50
50
  "zod": "^4.0.0"
51
51
  }
52
52
  }