mikser-io-drive 0.14.0 → 0.16.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.
@@ -0,0 +1,73 @@
1
+ # Publish to npm without a stored token.
2
+ #
3
+ # npm is retiring tokens that skip the second factor — its own token page says
4
+ # "Publish new versions directly (deprecated — ends January 2027)". This is the
5
+ # replacement: the job proves who it is at publish time with a short-lived
6
+ # credential GitHub issues and npm verifies, so npm trusts "the publish.yml
7
+ # workflow in this repository" rather than a string.
8
+ #
9
+ # There is no NODE_AUTH_TOKEN and no secret to configure. Nothing to leak,
10
+ # expire or rotate — the two ways publishing broke in September 2026.
11
+ #
12
+ # Link it once on npm: the package → Settings → Trusted Publisher → GitHub
13
+ # Actions, naming this repository and this filename. Until that exists the
14
+ # publish step fails rather than falling back to something weaker.
15
+
16
+ name: Publish
17
+
18
+ on:
19
+ push:
20
+ tags: ['v*']
21
+ workflow_dispatch:
22
+
23
+ jobs:
24
+ publish:
25
+ runs-on: ubuntu-latest
26
+ permissions:
27
+ contents: read
28
+ # The whole mechanism. Without it npm has nothing to verify.
29
+ id-token: write
30
+ steps:
31
+ - uses: actions/checkout@v4
32
+
33
+ - uses: actions/setup-node@v4
34
+ with:
35
+ node-version: '24'
36
+ registry-url: 'https://registry.npmjs.org'
37
+
38
+ # Trusted publishing landed in npm 11.5.1.
39
+ - name: Use an npm that can do trusted publishing
40
+ run: npm install -g npm@latest
41
+
42
+ # `npm install`, not `npm ci`: these packages are developed in a
43
+ # workspace, so dependencies hoist to its root and each package's own
44
+ # lockfile is never read locally. They drift silently, and CI is the only
45
+ # thing that would ever read them.
46
+ - run: npm install --no-audit --no-fund
47
+ env:
48
+ # puppeteer downloads a browser on install; nothing in the release
49
+ # path renders a page.
50
+ PUPPETEER_SKIP_DOWNLOAD: '1'
51
+
52
+ # The tag is the release. A tag that disagrees with package.json would
53
+ # publish a version nobody asked for.
54
+ - name: Tag must match package.json
55
+ if: startsWith(github.ref, 'refs/tags/v')
56
+ run: |
57
+ TAG="${GITHUB_REF_NAME#v}"
58
+ PKG="$(node -p 'require("./package.json").version')"
59
+ if [ "$TAG" != "$PKG" ]; then
60
+ echo "tag v$TAG does not match package.json $PKG"
61
+ exit 1
62
+ fi
63
+ echo "publishing $PKG"
64
+
65
+ # The suite runs here now. It did not before: these packages declared
66
+ # mikser-io only as a peerDependency (npm does not install a package's
67
+ # own peers) or as file:../mikser-io (a path that exists on a laptop and
68
+ # nowhere else), so a standalone clone could not resolve the engine it
69
+ # tests against. Both are now real devDependencies with version ranges,
70
+ # which npm still satisfies from the workspace locally.
71
+ - run: npm test
72
+
73
+ - run: npm publish --access public
@@ -1,5 +1,10 @@
1
1
  import { UnauthorizedError, ForbiddenError } from 'nephele'
2
2
  import { authorize } from 'mikser-io'
3
+ // The one implementation of "does this principal hold it", including the
4
+ // `*` wildcard — checked here rather than re-derived, because two gates in
5
+ // this file and one in mikser-io-auth each had their own `.includes` and a
6
+ // wildcard added to the shared function reached none of them.
7
+ import { hasCapability } from 'mikser-io'
3
8
 
4
9
  // Bridges Nephele's Authenticator contract onto the engine's verifier seam
5
10
  // (ADR-0012), so WebDAV authenticates against the same htpasswd identity as
