mikser-io-drive 0.9.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/lib/files.js ADDED
@@ -0,0 +1,746 @@
1
+ // File operations over MCP, for an agent that has no route to the host.
2
+ //
3
+ // WebDAV itself costs nothing in tokens, but it needs the caller to be able to
4
+ // reach the server and to have something to speak it with. Neither holds for
5
+ // every agent: a sandboxed one may have no egress, and a desktop one may have
6
+ // no shell to run a client from. For those, bytes have to travel the connection
7
+ // that already works — the MCP session itself. A person with a mounted share is
8
+ // still the better answer for anything large, and the caps say so.
9
+ //
10
+ // So these carry base64 through the tool. That is expensive and bounded, and
11
+ // the bound is stated rather than discovered: above the cap they refuse and say
12
+ // where the file belongs instead of failing blankly.
13
+ //
14
+ // Nothing here is media-specific. An endpoint is whatever the deployment
15
+ // configured — media, files, styles, js — and what a stored file becomes is
16
+ // whatever that deployment's pipeline makes of it. The response reports what
17
+ // was actually stamped rather than assuming: a served entity gets a `reference`
18
+ // from its meta.url, one that is not served gets its catalog id, and derived
19
+ // variants appear only where presets matched.
20
+ //
21
+ // Page TEXT is not in scope. update_entity owns that: it is checksum-guarded,
22
+ // previews blast radius, returns the build report and raises the spec-locked
23
+ // advisory. Writing a document through here would lose all four.
24
+
25
+ import path from 'node:path'
26
+ import { z } from 'zod'
27
+ import { readFile, writeFile, mkdir, rename, stat, unlink } from 'node:fs/promises'
28
+ import {
29
+ runtime, readEntity, findEntities, nextCycleId, whenCycleCompletes, checksum,
30
+ } from 'mikser-io'
31
+
32
+ const ok = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] })
33
+ const fail = (message) => ({ isError: true, content: [{ type: 'text', text: message }] })
34
+
35
+ // Bounds, stated in the tool description so a caller can decide before
36
+ // spending. These are what mikser refuses; the practical limit is the caller's
37
+ // own context, which mikser cannot see and does not pretend to.
38
+ //
39
+ // Sized against real content: a p90 image on the site this was built for is
40
+ // 146KB, so 2MB is an order of magnitude of headroom for one file, and 8MB is
41
+ // a batch nobody reaches by accident. A video is not close, and should not be —
42
+ // it belongs on a mount.
43
+ const MAX_FILE_BYTES = 2 * 1024 * 1024
44
+ const MAX_BATCH_BYTES = 8 * 1024 * 1024
45
+
46
+ // Returned as an image the model can actually look at, rather than as bytes it
47
+ // cannot. Everything else comes back described.
48
+ const VIEWABLE = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp'])
49
+ const MAX_VIEWABLE_BYTES = 1024 * 1024
50
+
51
+ const TEXTUAL = /^(text\/|application\/(json|xml|javascript|x-yaml|yaml))/
52
+
53
+ function mimeFor(name, given) {
54
+ if (given) return given
55
+ const ext = path.extname(name).slice(1).toLowerCase()
56
+ return {
57
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
58
+ webp: 'image/webp', svg: 'image/svg+xml', avif: 'image/avif',
59
+ pdf: 'application/pdf', mp4: 'video/mp4', webm: 'video/webm',
60
+ css: 'text/css', js: 'text/javascript', json: 'application/json',
61
+ yml: 'application/x-yaml', yaml: 'application/x-yaml',
62
+ md: 'text/markdown', txt: 'text/plain', html: 'text/html',
63
+ }[ext] ?? 'application/octet-stream'
64
+ }
65
+
66
+ // A name that cannot escape the folder it was given.
67
+ //
68
+ // `..` in a supplied filename is the oldest write primitive there is, and this
69
+ // writes wherever the caller says. Rejected rather than sanitised: a caller
70
+ // that meant `logo.png` is not helped by silently getting `logo.png` from
71
+ // `../../logo.png`, and one that meant to escape should be told no.
72
+ function safeSegment(value, what) {
73
+ const v = String(value ?? '').trim()
74
+ if (!v) return { error: `${what} is required` }
75
+ if (v.startsWith('/') || path.isAbsolute(v)) return { error: `${what} must be relative, got ${JSON.stringify(v)}` }
76
+ const parts = v.split(/[\\/]+/).filter(Boolean)
77
+ if (parts.some(p => p === '..' || p === '.')) {
78
+ return { error: `${what} must not contain . or .. — got ${JSON.stringify(v)}` }
79
+ }
80
+ if (parts.some(p => p.startsWith('.'))) {
81
+ return { error: `${what} must not contain dotfiles — got ${JSON.stringify(v)}` }
82
+ }
83
+ return { value: parts.join('/') }
84
+ }
85
+
86
+ // Where a file is, given whatever form the caller had to hand.
87
+ //
88
+ // Three forms reach here and all of them are reasonable things to be holding:
89
+ // the served URL another tool reported (`/media/x.png`), the catalog id
90
+ // (`/files/x.png`), or an endpoint plus a relative path. Resolving all three
91
+ // beats making a caller convert between them.
92
+ async function locate(pathish, endpoints, workingFolder) {
93
+ const wanted = String(pathish ?? '').trim()
94
+ if (!wanted) return { error: 'path is required' }
95
+
96
+ const rootOf = (ep) => (path.isAbsolute(ep.folder) ? ep.folder : path.join(workingFolder, ep.folder))
97
+
98
+ // The SOURCE file for an entity, which is not what `entity.uri` holds.
99
+ //
100
+ // Measured on real data: a files-plugin entity has
101
+ // `uri = <working>/out/media/x.png` — the built COPY — while its `name` is
102
+ // `media/x.png`. Operating on the uri would delete or move the output,
103
+ // which the next build simply recreates, so the caller sees success and
104
+ // nothing changes. The source is found by matching the entity's name
105
+ // against each endpoint's folder instead.
106
+ const sourceOf = (entity) => {
107
+ if (!entity) return null
108
+ for (const [name, ep] of Object.entries(endpoints)) {
109
+ const root = rootOf(ep)
110
+ // uri already inside an endpoint — the ordinary case for a
111
+ // collection that is not copied to an output folder.
112
+ if (entity.uri && !path.relative(root, entity.uri).startsWith('..')) {
113
+ return { file: entity.uri, endpoint: name }
114
+ }
115
+ // Otherwise reconstruct from `name`, which is folder-relative.
116
+ const prefix = `${ep.folder}/`
117
+ if (entity.name?.startsWith(prefix)) {
118
+ return { file: path.join(root, entity.name.slice(prefix.length)), endpoint: name }
119
+ }
120
+ }
121
+ return null
122
+ }
123
+
124
+ const byId = await readEntity({ id: wanted })
125
+ if (byId) {
126
+ const src = sourceOf(byId)
127
+ if (src) return { entity: byId, file: src.file, endpoint: src.endpoint }
128
+ return { error: `${wanted} exists but its source file is not inside any configured endpoint `
129
+ + `(${Object.keys(endpoints).join(', ')}), so this will not touch it.` }
130
+ }
131
+
132
+ const served = (await findEntities({ 'meta.url': wanted })) ?? []
133
+ if (served.length > 1) {
134
+ return { error: `${wanted} is the served URL of ${served.length} entities: `
135
+ + `${served.map(e => e.id).join(', ')}. Use a catalog id instead.` }
136
+ }
137
+ if (served.length === 1) {
138
+ const src = sourceOf(served[0])
139
+ if (src) return { entity: served[0], file: src.file, endpoint: src.endpoint }
140
+ }
141
+
142
+ // endpoint/relative form — always unambiguous, and the fallback when the
143
+ // catalog cannot place it.
144
+ const [head, ...rest] = wanted.replace(/^\/+/, '').split('/')
145
+ const ep = endpoints[head]
146
+ if (ep && rest.length) {
147
+ const rel = safeSegment(rest.join('/'), 'path')
148
+ if (rel.error) return { error: rel.error }
149
+ return { entity: served[0] ?? null, file: path.join(rootOf(ep), rel.value), endpoint: head }
150
+ }
151
+ return { error: `Nothing at ${JSON.stringify(wanted)}. Give a catalog id, a served URL, `
152
+ + `or <endpoint>/<path> where endpoint is one of: ${Object.keys(endpoints).join(', ')}` }
153
+ }
154
+
155
+ // What the pipeline made of a stored file, read back after the cycle.
156
+ //
157
+ // Reported rather than assumed. A served entity carries meta.url and that is
158
+ // the string to paste into a document; one that is not served — a stylesheet, a
159
+ // script — has no URL and its catalog id is the thing to reference. Derived
160
+ // variants appear only where a preset matched, which is a property of the
161
+ // deployment's config and not of any folder name.
162
+ async function describeStored(id) {
163
+ const entity = await readEntity({ id })
164
+ if (!entity) {
165
+ return { id, imported: false,
166
+ note: 'Written to disk but not in the catalog yet — no source plugin claims this folder, '
167
+ + 'or the cycle has not imported it.' }
168
+ }
169
+ const presets = entity.meta?.presets
170
+ return {
171
+ id: entity.id,
172
+ imported: true,
173
+ reference: entity.meta?.url ?? entity.id,
174
+ referenceKind: entity.meta?.url ? 'served URL' : 'catalog id (this entity is not served)',
175
+ ...(presets ? { derived: presets } : {}),
176
+ ...(presets ? {} : { derivedNote: 'No preset matched this file, so no variants were produced.' }),
177
+ }
178
+ }
179
+
180
+ // Wait for a cycle, but not forever.
181
+ //
182
+ // A build cycle follows a write under --watch. Without a watcher there is no
183
+ // cycle to follow, and `whenCycleCompletes` then returns a promise that never
184
+ // settles — which hangs the call AFTER the bytes have landed, leaving the
185
+ // caller with neither a result nor an error. Bounded instead, and the response
186
+ // says which happened, because "stored, references unknown" is a real answer
187
+ // and silence is not.
188
+ const CYCLE_WAIT_MS = 30_000
189
+
190
+ async function settledCycle(cycleId) {
191
+ // Without a watcher NOTHING will pick the write up, so there is no cycle
192
+ // to wait for and waiting the full timeout is pure delay — measured, it
193
+ // added 30s to every call and pushed a test run past five minutes.
194
+ // Answered immediately instead, and honestly.
195
+ if (!runtime.options?.watch) {
196
+ return {
197
+ completed: false,
198
+ note: 'Stored, but this server is not watching for changes, so nothing will import them until a build '
199
+ + 'runs. Catalog entries and derived variants are unknown until then.',
200
+ }
201
+ }
202
+ let timer
203
+ const timeout = new Promise(resolve => {
204
+ timer = setTimeout(() => resolve({
205
+ completed: false,
206
+ note: `The files are stored, but no build cycle completed within ${CYCLE_WAIT_MS / 1000}s, so their `
207
+ + 'catalog entries and derived variants are not known yet. A server running without --watch never '
208
+ + 'runs one. Read a file back with mikser_drive_read once a build has run.',
209
+ }), CYCLE_WAIT_MS)
210
+ })
211
+ try {
212
+ const report = await Promise.race([
213
+ whenCycleCompletes(cycleId).then(r => ({ completed: true, ...r })),
214
+ timeout,
215
+ ])
216
+ return report
217
+ } catch (err) {
218
+ // A report-and-exit invocation refuses the wait outright. The files
219
+ // still landed, and saying so beats a bare failure.
220
+ return { completed: false, note: `${err.message} The files are stored regardless.` }
221
+ } finally {
222
+ clearTimeout(timer)
223
+ }
224
+ }
225
+
226
+ export function registerFileTools({ runtime: rt, endpoints, capabilityOf, writeCapabilityOf, logger }) {
227
+ const mcp = rt.options.mcp
228
+ if (!mcp?.registerTool) return
229
+
230
+ const workingFolder = () => rt.options.workingFolder
231
+ const folderOf = (name) => {
232
+ const ep = endpoints[name]
233
+ if (!ep) return null
234
+ return path.isAbsolute(ep.folder) ? ep.folder : path.join(workingFolder(), ep.folder)
235
+ }
236
+ const holds = (principal, capability) => {
237
+ const held = principal?.capabilities
238
+ // Not capability-scoped — a static token or a loopback caller. The
239
+ // endpoint's own gate still applies at the transport; this cannot
240
+ // narrow further without guessing.
241
+ if (held == null) return true
242
+ return held.includes(capability)
243
+ }
244
+
245
+ // ── add ──────────────────────────────────────────────────────────────
246
+ mcp.registerTool(
247
+ 'mikser_drive_add',
248
+ {
249
+ description:
250
+ 'Store one or more FILES by sending their bytes as base64 through this call. For an agent with no '
251
+ + 'network route to the host — a sandbox with no egress, a desktop client with no shell — this is the '
252
+ + 'channel that works, because it rides the MCP connection you already have.\n\n'
253
+ + 'It costs tokens in proportion to the bytes: roughly 1.4 tokens per byte of file. Ten typical images '
254
+ + `is cheap; a video is not. Per file ${Math.round(MAX_FILE_BYTES / 1024 / 1024)}MB and per batch `
255
+ + `${Math.round(MAX_BATCH_BYTES / 1024 / 1024)}MB, above which this refuses rather than burning `
256
+ + 'your context. Something that big belongs on a WebDAV mount, placed by a person — every '
257
+ + 'endpoint here is reachable that way with ordinary credentials, and the bytes then cost nothing.\n\n'
258
+ + 'Send the whole batch in ONE call: it is written together and picked up by a single build cycle, so '
259
+ + 'you get one cycleId rather than one rebuild per file.\n\n'
260
+ + 'Each stored file comes back with the reference string to paste into a document, and the derived '
261
+ + 'variants any preset produced. This never writes page text — update_entity owns that, and a raw '
262
+ + 'write here would lose its checksum guard, blast radius, build report and spec-locked advisory.',
263
+ // zod here rather than the engine's neutral vocabulary: `files` is
264
+ // an array of OBJECTS, which that vocabulary cannot express — and
265
+ // teaching it to would make it the schema language the engine has
266
+ // no business owning.
267
+ inputSchema: {
268
+ endpoint: z.string()
269
+ .describe('Which configured endpoint to store into. A wrong name is refused with the list of real ones.'),
270
+ files: z.array(z.object({
271
+ name: z.string().describe('Filename, relative to `folder`. May not escape it.'),
272
+ base64: z.string().describe('The file content, base64-encoded.'),
273
+ mime: z.string().optional().describe('Content type. Derived from the extension when omitted.'),
274
+ })).min(1).describe('The batch. Sent together so one build cycle picks them all up.'),
275
+ folder: z.string().optional()
276
+ .describe('Subfolder within the endpoint, e.g. "devices/hera". Omit to store at its root.'),
277
+ overwrite: z.boolean().optional()
278
+ .describe('Replace a file that already exists. Default false — an existing file is reported and refused rather than silently replaced.'),
279
+ dryRun: z.boolean().optional()
280
+ .describe('Write nothing. Reports where each file would land, whether it would overwrite, and what it would cost.'),
281
+ },
282
+ },
283
+ async ({ endpoint, files, folder, overwrite = false, dryRun = false } = {}) => {
284
+ const principal = mcp.principal?.() ?? null
285
+ const root = folderOf(endpoint)
286
+ if (!root) {
287
+ return fail(`No endpoint named ${JSON.stringify(endpoint)}. `
288
+ + `Configured: ${Object.keys(endpoints).join(', ') || '(none)'}`)
289
+ }
290
+ if (endpoints[endpoint].readOnly === true) {
291
+ return fail(`${endpoint} is configured readOnly, so nothing can be stored there.`)
292
+ }
293
+ if (!holds(principal, writeCapabilityOf(endpoint))) {
294
+ return fail(`Refused: you do not hold ${writeCapabilityOf(endpoint)}. Nothing was written.`)
295
+ }
296
+ if (!Array.isArray(files) || !files.length) return fail('files must be a non-empty array')
297
+
298
+ const sub = folder ? safeSegment(folder, 'folder') : { value: '' }
299
+ if (sub.error) return fail(sub.error)
300
+
301
+ // Decode and check EVERYTHING before writing anything. A batch that
302
+ // fails halfway leaves the caller unable to say what landed.
303
+ const staged = []
304
+ let total = 0
305
+ for (const [i, f] of files.entries()) {
306
+ const named = safeSegment(f?.name, `files[${i}].name`)
307
+ if (named.error) return fail(named.error)
308
+ if (typeof f?.base64 !== 'string' || !f.base64) {
309
+ return fail(`files[${i}] (${named.value}) has no base64 content`)
310
+ }
311
+ let bytes
312
+ try {
313
+ bytes = Buffer.from(f.base64, 'base64')
314
+ } catch {
315
+ return fail(`files[${i}] (${named.value}) is not valid base64`)
316
+ }
317
+ if (!bytes.length) return fail(`files[${i}] (${named.value}) decoded to zero bytes`)
318
+ if (bytes.length > MAX_FILE_BYTES) {
319
+ return fail(`files[${i}] (${named.value}) is ${(bytes.length / 1024 / 1024).toFixed(1)}MB, over the `
320
+ + `${Math.round(MAX_FILE_BYTES / 1024 / 1024)}MB per-file cap. Nothing was written. A `
321
+ + `file this size belongs on a WebDAV mount rather than in a tool call — the same `
322
+ + `folder is reachable over WebDAV at /drive/${endpoint} with ordinary credentials.`)
323
+ }
324
+ total += bytes.length
325
+ if (total > MAX_BATCH_BYTES) {
326
+ return fail(`The batch exceeds the ${Math.round(MAX_BATCH_BYTES / 1024 / 1024)}MB cap at `
327
+ + `files[${i}] (${named.value}). Nothing was written. Send fewer per call, or place `
328
+ + `them on a WebDAV mount at /drive/${endpoint} instead.`)
329
+ }
330
+ const relative = sub.value ? `${sub.value}/${named.value}` : named.value
331
+ const file = path.join(root, relative)
332
+ let exists = false
333
+ try { await stat(file); exists = true } catch { /* new */ }
334
+ staged.push({
335
+ name: named.value, relative, file, bytes,
336
+ mime: mimeFor(named.value, f.mime),
337
+ size: bytes.length,
338
+ exists,
339
+ })
340
+ }
341
+
342
+ const collisions = staged.filter(s => s.exists)
343
+ if (collisions.length && !overwrite && !dryRun) {
344
+ return fail(`${collisions.length} file(s) already exist and overwrite is false: `
345
+ + `${collisions.map(s => s.relative).join(', ')}. Nothing was written. Pass overwrite: true to replace them.`)
346
+ }
347
+
348
+ if (dryRun) {
349
+ return ok({
350
+ dryRun: true, endpoint, folder: sub.value || null,
351
+ totalBytes: total,
352
+ approxTokens: Math.round(total * 1.4 / 4),
353
+ files: staged.map(s => ({
354
+ name: s.relative, size: s.size, mime: s.mime,
355
+ wouldOverwrite: s.exists,
356
+ path: path.relative(workingFolder(), s.file),
357
+ })),
358
+ ...(collisions.length && !overwrite ? {
359
+ blocked: `${collisions.length} of these exist and would be refused without overwrite: true`,
360
+ } : {}),
361
+ note: 'Nothing was written. Reference strings and derived variants are only known after a real '
362
+ + 'write, because they come from what the pipeline makes of the file.',
363
+ })
364
+ }
365
+
366
+ // One cycle for the whole batch, claimed before the first write so
367
+ // the watcher's debounce coalesces them all into it.
368
+ const cycleId = nextCycleId()
369
+ const written = []
370
+ for (const s of staged) {
371
+ await mkdir(path.dirname(s.file), { recursive: true })
372
+ await writeFile(s.file, s.bytes)
373
+ written.push(s)
374
+ }
375
+ logger?.info?.('drive: stored %d file(s) in %s for %j (%d bytes)',
376
+ written.length, endpoint, principal?.subject ?? 'anonymous', total)
377
+
378
+ // Bounded, because a cycle is not guaranteed to arrive. A server
379
+ // without --watch never runs one, and awaiting it forever would
380
+ // hang the call after the bytes had already landed — the caller
381
+ // left holding neither a result nor an error. Measured: this hung
382
+ // outright before the race was added.
383
+ const report = await settledCycle(cycleId)
384
+
385
+ const results = []
386
+ for (const s of written) {
387
+ const stored = {
388
+ name: s.relative,
389
+ path: path.relative(workingFolder(), s.file),
390
+ size: s.size,
391
+ mime: s.mime,
392
+ replaced: s.exists,
393
+ checksum: await checksum(s.file).catch(() => null),
394
+ }
395
+ // Identify what the catalog made of it.
396
+ //
397
+ // By `name`, not by `uri`: a files-plugin entity's uri is the
398
+ // built copy under the output folder, so matching the source
399
+ // path against it never hits. `name` is folder-relative and
400
+ // indexed, which makes this one indexed lookup per file rather
401
+ // than a scan.
402
+ const found = (await findEntities({ name: `${endpoints[endpoint].folder}/${s.relative}` })) ?? []
403
+ Object.assign(stored, found.length === 1
404
+ ? await describeStored(found[0].id)
405
+ : { imported: false, note: found.length
406
+ ? `Ambiguous: ${found.length} entities claim this file.`
407
+ : 'Written to disk but not in the catalog — no source plugin claims this folder, or the '
408
+ + 'cycle did not import it.' })
409
+ results.push(stored)
410
+ }
411
+
412
+ return ok({
413
+ endpoint, folder: sub.value || null,
414
+ cycleId,
415
+ stored: results.length,
416
+ totalBytes: total,
417
+ files: results,
418
+ ...(report.completed ? {
419
+ cycle: { id: report.cycleId ?? cycleId, rendered: report.summary?.rendered ?? null,
420
+ errors: report.summary?.errors ?? null, warnings: report.summary?.warnings ?? null },
421
+ } : { cycleNote: report.note }),
422
+ next: 'Paste a `reference` into a document with update_entity to use it. `derived` lists the variants '
423
+ + 'a preset produced, if any matched.',
424
+ })
425
+ },
426
+ )
427
+
428
+ // ── read ─────────────────────────────────────────────────────────────
429
+ mcp.registerTool(
430
+ 'mikser_drive_read',
431
+ {
432
+ description:
433
+ 'Read a stored FILE back — by catalog id, by served URL, or as <endpoint>/<path>.\n\n'
434
+ + 'An image comes back as an image you can actually look at, not as a refusal or as bytes you cannot '
435
+ + 'reason about. A text file comes back as text. Anything else comes back described — size, type, '
436
+ + 'where it is — because a utf8 read of a font is convincing garbage.\n\n'
437
+ + 'This reads the SOURCE file. mikser_read_output reads what was built and deployed, which is a '
438
+ + 'different question.',
439
+ inputSchema: {
440
+ path: z.string()
441
+ .describe('Catalog id ("/files/x.png"), served URL ("/media/x.png"), or "<endpoint>/<path>".'),
442
+ },
443
+ },
444
+ async ({ path: pathish } = {}) => {
445
+ const found = await locate(pathish, endpoints, workingFolder())
446
+ if (found.error) return fail(found.error)
447
+
448
+ let info
449
+ try {
450
+ info = await stat(found.file)
451
+ } catch {
452
+ return fail(`Nothing on disk at ${path.relative(workingFolder(), found.file)}.`)
453
+ }
454
+ const entity = found.entity
455
+ const mime = mimeFor(found.file, entity?.meta?.mime)
456
+ const meta = {
457
+ path: path.relative(workingFolder(), found.file),
458
+ size: info.size,
459
+ mime,
460
+ ...(entity ? {
461
+ id: entity.id,
462
+ reference: entity.meta?.url ?? entity.id,
463
+ ...(entity.meta?.presets ? { derived: entity.meta.presets } : {}),
464
+ } : { note: 'Not in the catalog — read straight from disk.' }),
465
+ }
466
+
467
+ if (VIEWABLE.has(mime)) {
468
+ if (info.size > MAX_VIEWABLE_BYTES) {
469
+ return ok({ ...meta, viewed: false,
470
+ note: `Too large to return as an image (${(info.size / 1024 / 1024).toFixed(1)}MB over the `
471
+ + `${Math.round(MAX_VIEWABLE_BYTES / 1024 / 1024)}MB cap). Its derived variants, if any, `
472
+ + 'are smaller and listed above.' })
473
+ }
474
+ const bytes = await readFile(found.file)
475
+ // An image block and a text block: the picture to look at, and
476
+ // the facts about it, which an image alone cannot carry.
477
+ return {
478
+ content: [
479
+ { type: 'image', data: bytes.toString('base64'), mimeType: mime },
480
+ { type: 'text', text: JSON.stringify({ ...meta, viewed: true }, null, 2) },
481
+ ],
482
+ }
483
+ }
484
+
485
+ if (TEXTUAL.test(mime)) {
486
+ const text = await readFile(found.file, 'utf8')
487
+ return ok({ ...meta, content: text, contentComplete: true })
488
+ }
489
+
490
+ return ok({ ...meta, viewed: false,
491
+ note: `Not a viewable image or a text format, so the bytes are not returned — a utf8 read of a ${mime} `
492
+ + 'is convincing garbage. The facts above are what there is.' })
493
+ },
494
+ )
495
+
496
+ // ── delete ───────────────────────────────────────────────────────────
497
+ mcp.registerTool(
498
+ 'mikser_drive_delete',
499
+ {
500
+ description:
501
+ 'Remove a stored FILE. It is moved to a trash folder under the runtime directory, never unlinked — a '
502
+ + 'delete that turns out to be wrong is then a move back rather than a restore from backup.\n\n'
503
+ + 'REFUSED when anything still references it, listing every (entity, field) that would break. Pass '
504
+ + 'force: true to delete anyway, which leaves those references pointing at nothing.\n\n'
505
+ + 'What it can see: values stored in entity META, including inside arrays. It cannot see a reference '
506
+ + 'written in a document BODY, or one a layout builds at render time — so an empty list means '
507
+ + '"nothing of the kind I look at", not "nothing at all". Use mikser_search({ in: ["content"] }) for '
508
+ + 'body text.',
509
+ inputSchema: {
510
+ path: z.string().describe('Catalog id, served URL, or "<endpoint>/<path>".'),
511
+ force: z.boolean().optional()
512
+ .describe('Delete even though something references it. Those references will break.'),
513
+ dryRun: z.boolean().optional()
514
+ .describe('Report what would happen and what would break. Removes nothing.'),
515
+ },
516
+ },
517
+ async ({ path: pathish, force = false, dryRun = false } = {}) => {
518
+ const principal = mcp.principal?.() ?? null
519
+ const found = await locate(pathish, endpoints, workingFolder())
520
+ if (found.error) return fail(found.error)
521
+
522
+ // Which endpoint owns it, for the capability check. Derived from
523
+ // the resolved file rather than trusted from the input, so a served
524
+ // URL is checked against the folder it actually lives in.
525
+ const owning = found.endpoint ?? Object.keys(endpoints).find(name => {
526
+ const root = folderOf(name)
527
+ return root && !path.relative(root, found.file).startsWith('..')
528
+ })
529
+ if (!owning) {
530
+ return fail(`${pathish} is not inside any configured WebDAV endpoint, so this will not remove it.`)
531
+ }
532
+ if (endpoints[owning].readOnly === true) {
533
+ return fail(`${owning} is configured readOnly, so nothing can be removed from it.`)
534
+ }
535
+ if (!holds(principal, writeCapabilityOf(owning))) {
536
+ return fail(`Refused: you do not hold ${writeCapabilityOf(owning)}. Nothing was removed.`)
537
+ }
538
+ try {
539
+ await stat(found.file)
540
+ } catch {
541
+ return fail(`Nothing on disk at ${path.relative(workingFolder(), found.file)}.`)
542
+ }
543
+
544
+ // Who breaks. The reference is the served URL where there is one,
545
+ // because that is the string documents actually carry.
546
+ const reference = found.entity?.meta?.url ?? found.entity?.id ?? null
547
+ const referrers = reference && rt.refs?.inboundFor
548
+ ? rt.refs.inboundFor(reference)
549
+ : []
550
+
551
+ const breakage = {
552
+ reference,
553
+ referencedBy: referrers.length,
554
+ references: referrers.map(r => ({ entity: r.id, field: r.field, kind: r.kind })),
555
+ coverage: 'Values in entity meta, including inside arrays. NOT body text, and not links a layout '
556
+ + 'builds at render time — mikser_search({ in: ["content"] }) covers those.',
557
+ }
558
+
559
+ if (dryRun) {
560
+ return ok({
561
+ dryRun: true, endpoint: owning,
562
+ path: path.relative(workingFolder(), found.file),
563
+ id: found.entity?.id ?? null,
564
+ wouldRefuse: referrers.length > 0 && !force,
565
+ ...breakage,
566
+ note: 'Nothing was removed.',
567
+ })
568
+ }
569
+
570
+ if (referrers.length && !force) {
571
+ return fail(`Refused: ${referrers.length} reference(s) would break.\n\n`
572
+ + referrers.map(r => ` ${r.entity ?? r.id} ${r.field}`).join('\n')
573
+ + '\n\nNothing was removed. Repoint or remove those first, or pass force: true to delete anyway '
574
+ + 'and leave them pointing at nothing. Body-text references are not counted here — check with '
575
+ + 'mikser_search({ in: ["content"] }).')
576
+ }
577
+
578
+ // Trash, not unlink. Under the runtime folder so it is outside every
579
+ // served collection — dropping it inside one would republish the
580
+ // file at a new URL and, where git sync is on, commit it.
581
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
582
+ const trash = path.join(rt.options.runtimeFolder ?? path.join(workingFolder(), 'runtime'),
583
+ 'trash', stamp, owning, path.basename(found.file))
584
+ await mkdir(path.dirname(trash), { recursive: true })
585
+ try {
586
+ await rename(found.file, trash)
587
+ } catch {
588
+ // Across devices rename fails; copy-then-unlink is the fallback.
589
+ await writeFile(trash, await readFile(found.file))
590
+ await unlink(found.file)
591
+ }
592
+ logger?.info?.('drive: trashed %s from %s for %j%s',
593
+ path.relative(workingFolder(), found.file), owning,
594
+ principal?.subject ?? 'anonymous', force && referrers.length ? ' (forced over references)' : '')
595
+
596
+ return ok({
597
+ removed: true,
598
+ endpoint: owning,
599
+ path: path.relative(workingFolder(), found.file),
600
+ id: found.entity?.id ?? null,
601
+ trash: path.relative(workingFolder(), trash),
602
+ cycleId: nextCycleId(),
603
+ ...breakage,
604
+ ...(force && referrers.length ? {
605
+ broke: `Forced past ${referrers.length} reference(s), which now point at nothing.`,
606
+ } : {}),
607
+ restore: `Move it back from ${path.relative(workingFolder(), trash)} to undo this.`,
608
+ })
609
+ },
610
+ )
611
+
612
+
613
+ // ── move ─────────────────────────────────────────────────────────────
614
+ mcp.registerTool(
615
+ 'mikser_drive_move',
616
+ {
617
+ description:
618
+ 'Move or rename a stored FILE. Refused by default when anything still references it, listing every '
619
+ + '(entity, field) that would break — a move is a rename of the thing documents point at, so the '
620
+ + 'references are the whole risk.\n\n'
621
+ + 'With rewriteRefs: true it moves AND repoints them, editing each referring source file. Reference '
622
+ + 'strings here are plain rooted paths ("/media/x.png") rather than $-keyed refs, so this rewrites '
623
+ + 'the literal string; the response names every file changed and how many occurrences in each.\n\n'
624
+ + 'It sees values in entity META, including inside arrays. It cannot see a reference in a document '
625
+ + 'BODY or one a layout builds at render time — so an empty list means "nothing of the kind I look '
626
+ + 'at". Check body text with mikser_search({ in: ["content"] }) before forcing.',
627
+ inputSchema: {
628
+ from: z.string().describe('Catalog id, served URL, or "<endpoint>/<path>".'),
629
+ to: z.string().describe('Destination, relative to the SAME endpoint, e.g. "devices/hera/cta.png".'),
630
+ rewriteRefs: z.boolean().optional()
631
+ .describe('Repoint every meta reference at the new location instead of refusing.'),
632
+ dryRun: z.boolean().optional()
633
+ .describe('Report what would move and what would be rewritten. Changes nothing.'),
634
+ },
635
+ },
636
+ async ({ from, to, rewriteRefs = false, dryRun = false } = {}) => {
637
+ const principal = mcp.principal?.() ?? null
638
+ const found = await locate(from, endpoints, workingFolder())
639
+ if (found.error) return fail(found.error)
640
+
641
+ const owning = found.endpoint ?? Object.keys(endpoints).find(name => {
642
+ const root = folderOf(name)
643
+ return root && !path.relative(root, found.file).startsWith('..')
644
+ })
645
+ if (!owning) return fail(`${from} is not inside any configured endpoint.`)
646
+ if (endpoints[owning].readOnly === true) {
647
+ return fail(`${owning} is configured readOnly, so nothing can be moved within it.`)
648
+ }
649
+ if (!holds(principal, writeCapabilityOf(owning))) {
650
+ return fail(`Refused: you do not hold ${writeCapabilityOf(owning)}. Nothing was moved.`)
651
+ }
652
+ const dest = safeSegment(to, 'to')
653
+ if (dest.error) return fail(dest.error)
654
+ try { await stat(found.file) } catch {
655
+ return fail(`Nothing on disk at ${path.relative(workingFolder(), found.file)}.`)
656
+ }
657
+ const target = path.join(folderOf(owning), dest.value)
658
+ if (target === found.file) return fail('`to` is the same path as `from`.')
659
+ let clash = false
660
+ try { await stat(target); clash = true } catch { /* free */ }
661
+ if (clash) return fail(`${dest.value} already exists in ${owning}. Nothing was moved.`)
662
+
663
+ // What points at it, and what those references would have to become.
664
+ const oldRef = found.entity?.meta?.url ?? null
665
+ const referrers = oldRef && rt.refs?.inboundFor ? rt.refs.inboundFor(oldRef) : []
666
+ // The new served URL is the old one with the path swapped. Derived
667
+ // from the old rather than rebuilt, so a deployment that prefixes
668
+ // its URLs differently is not second-guessed.
669
+ const newRef = oldRef
670
+ ? oldRef.slice(0, oldRef.length - path.relative(folderOf(owning), found.file).length) + dest.value
671
+ : null
672
+
673
+ const breakage = {
674
+ from: path.relative(workingFolder(), found.file),
675
+ to: path.relative(workingFolder(), target),
676
+ reference: oldRef,
677
+ newReference: newRef,
678
+ referencedBy: referrers.length,
679
+ references: referrers.map(r => ({ entity: r.id, field: r.field, kind: r.kind })),
680
+ coverage: 'Values in entity meta, including inside arrays. NOT body text, and not links a layout '
681
+ + 'builds at render time — mikser_search({ in: ["content"] }) covers those.',
682
+ }
683
+
684
+ if (dryRun) {
685
+ return ok({ dryRun: true, endpoint: owning, ...breakage,
686
+ wouldRefuse: referrers.length > 0 && !rewriteRefs,
687
+ wouldRewrite: rewriteRefs ? [...new Set(referrers.map(r => r.id))] : [],
688
+ note: 'Nothing was moved or rewritten.' })
689
+ }
690
+
691
+ if (referrers.length && !rewriteRefs) {
692
+ return fail(`Refused: ${referrers.length} reference(s) point at ${oldRef}.\n\n`
693
+ + referrers.map(r => ` ${r.id} ${r.field}`).join('\n')
694
+ + '\n\nNothing was moved. Pass rewriteRefs: true to move and repoint them, or repoint them '
695
+ + 'yourself first. Body-text references are not counted here — check with '
696
+ + 'mikser_search({ in: ["content"] }).')
697
+ }
698
+
699
+ await mkdir(path.dirname(target), { recursive: true })
700
+ try {
701
+ await rename(found.file, target)
702
+ } catch {
703
+ await writeFile(target, await readFile(found.file))
704
+ await unlink(found.file)
705
+ }
706
+
707
+ // Repoint the referrers, by editing the literal string in each
708
+ // source file. A rooted path like /media/x.png is specific enough
709
+ // that a coincidental match is not a real risk, and every file
710
+ // changed is named so the caller can check rather than trust.
711
+ const rewritten = []
712
+ if (rewriteRefs && oldRef && newRef) {
713
+ for (const id of [...new Set(referrers.map(r => r.id))]) {
714
+ const entity = await readEntity({ id })
715
+ if (!entity?.uri) { rewritten.push({ id, rewrote: 0, note: 'no source file' }); continue }
716
+ try {
717
+ const before = await readFile(entity.uri, 'utf8')
718
+ const occurrences = before.split(oldRef).length - 1
719
+ if (!occurrences) { rewritten.push({ id, rewrote: 0, note: 'not present as literal text' }); continue }
720
+ await writeFile(entity.uri, before.split(oldRef).join(newRef), 'utf8')
721
+ rewritten.push({ id, path: path.relative(workingFolder(), entity.uri), rewrote: occurrences })
722
+ } catch (err) {
723
+ rewritten.push({ id, rewrote: 0, error: err.message })
724
+ }
725
+ }
726
+ }
727
+ logger?.info?.('drive: moved %s to %s for %j%s',
728
+ path.relative(workingFolder(), found.file), dest.value, principal?.subject ?? 'anonymous',
729
+ rewritten.length ? ` (repointed ${rewritten.length} referrer(s))` : '')
730
+
731
+ const missed = rewritten.filter(r => !r.rewrote)
732
+ return ok({
733
+ moved: true, endpoint: owning, ...breakage,
734
+ cycleId: nextCycleId(),
735
+ ...(rewriteRefs ? { rewritten } : {}),
736
+ ...(missed.length ? {
737
+ warning: `${missed.length} referrer(s) were not rewritten — the reference is not in their source `
738
+ + 'as literal text, so it is built at render time or lives in a body. Those still point at '
739
+ + `${oldRef}, which no longer exists.`,
740
+ } : {}),
741
+ })
742
+ },
743
+ )
744
+
745
+ logger?.debug?.('mikser_drive_add/read/move/delete registered')
746
+ }