mikser-io-mcp 2.0.0 → 2.1.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/README.md +31 -3
  2. package/index.js +75 -2
  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,7 +50,9 @@ 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,
56
58
  actingRole,
@@ -237,7 +239,14 @@ function wrapMutatingHandler(handler) {
237
239
  // more calls can join it, and only it knows when that stops.
238
240
  closeOnReturn: !changeSet,
239
241
  },
240
- () => handler(toolArgs, ...rest),
242
+ // The REAL principal, alongside the display string above. Those
243
+ // are two different things and the difference matters: the string
244
+ // is for the log, and asking `hasCapability` about it reads
245
+ // `undefined.capabilities`, takes the not-capability-scoped
246
+ // branch, and returns true for everything. Establishing the
247
+ // object is what lets the write primitives refuse.
248
+ () => withPrincipal(authContext.getStore()?.principal ?? null,
249
+ () => handler(toolArgs, ...rest)),
241
250
  )
242
251
  // Report the id back, whatever shape the tool's own result takes.
243
252
  //
@@ -2183,7 +2192,8 @@ export function mcp(options = {}) {
2183
2192
 
2184
2193
  mcp.simpleTool(
2185
2194
  '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'
2195
+ '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'
2196
+ + '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
2197
  + '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
2198
  + '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
2199
  + '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 +2249,69 @@ export function mcp(options = {}) {
2239
2249
  { mutates: true },
2240
2250
  )
2241
2251
 
2252
+ mcp.simpleTool(
2253
+ 'mikser_edit_entity',
2254
+ 'Change PART of an existing content file, by naming the text to change. Everything you do not name is '
2255
+ + 'left exactly as it is — byte for byte, including whitespace, line endings and anything further down '
2256
+ + 'the file you never read.\n\n'
2257
+ + 'Prefer this over mikser_update_entity for any change to an existing file. A whole-file write makes you '
2258
+ + 're-emit the entire document to change one line, and a line dropped on the way looks downstream exactly '
2259
+ + 'like a line someone deleted on purpose.\n\n'
2260
+ + '`find` must appear EXACTLY ONCE. If it appears more than once the edit is refused and the response '
2261
+ + 'says how many times: extend `find` with the surrounding lines until it is unique, or pass `all: true` '
2262
+ + 'to change every occurrence. If it appears nowhere the edit is refused too — the file is not what you '
2263
+ + 'read, so re-read it rather than guessing.\n\n'
2264
+ + 'The result must also still PARSE in the file\'s format (YAML frontmatter, a .yml data file, whatever '
2265
+ + 'the site has registered). If it would not, nothing is written and the response carries the parser\'s '
2266
+ + 'complaint. This is the check a whole-file write cannot offer.\n\n'
2267
+ + 'Copy `find` from mikser_read_entity output verbatim, including indentation. An anchor assembled from '
2268
+ + 'memory is the usual reason for a refusal.',
2269
+ {
2270
+ id: z.string().optional().describe('Catalog id of the entity to edit (e.g. "/blog/launch.md"). Alternative to collection + relativePath.'),
2271
+ collection: z.string().optional().describe('Collection name (e.g. "documents"). Required unless `id` is given.'),
2272
+ relativePath: z.string().optional().describe('Path relative to the collection folder. Required unless `id` is given.'),
2273
+ 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.'),
2274
+ 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.'),
2275
+ 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`.'),
2276
+ 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.'),
2277
+ await: z.boolean().optional().describe('Block until the cycle that picks up this edit completes, and return its build report as `report`.'),
2278
+ 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.'),
2279
+ },
2280
+ async ({ id, collection, relativePath, find, replace = '', all, ifChecksum, await: awaitCycle, dryRun }) => {
2281
+ try {
2282
+ if (!dryRun) {
2283
+ const refusal = refuseIfExpiringWithin(
2284
+ awaitCycle ? 120 : 15,
2285
+ awaitCycle ? 'an edit that then waits for a build cycle' : 'an edit')
2286
+ if (refusal) return refusal
2287
+ }
2288
+
2289
+ const result = await editEntitySource({
2290
+ id, collection, relativePath, find, replace, all, ifChecksum, dryRun, awaitCycle,
2291
+ })
2292
+
2293
+ // The anchor refusals are NOT errors. Each one carries what
2294
+ // the next attempt needs — the occurrence count, the
2295
+ // parser's complaint, the current checksum — and an error
2296
+ // string is where that goes to die. A caller that gets
2297
+ // `refused: 'anchor-ambiguous'` with `occurrences: 4` can
2298
+ // fix its own anchor; one that gets a red failure retries
2299
+ // the same call or falls back to rewriting the whole file,
2300
+ // which is the outcome this tool exists to avoid.
2301
+ const recoverable = ['anchor-not-found', 'anchor-ambiguous',
2302
+ 'would-not-parse', 'checksum-mismatch']
2303
+ if (!result.ok && !recoverable.includes(result.refused)) {
2304
+ return fail(result.error ?? result.refused)
2305
+ }
2306
+ return ok(result)
2307
+ } catch (err) {
2308
+ logger.error('MCP mikser_edit_entity error: %s', err.message)
2309
+ return fail(err.message)
2310
+ }
2311
+ },
2312
+ { mutates: true },
2313
+ )
2314
+
2242
2315
  mcp.simpleTool(
2243
2316
  'mikser_delete_entity',
2244
2317
  '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'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-mcp",
3
- "version": "2.0.0",
3
+ "version": "2.1.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,11 +42,11 @@
42
42
  "minimatch": "^10.0.3"
43
43
  },
44
44
  "peerDependencies": {
45
- "mikser-io": "^10.0.0",
45
+ "mikser-io": "^10.14.0",
46
46
  "zod": "^4.0.0"
47
47
  },
48
48
  "devDependencies": {
49
- "mikser-io": "file:../mikser-io",
49
+ "mikser-io": "^10.14.0",
50
50
  "zod": "^4.0.0"
51
51
  }
52
52
  }