@auggieteo/dsh-mcp-adapter 0.2.2 → 0.4.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/src/host/index.js CHANGED
@@ -4,69 +4,92 @@ import { createMcpConnection } from './mcp-connection.js'
4
4
  import { installMcpOauthCommands } from './oauth-commands.js'
5
5
  import { createFileTokenStore } from './oauth.js'
6
6
  import { installMcpOauth } from './oauth-service.js'
7
+ import { importLegacyMcpSettings } from './legacy-import.js'
7
8
  import { installMcpPromptCommand } from './prompt-commands.js'
8
9
  import { installMcpPromotions } from './promotions.js'
9
10
  import { installMcpProxyTool } from './proxy-tool.js'
10
- import { installMcpSettings } from './settings.js'
11
+ import { createMcpConfigScope, McpConfigSchema } from './settings.js'
11
12
  import { installMcpSkill } from './skill.js'
12
13
  import { installWorkspaceLayer } from './workspace-config.js'
13
14
 
14
15
  export const name = 'dsh-mcp-adapter'
15
16
 
16
- // Settings and Timer are optional at the Adapter row. The manager starts only
17
- // while both Services exist, but the Config namespace can exist without Timer.
17
+ // 0.1.7 model: the Adapter's configuration is its Loader-entry Config (the
18
+ // `Config` schema below), always readable from `ctx.config`; Timer is only
19
+ // needed by the connection manager, and Settings by writes and the page.
18
20
  export const inject = []
19
21
 
