@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/CHANGELOG.md +24 -0
- package/CONTEXT.md +5 -1
- package/README.md +30 -28
- package/docs/adr/0002-config-in-dsh-settings-namespace.md +2 -0
- package/docs/adr/0009-dual-generation-settings-api.md +4 -0
- package/docs/adr/0010-loader-entry-config-model.md +13 -0
- package/lib/client.js +15 -9
- package/lib/client.js.map +3 -3
- package/package.json +8 -8
- package/src/host/index.js +67 -44
- package/src/host/legacy-import.js +121 -0
- package/src/host/settings.js +122 -18
- package/src/host/workspace-config.js +3 -10
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 {
|
|
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
|
-
//
|
|
17
|
-
//
|
|
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.
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
+
}
|
package/src/host/settings.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import z from '@deepseek-ai/schemastery'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
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
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
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
|
|
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 = {}) {
|