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.
- package/README.md +31 -3
- package/index.js +80 -5
- 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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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`
|
|
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
|
-
|
|
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
|
|
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": "
|
|
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": "^
|
|
45
|
+
"mikser-io": "^11.0.1",
|
|
46
46
|
"zod": "^4.0.0"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
-
"mikser-io": "
|
|
49
|
+
"mikser-io": "^11.0.1",
|
|
50
50
|
"zod": "^4.0.0"
|
|
51
51
|
}
|
|
52
52
|
}
|