22
+ export const Config = McpConfigSchema
23
+
20
24
  export function apply(ctx) {
21
25
  // The bundled agent skill is independent of every other Adapter feature: a
22
- // deployment without the skills service simply gets no skill entry. Sibling
23
- // inject fibers run in registration order while the services exist, so the
24
- // settings callback reads `skillInstall` before the skills callback runs;
25
- // without the settings service the skill module's 'file' default applies.
26
- let skillInstall
27
- installMcpSettings(ctx, (settingsCtx, scope) => {
28
- skillInstall = scope.get()?.skillInstall
29
- const layeredScope = installWorkspaceLayer(settingsCtx, scope)
30
- settingsCtx.inject(['timer'], (managerCtx) => {
31
- const store = createFileTokenStore()
32
- let oauth
33
- const manager = installMcpManager(managerCtx, layeredScope, {
34
- connectionFactory: (serverName, config, callbacks, signal, sdk) =>
35
- createMcpConnection(serverName, config, callbacks, signal, sdk, {
36
- store,
37
- onAuthorizationRequired: (url) =>
38
- oauth?.noteAuthorizationRequired(serverName, url),
39
- }),
40
- })
41
- // The OAuth controller and every command read the LAYERED scope, so a
42
- // workspace-defined Server is signable and status-visible. Writes still
43
- // land in the global namespace: the layered scope forwards them there.
44
- oauth = installMcpOauth(managerCtx, manager, layeredScope, { store })
45
- managerCtx.inject(['connection'], (rpcCtx) => {
46
- installMcpManagerRpc(rpcCtx, manager, {
47
- // Refresh the workspace layer before each snapshot, giving the
48
- // Settings page poll a real refresh path for a file created after
49
- // startup.
50
- layerSnapshot: async () => {
51
- await layeredScope.refreshLayers()
52
- return layeredScope.layerSnapshot()
53
- },
54
- oauth,
55
- })
56
- })
57
- installMcpCommands(managerCtx, manager, layeredScope)
58
- installMcpOauthCommands(managerCtx, layeredScope, oauth)
59
- installMcpPromptCommand(managerCtx, manager)
60
- managerCtx.inject(['tools'], (toolCtx) => {
61
- installMcpProxyTool(toolCtx, manager)
62
- installMcpPromotions(toolCtx, manager, layeredScope)
26
+ // deployment without the skills service simply gets no skill entry.
27
+ // `skillInstall` is read once here, from the entry Config.
28
+ const scope = createMcpConfigScope(ctx)
29
+ ctx.effect(() => () => scope.dispose(), 'dsh-mcp-adapter: config scope')
30
+ const skillInstall = scope.get().skillInstall
31
+ const layeredScope = installWorkspaceLayer(ctx, scope)
32
+
33
+ // The Adapter owns a hand-built Settings page, so the shell must not also
34
+ // auto-generate a form page for this entry. While the settings service is
35
+ // mounted, also finish the one-time legacy import after the Loader settles:
36
+ // the platform renames settings.yaml and imports same-named sections only,
37
+ // so the `mcp` section under our `mcp-adapter` entry id is left behind.
38
+ let disposed = false
39
+ ctx.effect(() => () => {
40
+ disposed = true
41
+ }, 'dsh-mcp-adapter: fiber disposal flag')
42
+ ctx.inject(['settings'], (settingsCtx) => {
43
+ settingsCtx.effect(
44
+ () => settingsCtx.settings.configure({ auto: false }, ctx.fiber),
45
+ 'dsh-mcp-adapter: settings presentation',
46
+ )
47
+ void ctx.root.loader
48
+ .await()
49
+ .then(() => (disposed ? undefined : importLegacyMcpSettings(settingsCtx)))
50
+ .catch((error) => settingsCtx.logger?.warn?.(error))
51
+ })
52
+
53
+ ctx.inject(['timer'], (managerCtx) => {
54
+ const store = createFileTokenStore()
55
+ let oauth
56
+ const manager = installMcpManager(managerCtx, layeredScope, {
57
+ connectionFactory: (serverName, config, callbacks, signal, sdk) =>
58
+ createMcpConnection(serverName, config, callbacks, signal, sdk, {
59
+ store,
60
+ onAuthorizationRequired: (url) =>
61
+ oauth?.noteAuthorizationRequired(serverName, url),
62
+ }),
63
+ })
64
+ // The OAuth controller and every command read the LAYERED scope, so a
65
+ // workspace-defined Server is signable and status-visible. Writes still
66
+ // land in the entry Config: the layered scope forwards them there.
67
+ oauth = installMcpOauth(managerCtx, manager, layeredScope, { store })
68
+ managerCtx.inject(['connection'], (rpcCtx) => {
69
+ installMcpManagerRpc(rpcCtx, manager, {
70
+ // Refresh the workspace layer before each snapshot, giving the
71
+ // Settings page poll a real refresh path for a file created after
72
+ // startup.
73
+ layerSnapshot: async () => {
74
+ await layeredScope.refreshLayers()
75
+ return layeredScope.layerSnapshot()
76
+ },
77
+ oauth,
63
78
  })
64
79
  })
80
+ installMcpCommands(managerCtx, manager, layeredScope)
81
+ installMcpOauthCommands(managerCtx, layeredScope, oauth)
82
+ installMcpPromptCommand(managerCtx, manager)
83
+ managerCtx.inject(['tools'], (toolCtx) => {
84
+ installMcpProxyTool(toolCtx, manager)
85
+ installMcpPromotions(toolCtx, manager, layeredScope)
86
+ })
65
87
  })
66
88
  ctx.inject(['skills'], (skillCtx) => installMcpSkill(skillCtx, { skillInstall }))
67
89
  }
68
90
 
69
91
  export * from './commands.js'
92
+ export * from './legacy-import.js'
70
93
  export * from './manager.js'
71
94
  export * from './mcp-connection.js'
72
95
  export * from './oauth-commands.js'
@@ -0,0 +1,121 @@
1
+ import { readFile, writeFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { parse as parseYaml } from 'yaml'
4
+
5
+ import {
6
+ MCP_SETTINGS_NAMESPACE,
7
+ McpConfigSchema,
8
+ validateMcpSettings,
9
+ } from './settings.js'
10
+ import { errorMessage } from './errors.js'
11
+
12
+ // 0.1.7 importLegacyDocument renames the profile's settings.yaml to
13
+ // settings.yaml.imported and moves only sections whose name matches a Loader
14
+ // entry id. The Adapter's legacy section is `mcp` while its entry id is
15
+ // `mcp-adapter`, so the platform leaves it behind; this import finishes the
16
+ // migration exactly once per profile.
17
+ const LEGACY_DOCUMENT_NAMES = ['settings.yaml.imported', 'settings.yaml']
18
+ const LEGACY_SECTION = 'mcp'
19
+ const MARKER_NAME = 'mcp-adapter.legacy-imported'
20
+
21
+ function isPlainObject(value) {
22
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
23
+ }
24
+
25
+ /** Whether the entry still answers with pure defaults and no user section. */
26
+ function isUntouched(descriptor) {
27
+ if (descriptor === undefined) return false
28
+ const user = descriptor.user
29
+ if (user !== undefined && Object.keys(user).length > 0) return false
30
+ const value = descriptor.value
31
+ if (!isPlainObject(value)) return true
32
+ return Object.keys(value.mcpServers ?? {}).length === 0
33
+ }
34
+
35
+ /**
36
+ * Import the removed settings.yaml `mcp` section into this entry's profile
37
+ * Config once. Runs after the Loader settles (the platform import runs first
38
+ * and skips our mismatched section name). A `user` section or non-default
39
+ * servers on the entry mean the config was already written, so the legacy
40
+ * data stays untouched in the imported document; failures only warn, they
41
+ * never throw. On success a marker file prevents re-import, so deliberately
42
+ * clearing every Server later never resurrects the legacy list.
43
+ */
44
+ export async function importLegacyMcpSettings(ctx, deps = {}) {
45
+ const settings = deps.settings ?? ctx.get('settings')
46
+ const profileContext = deps.profileContext ?? ctx.get('profileContext')
47
+ const logger = deps.logger ?? ctx.logger
48
+ if (settings === undefined || profileContext?.home === undefined) return
49
+
50
+ const home = profileContext.home
51
+ const readFileFn = deps.readFile ?? ((path) => readFile(path, 'utf8'))
52
+ const writeFileFn = deps.writeFile ?? ((path, text) => writeFile(path, text))
53
+ const markerPath = join(home, MARKER_NAME)
54
+
55
+ try {
56
+ try {
57
+ await readFileFn(markerPath)
58
+ return // already imported for this profile
59
+ } catch {
60
+ // No marker yet; continue.
61
+ }
62
+
63
+ let documentPath
64
+ let sections
65
+ for (const name of LEGACY_DOCUMENT_NAMES) {
66
+ let text
67
+ try {
68
+ text = await readFileFn(join(home, name))
69
+ } catch {
70
+ continue
71
+ }
72
+ const parsed = parseYaml(text)
73
+ if (isPlainObject(parsed) && isPlainObject(parsed[LEGACY_SECTION])) {
74
+ documentPath = join(home, name)
75
+ sections = parsed
76
+ break
77
+ }
78
+ }
79
+ if (documentPath === undefined) return
80
+
81
+ const descriptor = settings
82
+ .describe({ redactSecrets: true })
83
+ .find((entry) => entry.ns === MCP_SETTINGS_NAMESPACE)
84
+ if (!isUntouched(descriptor)) {
85
+ logger?.info?.(
86
+ 'dsh-mcp-adapter: %s already has written Config; the legacy "mcp" section in %s was left in place',
87
+ MCP_SETTINGS_NAMESPACE,
88
+ documentPath,
89
+ )
90
+ return
91
+ }
92
+
93
+ // Keep only fields this entry declares; anything else in the legacy
94
+ // section would make the reloaded entry fail schema validation.
95
+ const legacy = sections[LEGACY_SECTION]
96
+ const section = {
97
+ mcpServers: isPlainObject(legacy.mcpServers) ? legacy.mcpServers : {},
98
+ ...(typeof legacy.skillInstall === 'string'
99
+ ? { skillInstall: legacy.skillInstall }
100
+ : {}),
101
+ }
102
+ const resolved = McpConfigSchema(section)
103
+ validateMcpSettings({
104
+ mcpServers: resolved.mcpServers.get(),
105
+ skillInstall: resolved.skillInstall.get(),
106
+ })
107
+
108
+ await settings.update(MCP_SETTINGS_NAMESPACE, section)
109
+ await writeFileFn(markerPath, `imported the "${LEGACY_SECTION}" section of ${documentPath}\n`)
110
+ logger?.info?.(
111
+ 'dsh-mcp-adapter: imported the legacy "mcp" settings section from %s into the %s entry',
112
+ documentPath,
113
+ MCP_SETTINGS_NAMESPACE,
114
+ )
115
+ } catch (error) {
116
+ logger?.warn?.(
117
+ 'dsh-mcp-adapter: legacy "mcp" settings import failed; copy the "mcp" section into the profile manually: %s',
118
+ errorMessage(error),
119
+ )
120
+ }
121
+ }
@@ -1,10 +1,11 @@
1
1
  import z from '@deepseek-ai/schemastery'
2
2
 
3
- // The plain namespace string works on both supported harness generations:
4
- // DSH 0.1.1-rc.2 brands it via the (removed-in-0.1.2-rc.1) `settingsNamespace`
5
- // export, while 0.1.2-rc.1 validates the raw string inside `register` itself.
6
- // Branding was type-only, so no runtime behavior changes either way.
7
- export const MCP_SETTINGS_NAMESPACE = 'mcp'
3
+ import { errorMessage } from './errors.js'
4
+
5
+ // DSH 0.1.7 replaced the registered-namespace settings model with profile
6
+ // Loader-entry Config: the Adapter's configuration IS its entry Config, so
7
+ // the settings namespace identity is the entry id from cordis.patch.yml.
8
+ export const MCP_SETTINGS_NAMESPACE = 'mcp-adapter'
8
9
 
9
10
  const optionalString = () => z.string().required(false)
10
11
 
@@ -64,14 +65,21 @@ export const McpServerSchema = z
64
65
  })
65
66
  .description('One configured MCP server.')
66
67
 
67
- export const McpSettingsSchema = z.object({
68
+ // Every Adapter-managed field is volatile: the Loader commits form edits into
69
+ // the running fiber without a restart, and the Settings page / profile editor
70
+ // may only write schema-declared volatile paths. Volatile fields must sit at
71
+ // fixed object paths, so the marks stay on the two top-level fields — server
72
+ // entries inside the dict cannot carry their own volatile marks.
73
+ export const McpConfigSchema = z.object({
68
74
  mcpServers: z
69
75
  .dict(McpServerSchema)
70
76
  .default({})
77
+ .volatile()
71
78
  .description('Global MCP servers, keyed by their unique server name.'),
72
79
  skillInstall: z
73
80
  .union(['file', 'runtime', 'off'])
74
81
  .default('file')
82
+ .volatile()
75
83
  .description(
76
84
  'How the bundled mcp-adapter agent skill is installed: file (copy to '
77
85
  + '$DSH_HOME/skills/mcp-adapter/ so Settings > Skills lists it; an '
@@ -109,7 +117,9 @@ function rejectUnknownKeys(value, allowed, path) {
109
117
 
110
118
  /**
111
119
  * Cross-field checks that the serializable Schemastery shape cannot express.
112
- * SettingsProvider runs this after schema validation and before every persist.
120
+ * The old SettingsProvider ran this before every persist; the 0.1.7 Config
121
+ * write path validates only the schema, so this runs at Host consumption
122
+ * instead (the Adapter serves no Servers for a semantically invalid section).
113
123
  */
114
124
  export function validateMcpSettings(value) {
115
125
  rejectUnknownKeys(value, TOP_LEVEL_KEYS, 'mcp')
@@ -181,18 +191,112 @@ export function validateMcpSettings(value) {
181
191
  }
182
192
  }
183
193
 
194
+ // Cosmokit identifies volatile references across library copies through this
195
+ // shared symbol; reading it directly keeps the Host free of a cosmokit import.
196
+ const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write')
197
+
198
+ function unwrapVolatile(value, fallback) {
199
+ if (typeof value === 'object' && value !== null && VOLATILE_WRITE in value) {
200
+ return value.get()
201
+ }
202
+ return value ?? fallback
203
+ }
204
+
205
+ export function stableJson(value) {
206
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`
207
+ if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'undefined'
208
+ const keys = Object.keys(value).sort()
209
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`
210
+ }
211
+
212
+ /**
213
+ * Read the live Adapter Config from the fiber and enforce the cross-field
214
+ * rules. A section that schemastery accepts but these rules reject fails
215
+ * closed to an empty server list with one deduplicated warning, so a
216
+ * hand-edited profile can never brick the connection manager.
217
+ */
218
+ export function readMcpConfig(config = {}, onInvalid) {
219
+ const value = {
220
+ ...config,
221
+ mcpServers: unwrapVolatile(config.mcpServers, {}),
222
+ skillInstall: unwrapVolatile(config.skillInstall, 'file'),
223
+ }
224
+ try {
225
+ // Validation sees the whole resolved Config, so unknown fields still
226
+ // reject the section; the returned value only carries declared fields.
227
+ validateMcpSettings(value)
228
+ return { mcpServers: value.mcpServers, skillInstall: value.skillInstall }
229
+ } catch (error) {
230
+ onInvalid?.(errorMessage(error))
231
+ return { mcpServers: {}, skillInstall: 'file' }
232
+ }
233
+ }
234
+
184
235
  /**
185
- * Register Config while the optional Host settings service exists. The scope
186
- * belongs to the injected fiber, so provider reload or Adapter disposal removes
187
- * the namespace and every watcher cleanly.
236
+ * Wrap the fiber's volatile entry Config in the scope surface every Adapter
237
+ * feature consumes (the same { get, watch, update, mutate } the old settings
238
+ * scope provided):
239
+ * - `get()` unwraps the volatile references and fail-closes on invalid data;
240
+ * - `watch(listener)` fires `(next, previous)` after committed volatile
241
+ * updates (`loader/volatile-update`) whose value actually changed;
242
+ * - `update(patch)` / `mutate(ops)` write through the settings service onto
243
+ * this entry and throw when no settings service is mounted.
188
244
  */
189
- export function installMcpSettings(ctx, activate) {
190
- ctx.inject(['settings'], (settingsCtx) => {
191
- const scope = settingsCtx.settings.register(
192
- MCP_SETTINGS_NAMESPACE,
193
- McpSettingsSchema,
194
- { validate: validateMcpSettings },
195
- )
196
- return activate?.(settingsCtx, scope)
245
+ export function createMcpConfigScope(ctx, options = {}) {
246
+ const warn = options.warn ?? ((message) => ctx.logger?.warn?.(message))
247
+ const settingsService = options.settings ?? (() => ctx.get('settings'))
248
+ const listeners = new Set()
249
+ let warnedInvalid
250
+ let last = readCurrent()
251
+
252
+ function readCurrent() {
253
+ return readMcpConfig(ctx.config, (message) => {
254
+ if (message === warnedInvalid) return
255
+ warnedInvalid = message
256
+ warn(`dsh-mcp-adapter: invalid MCP Config, serving no Servers: ${message}`)
257
+ })
258
+ }
259
+
260
+ const off = ctx.on('loader/volatile-update', () => {
261
+ const next = readCurrent()
262
+ if (stableJson(next) === stableJson(last)) return
263
+ const previous = last
264
+ last = next
265
+ for (const listener of [...listeners]) {
266
+ try {
267
+ listener(next, previous)
268
+ } catch {
269
+ // A stale or failing listener must not break Config notification.
270
+ }
271
+ }
197
272
  })
273
+
274
+ function requireSettings(method) {
275
+ const settings = settingsService()
276
+ if (settings === undefined) {
277
+ throw new Error(
278
+ `dsh-mcp-adapter: settings ${method} unavailable: the DSH settings service is not `
279
+ + 'mounted in this profile, so MCP Config cannot be written.',
280
+ )
281
+ }
282
+ return settings
283
+ }
284
+
285
+ return {
286
+ get: () => readCurrent(),
287
+ watch(listener) {
288
+ listeners.add(listener)
289
+ return () => listeners.delete(listener)
290
+ },
291
+ update(patch) {
292
+ return requireSettings('update').update(MCP_SETTINGS_NAMESPACE, patch)
293
+ },
294
+ mutate(ops) {
295
+ return requireSettings('mutate').mutate(MCP_SETTINGS_NAMESPACE, ops)
296
+ },
297
+ dispose() {
298
+ off?.()
299
+ listeners.clear()
300
+ },
301
+ }
198
302
  }
@@ -2,7 +2,7 @@ import { watch as fsWatch } from 'node:fs'
2
2
  import { readFile as fsReadFile } from 'node:fs/promises'
3
3
  import { join } from 'node:path'
4
4
 
5
- import { McpServerSchema, validateMcpSettings } from './settings.js'
5
+ import { McpServerSchema, stableJson, validateMcpSettings } from './settings.js'
6
6
  import { errorMessage } from './errors.js'
7
7
 
8
8
  function isRecord(value) {
@@ -95,13 +95,6 @@ export function mergeMcpConfigs(globalServers, workspaceServers) {
95
95
  return { servers, sources }
96
96
  }
97
97
 
98
- function stableJson(value) {
99
- if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`
100
- if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'undefined'
101
- const keys = Object.keys(value).sort()
102
- return `{${keys.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`
103
- }
104
-
105
98
  function defaultWatchDirectory(directory, onChange) {
106
99
  const watcher = fsWatch(directory, () => onChange())
107
100
  watcher.on('error', () => {
@@ -120,7 +113,7 @@ function publicServerSummary(config) {
120
113
  }
121
114
 
122
115
  /**
123
- * Wrap the registered global Config scope with the workspace layer.
116
+ * Wrap the entry Config scope with the workspace layer.
124
117
  *
125
118
  * `get()` returns the merged namespace value and `layerSnapshot()` reports
126
119
  * which source each Server came from. Watch listeners see global Config edits
@@ -287,7 +280,7 @@ export function createLayeredScope(globalScope, options = {}) {
287
280
  }
288
281
 
289
282
  /**
290
- * Register the workspace Config layer on the settings fiber. Disposing the
283
+ * Register the workspace Config layer on the Adapter fiber. Disposing the
291
284
  * fiber stops the global watch and the best-effort `.dsh` directory watch.
292
285
  */
293
286
  export function installWorkspaceLayer(ctx, scope, options = {}) {