@@ -80,8 +85,7 @@ export class MikserAuthenticator {
80
85
  // Capability gate for this endpoint. `capabilities: null` means the
81
86
  // credential is not capability-scoped (no map configured, or a bare
82
87
  // static token), which passes — same rule as every other surface.
83
- if (this.#capability && principal.capabilities != null &&
84
- !principal.capabilities.includes(this.#capability)) {
88
+ if (this.#capability && !hasCapability(principal, this.#capability)) {
85
89
  this.#logger?.debug?.('drive: %j lacks %j', principal.subject, this.#capability)
86
90
  throw new ForbiddenError(`Your credential does not carry '${this.#capability}'.`)
87
91
  }
@@ -94,8 +98,7 @@ export class MikserAuthenticator {
94
98
  // therefore never means what it looks like it means.) The authenticator
95
99
  // is the earliest place that holds both the principal and the method.
96
100
  if (this.#writeCapability && WRITE_METHODS.has(request.method) &&
97
- principal.capabilities != null &&
98
- !principal.capabilities.includes(this.#writeCapability)) {
101
+ !hasCapability(principal, this.#writeCapability)) {
99
102
  this.#logger?.debug?.('drive: %j may not %s — lacks %j',
100
103
  principal.subject, request.method, this.#writeCapability)
101
104
  throw new ForbiddenError(`Your credential does not carry '${this.#writeCapability}'.`)
package/lib/files.js CHANGED
@@ -30,7 +30,7 @@ import { lookup as mimeLookup } from 'mime-types'
30
30
  // Drive writes with fs directly rather than through a collection handle, so
31
31
  // it records its own paths. Without this a media replacement — the change most
32
32
  // worth taking back — falls into the unattributed sweep.
33
- import { looksTextual, recordChangeSetWrite, explainRefusal, actingRole } from 'mikser-io'
33
+ import { looksTextual, recordChangeSetWrite, explainRefusal, actingRole, useService } from 'mikser-io'
34
34
  import { readFile, writeFile, mkdir, rename, stat, unlink, open } from 'node:fs/promises'
35
35
  import {
36
36
  runtime, readEntity, findEntities, nextCycleId, whenCycleCompletes, checksum,
@@ -246,11 +246,22 @@ async function settledCycle(cycleId) {
246
246
  }
247
247
  }
248
248
 
249
- export function registerFileTools({ runtime: rt, endpoints, capabilityOf, writeCapabilityOf, logger }) {
250
- const mcp = rt.options.mcp
249
+ export function registerFileTools({ runtime, endpoints, capabilityOf, writeCapabilityOf, logger }) {
250
+ // Asked of core rather than read off runtime.options.mcp, so this package
251
+ // no longer reaches into a sibling's namespace and no longer depends on
252
+ // where the mcp plugin sits in the plugins array.
253
+ //
254
+ // Registered through the SUBSTRATE, not the engine registry, on purpose:
255
+ // these tools declare `mutates`, and the substrate is what gives a
256
+ // mutating tool its changeSet parameters and runs its handler inside the
257
+ // change set. Registering them against the engine would leave their
258
+ // writes unattributable — the one failure nothing downstream can repair.
259
+ // They still reach the CLI, because the substrate mirrors every
260
+ // registration into the engine registry with the wrapped handler intact.
261
+ const mcp = useService('mcp')
251
262
  if (!mcp?.registerTool) return
252
263
 
253
- const workingFolder = () => rt.options.workingFolder
264
+ const workingFolder = () => runtime.options.workingFolder
254
265
  const folderOf = (name) => {
255
266
  const ep = endpoints[name]
256
267
  if (!ep) return null
@@ -484,10 +495,10 @@ export function registerFileTools({ runtime: rt, endpoints, capabilityOf, writeC
484
495
  if (found.endpoint && !holds(principal, capabilityOf(found.endpoint))) {
485
496
  return fail(explainRefusal({
486
497
  capability: capabilityOf(found.endpoint),
487
- role: actingRole(principal?.roles ?? [], rt.options?.roles?.catalogue ?? {}),
498
+ role: actingRole(principal?.roles ?? [], runtime.options?.roles?.catalogue ?? {}),
488
499
  target: pathish,
489
- catalogue: rt.options?.roles?.catalogue ?? {},
490
- summaries: rt.options?.roles?.summaries ?? {},
500
+ catalogue: runtime.options?.roles?.catalogue ?? {},
501
+ summaries: runtime.options?.roles?.summaries ?? {},
491
502
  }))
492
503
  }
493
504
 
@@ -641,7 +652,7 @@ export function registerFileTools({ runtime: rt, endpoints, capabilityOf, writeC
641
652
  // Who breaks. The reference is the served URL where there is
642
653
  // one, because that is the string documents actually carry.
643
654
  const reference = found.entity?.meta?.url ?? found.entity?.id ?? null
644
- const referrers = reference && rt.refs?.inboundFor ? rt.refs.inboundFor(reference) : []
655
+ const referrers = reference && runtime.refs?.inboundFor ? runtime.refs.inboundFor(reference) : []
645
656
  staged.push({ input: pathish, file: found.file, entity: found.entity, owning, reference, referrers })
646
657
  }
647
658
 
@@ -695,7 +706,7 @@ export function registerFileTools({ runtime: rt, endpoints, capabilityOf, writeC
695
706
  // file at a new URL and, where git sync is on, commit it. One stamp
696
707
  // for the batch, so undoing it is one move back.
697
708
  const stamp = new Date().toISOString().replace(/[:.]/g, '-')
698
- const trashRoot = path.join(rt.options.runtimeFolder ?? path.join(workingFolder(), 'runtime'),
709
+ const trashRoot = path.join(runtime.options.runtimeFolder ?? path.join(workingFolder(), 'runtime'),
699
710
  'trash', stamp)
700
711
  const results = []
701
712
  for (const s of staged) {
@@ -827,7 +838,7 @@ export function registerFileTools({ runtime: rt, endpoints, capabilityOf, writeC
827
838
  // URLs differently is not second-guessed.
828
839
  for (const s of staged) {
829
840
  s.oldRef = s.entity?.meta?.url ?? null
830
- s.referrers = s.oldRef && rt.refs?.inboundFor ? rt.refs.inboundFor(s.oldRef) : []
841
+ s.referrers = s.oldRef && runtime.refs?.inboundFor ? runtime.refs.inboundFor(s.oldRef) : []
831
842
  s.newRef = s.oldRef
832
843
  ? s.oldRef.slice(0, s.oldRef.length - path.relative(folderOf(s.owning), s.file).length) + s.dest
833
844
  : null
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "mikser-io-drive",
3
- "version": "0.14.0",
4
- "description": "WebDAV for mikser-io. Exposes working-folder directories as WebDAV endpoints via Nephele, authenticated through mikser-io-auth's htpasswd identity — so content can be edited from Finder, Explorer or any DAV client and the build picks the change up.",
3
+ "version": "0.16.0",
4
+ "description": "WebDAV for mikser-io. Exposes working-folder directories as WebDAV endpoints via Nephele, authenticated through mikser-io-auth's htpasswd identity \u2014 so content can be edited from Finder, Explorer or any DAV client and the build picks the change up.",
5
5
  "main": "index.js",
6
6
  "type": "module",
7
+ "allowScripts": {
8
+ "better-sqlite3": true
9
+ },
7
10
  "scripts": {
8
11
  "test": "node --no-warnings --test --test-reporter=spec 'test/**/*.test.js'"
9
12
  },
@@ -25,7 +28,7 @@
25
28
  },
26
29
  "homepage": "https://github.com/almero-digital-marketing/mikser-io-drive#readme",
27
30
  "peerDependencies": {
28
- "mikser-io": "^9.52.0",
31
+ "mikser-io": "^10.0.0",
29
32
  "zod": "^4.0.0"
30
33
  },
31
34
  "dependencies": {
@@ -38,8 +41,9 @@
38
41
  "bcryptjs": "^3.0.0",
39
42
  "express": "^5.2.1",
40
43
  "globby": "^15.0.0",
41
- "mikser-io": "file:../mikser-io",
42
- "mikser-io-auth": "file:../mikser-io-auth",
44
+ "mikser-io": "^10.0.0",
45
+ "mikser-io-auth": "^0.13.0",
46
+ "mikser-io-mcp": "^2.0.0",
43
47
  "webdav": "^5.10.0",
44
48
  "zod": "^4.0.0"
45
49
  }
@@ -3,18 +3,21 @@ import assert from 'node:assert/strict'
3
3
  import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'
4
4
  import { tmpdir } from 'node:os'
5
5
  import { spawn } from 'node:child_process'
6
+ import { existsSync } from 'node:fs'
6
7
  import path from 'node:path'
7
8
  import { createRequire } from 'node:module'
8
9
 
9
- // `derived` is a passthrough of what the assets plugin stamped, and it was the
10
- // one field in the add response I could not verify by unit test: it only exists
11
- // when a real preset matched and a real derivative was produced. So this runs
12
- // the whole pipeline — files → assets → renderPreset → sharp — against a live
13
- // server and asks the tool.
10
+ // `derived` is `entity.meta.presets`, spread onto the add response. What this
11
+ // package owns is that passthrough: the field it reads and the field it
12
+ // writes, over a live server through the real tool.
14
13
  //
15
- // The read is three lines. What this pins is that those three lines name the
16
- // field the pipeline actually writes, which is the half that silently rots when
17
- // the plugin changes shape.
14
+ // It used to produce a real derivative to get there — files → assets →
15
+ // renderPreset → sharp — which bought two assertions beyond the passthrough
16
+ // (a .webp suffix, a URL unlike the source) at the price of a hard dependency
17
+ // on mikser-io-assets, a live image encode, and a `skip` whenever sharp was
18
+ // absent, so on many machines it never ran at all. Those two assertions are
19
+ // presetUrl's behaviour and are unit-tested in mikser-io-assets. A five-line
20
+ // plugin stamps the field on the same hook instead.
18
21
  //
19
22
  // Two mistakes made while building it, both of which produce SILENCE rather
20
23
  // than an error and are worth knowing:
@@ -27,15 +30,7 @@ import { createRequire } from 'node:module'
27
30
  // (/media/**) matches nothing, produces no variant, and reports only that
28
31
  // none matched.
29
32
 
30
- // sharp is mikser-io's dependency, not this package's — the fixture needs it
31
- // because a preset does the actual image work. Resolved from there rather than
32
- // from here, and the fixture's node_modules is built from mikser-io's so the
33
- // spawned build finds it too.
34
- const require_ = createRequire(import.meta.url)
35
33
  const ENGINE = path.resolve(import.meta.dirname, '..', '..', 'mikser-io')
36
- const hasSharp = (() => {
37
- try { require_.resolve('sharp', { paths: [ENGINE] }); return true } catch { return false }
38
- })()
39
34
 
40
35
  const PORT = 4211
41
36
  const PNG = Buffer.from(
@@ -60,10 +55,8 @@ async function rpc(token, method, params, session) {
60
55
  }
61
56
 
62
57
  before(async (t) => {
63
- if (!hasSharp) return
64
58
  dir = await mkdtemp(path.join(tmpdir(), 'mikser-derived-'))
65
59
  await mkdir(path.join(dir, 'media'), { recursive: true })
66
- await mkdir(path.join(dir, 'presets'), { recursive: true })
67
60
  await mkdir(path.join(dir, 'documents'), { recursive: true })
68
61
 
69
62
  // node_modules by symlink, so the fixture resolves the working copies.
@@ -77,33 +70,56 @@ before(async (t) => {
77
70
  await symlink(path.join(donor, entry), path.join(dir, 'node_modules', entry)).catch(() => {})
78
71
  }
79
72
  }
73
+ // Point at the WORKING COPY of each sibling when there is one — the whole
74
+ // point is to exercise this checkout, not the last release. In CI there is
75
+ // no workspace: the repository is cloned alone and the siblings come from
76
+ // npm, already symlinked by the donor loop above. Replacing them there
77
+ // removed the installed copy and linked to a path that does not exist, so
78
+ // the fixture had no engine at all and the server never started. Both
79
+ // steps failed silently — the rm succeeded, the symlink was swallowed by
80
+ // .catch — which is why it read as "Cannot find module .../app.js".
80
81
  for (const pkg of ['mikser-io', 'mikser-io-mcp', 'mikser-io-drive']) {
82
+ const sibling = path.resolve(here, '..', pkg)
83
+ if (!existsSync(sibling)) continue
81
84
  await rm(path.join(dir, 'node_modules', pkg), { recursive: true, force: true })
82
- await symlink(path.resolve(here, '..', pkg), path.join(dir, 'node_modules', pkg)).catch(() => {})
85
+ await symlink(sibling, path.join(dir, 'node_modules', pkg)).catch(() => {})
83
86
  }
84
87
 
85
- // A preset is a module whose DEFAULT export does the work.
86
- await writeFile(path.join(dir, 'presets/web.js'),
87
- "import sharp from 'sharp'\n"
88
- + 'export const revision = 1\n'
89
- + "export const format = 'webp'\n"
90
- + 'export const options = { width: 8 }\n'
91
- + 'export default ({ entity: { source, destination }, options }) =>\n'
92
- + ' sharp(source).resize(options).webp().toFile(destination)\n')
93
-
94
88
  // `match` against the ID shape, which the files plugin builds from the path
95
89
  // inside filesFolder — NOT the URL shape.
90
+ // A five-line plugin in place of the assets pipeline.
91
+ //
92
+ // `derived` is `entity.meta.presets`, spread onto the response. What drive
93
+ // owns is that passthrough — the field name it reads and the field name it
94
+ // writes. Producing a real derivative to test it meant a live sharp
95
+ // encode, a preset module on disk, and a hard dependency on
96
+ // mikser-io-assets for a package that does not otherwise use it; the
97
+ // assertions it bought beyond the passthrough (a .webp suffix, a URL
98
+ // unlike the source) are presetUrl's behaviour and are unit-tested in
99
+ // mikser-io-assets, where changing them would be noticed.
100
+ //
101
+ // Stamped in onProcessed, the same hook and the same field the assets
102
+ // plugin writes.
96
103
  await writeFile(path.join(dir, 'mikser.config.js'),
97
- "import { files, assets, renderPreset } from 'mikser-io'\n"
104
+ "import { files } from 'mikser-io'\n"
98
105
  + "import { mcp } from 'mikser-io-mcp'\n"
99
106
  + "import { drive } from 'mikser-io-drive'\n"
107
+ // onProcessed is a PHASE hook — it receives the abort signal, not an
108
+ // entity — so the stamp walks the journal, which is the same shape and
109
+ // the same hook the assets plugin uses to write this field.
110
+ + 'const stampPresets = () => ({ onProcessed, useJournal, updateEntity, constants: { OPERATION } }) =>\n'
111
+ + ' onProcessed(async (signal) => {\n'
112
+ + " for await (const { entity } of useJournal('Stamp presets', [OPERATION.CREATE, OPERATION.UPDATE], signal)) {\n"
113
+ + " if (entity.collection !== 'files' || !entity.id.endsWith('.png')) continue\n"
114
+ + " entity.meta = { ...entity.meta, presets: { web: '/derived/web/shots/hero.webp' } }\n"
115
+ + ' await updateEntity(entity)\n'
116
+ + ' }\n'
117
+ + '})\n'
100
118
  + 'export default async () => ({\n'
101
119
  + ' plugins: [\n'
102
120
  + " mcp({ base: '' }),\n"
103
121
  + " files({ filesFolder: 'media', outputFolder: 'media' }),\n"
104
- + " assets({ assetsFolder: 'derived', presetsFolder: 'presets',\n"
105
- + " presets: { web: { match: ['/files/**/*.png'] } } }),\n"
106
- + ' renderPreset(),\n'
122
+ + ' stampPresets(),\n'
107
123
  + " drive({ allowRemote: true, endpoints: { media: { folder: 'media' } } }),\n"
108
124
  + ' ],\n'
109
125
  + '})\n')
@@ -144,7 +160,7 @@ after(async () => {
144
160
  if (dir) await rm(dir, { recursive: true, force: true })
145
161
  })
146
162
 
147
- describe('derived — the preset passthrough', { skip: hasSharp ? false : 'sharp is not installed' }, () => {
163
+ describe('derived — the preset passthrough', () => {
148
164
  it('reports the variant a preset actually produced', async () => {
149
165
  const session = { id: null, n: 1 }
150
166
  await rpc(null, 'initialize',
@@ -27,7 +27,7 @@ before(async () => {
27
27
  await writeFile(path.join(dir, 'users.htpasswd'), ['alice', 'bob', 'carol']
28
28
  .map(u => `${u}:${bcrypt.hashSync(`${u}-pw`, 10)}`).join('\n') + '\n')
29
29
  await writeFile(path.join(dir, 'groups.htgroup'),
30
- 'editors: alice\nreviewers: bob\n')
30
+ 'editors: alice\nreviewers: bob\nowners: carol\n')
31
31
 
32
32
  const { default: express } = await import('express')
33
33
  const app = express()
@@ -43,6 +43,10 @@ before(async () => {
43
43
  // why `editors` has to name `drive:locked` too.
44
44
  editors: ['drive:content', 'drive:content:write', 'drive:locked'],
45
45
  reviewers: ['drive:content'], // read, no write
46
+ // `*` is a real wildcard. It was written in grants long before it
47
+ // meant anything, and matched nothing — every gate was an exact
48
+ // includes, including the two in this package.
49
+ owners: ['*'],
46
50
  },
47
51
  })
48
52
 
@@ -103,11 +107,30 @@ describe('auth', () => {
103
107
  })
104
108
 
105
109
  it('403s an authenticated user who lacks the endpoint capability', async () => {
106
- // carol is in no group, so with a capability map configured she holds
107
- // nothing — authenticated, but not for this endpoint.
108
- const res = await dav('PROPFIND', '/drive/content/', { headers: { depth: '0', ...as('carol') } })
110
+ // bob is a reviewer: he holds drive:content but was never granted
111
+ // drive:locked, so that endpoint is not his to open.
112
+ const res = await dav('PROPFIND', '/drive/locked/', { headers: { depth: '0', ...as('bob') } })
109
113
  assert.equal(res.status, 403)
110
114
  })
115
+
116
+ it('opens an endpoint for a wildcard holder who was never granted it', async () => {
117
+ // carol holds `*` and nothing else. The gate here checked
118
+ // `capabilities.includes(...)` directly rather than asking
119
+ // hasCapability, so a wildcard reached api and mcp — which declare no
120
+ // capability and so pass the unscoped check — and stopped at the
121
+ // drive, which declares one. A role whose summary said "everything"
122
+ // could not open a folder, and its printed reach was empty.
123
+ const res = await dav('PROPFIND', '/drive/content/', { headers: { depth: '0', ...as('carol') } })
124
+ assert.equal(res.status, 207, 'a wildcard holder may mount and read')
125
+ })
126
+
127
+ it('lets a wildcard holder write, without naming the write capability', async () => {
128
+ const res = await dav('PUT', '/drive/content/wildcard.md', {
129
+ headers: { ...as('carol'), 'content-type': 'text/markdown' },
130
+ body: '# by a wildcard holder\n',
131
+ })
132
+ assert.ok([201, 204].includes(res.status), `expected a write, got ${res.status}`)
133
+ })
111
134
  })
112
135
 
113
136
  describe('read and write', () => {
@@ -4,7 +4,7 @@ import { mkdtemp, rm, mkdir, writeFile, readFile, stat } from 'node:fs/promises'
4
4
  import { tmpdir } from 'node:os'
5
5
  import path from 'node:path'
6
6
 
7
- import { runtime } from 'mikser-io'
7
+ import { runtime, provideService, resetServices } from 'mikser-io'
8
8
  import { registerFileTools } from '../lib/files.js'
9
9
  import { readCapability, writeCapability } from '../index.js'
10
10
 
@@ -41,8 +41,10 @@ before(async () => {
41
41
  runtime.options = { ...runtime.options, workingFolder: dir, runtimeFolder: path.join(dir, 'runtime') }
42
42
 
43
43
  mcp = fakeSubstrate({ subject: 'alice', capabilities: ['drive:media', 'drive:media:write'] })
44
+ resetServices()
45
+ provideService('mcp', mcp)
44
46
  registerFileTools({
45
- runtime: { options: { ...runtime.options, mcp }, refs: runtime.refs },
47
+ runtime: { options: { ...runtime.options }, refs: runtime.refs },
46
48
  endpoints: { media: { folder: 'media' }, locked: { folder: 'locked', readOnly: true } },
47
49
  capabilityOf: readCapability, writeCapabilityOf: writeCapability,
48
50
  })
@@ -448,10 +450,12 @@ describe('move', () => {
448
450
  describe('read capability', () => {
449
451
  const toolsFor = async (principal) => {
450
452
  const substrate = fakeSubstrate(principal)
453
+ resetServices()
454
+ provideService('mcp', substrate)
451
455
  registerFileTools({
452
456
  runtime: {
453
457
  options: {
454
- ...runtime.options, mcp: substrate,
458
+ ...runtime.options,
455
459
  roles: {
456
460
  catalogue: {
457
461
  editors: ['drive:media', 'drive:media:write', 'drive:secrets'],