@auggieteo/dsh-mcp-adapter 0.2.1 → 0.2.2

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 CHANGED
@@ -4,6 +4,16 @@ All notable changes to this project are documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [v0.2.2] - 2026-09-06
8
+
9
+ ### Added
10
+
11
+ - `skillInstall` Config field (`file` by default, `runtime`, or `off`) controlling how the bundled `mcp-adapter` agent skill is installed. `file` copies `skills/mcp-adapter/SKILL.md` verbatim to `$DSH_HOME/skills/mcp-adapter/SKILL.md` at startup (atomic temp-file-then-rename write, never a symlink), so the DSH filesystem skill provider lists it under Settings > Skills > DSH skills. `runtime` keeps the previous in-memory registration; `off` installs nothing. The mode is read once at Adapter startup.
12
+
13
+ ### Changed
14
+
15
+ - The bundled agent skill now installs as a real file by default instead of registering only in memory. The installed file is never overwritten: an identical file is left untouched, and a file edited by a human wins with one Host warning naming the installed path and the package version — delete `$DSH_HOME/skills/mcp-adapter/` and restart DSH to refresh it from the package. A failed file install (for example a read-only `$DSH_HOME`) falls back to the runtime registration with a warning; the skill stays a convenience and never throws.
16
+
7
17
  ## [v0.2.1] - 2026-09-06
8
18
 
9
19
  ### Added
@@ -56,5 +66,6 @@ Everything from v0.1.2 through this release: the six deferred v1 features (#8–
56
66
 
57
67
  Initial releases of the Adapter through v1 issues #1–#7: proxy-first tool surface, Config in the DSH settings namespace, promotion registry, lazy lifecycle, output guard, settings page, and the `/mcp-adapter` RPC channel. See [docs/verification/v1-e2e.md](docs/verification/v1-e2e.md).
58
68
 
69
+ [v0.2.2]: https://github.com/auggie246/dsh-mcp-adapter/compare/v0.2.1...v0.2.2
59
70
  [v0.2.0]: https://github.com/auggie246/dsh-mcp-adapter/compare/v0.1.2...v0.2.0
60
71
  [v0.1.2]: https://github.com/auggie246/dsh-mcp-adapter/releases/tag/v0.1.2
package/README.md CHANGED
@@ -130,10 +130,13 @@ mcp:
130
130
  url: https://mcp.example.com/other
131
131
  auth: oauth
132
132
  scopes: [repo, read:org]
133
+ skillInstall: file
133
134
  ```
134
135
 
135
136
  Each Server configures exactly one Transport: `command` for stdio, or `url` for streamable HTTP with SSE fallback. Adapter extension fields are `auth` (`headers` by default, or `oauth` for HTTP Servers), `scopes` (OAuth scopes, requires `auth: oauth`), `disabled`, `autoAllow`, `lifecycle` (`lazy`, `eager`, `keep-alive`, or `lazy-keep-alive`; default `lazy`), `idleTimeoutMinutes` (default `10`), and `promotedTools`.
136
137
 
138
+ The one top-level Adapter field is `skillInstall` (`file` by default, `runtime`, or `off`); see [Agent skill](#agent-skill).
139
+
137
140
  The schema rejects unknown fields and invalid transport combinations before they reach `$DSH_HOME/settings.yaml`. Each `env` and `headers` value has the DSH `secret` schema role. Wire views retain each key and redact its value.
138
141
 
139
142
  #### Workspace Config layer
@@ -211,7 +214,13 @@ The SDK transports refresh expired access tokens through the stored refresh toke
211
214
 
212
215
  ### Agent skill
213
216
 
214
- The package bundles a model-facing skill in `skills/mcp-adapter/SKILL.md`. When the DSH `skills` service is present, the Adapter registers it at startup as a runtime skill named `mcp-adapter`, so agents learn the search → describe → call workflow, how to read failure messages (including the OAuth sign-in hint), and which commands belong to the human. The frontmatter drives routing; edit the file to change the guidance, and a DSH restart picks the edit up.
217
+ The package bundles a model-facing skill in `skills/mcp-adapter/SKILL.md`, so agents learn the search → describe → call workflow, how to read failure messages (including the OAuth sign-in hint), and which commands belong to the human. The top-level `skillInstall` Config field controls how the Adapter installs it; the mode is read once at Adapter startup, so a change takes effect on the next DSH restart.
218
+
219
+ - `file` (default): on startup the Adapter copies the bundled `SKILL.md` verbatim to `$DSH_HOME/skills/mcp-adapter/SKILL.md` (an atomic temp-file-then-rename write, never a symlink). The DSH filesystem skill provider discovers the file, so the skill shows under **Settings > Skills > DSH skills** and joins the session catalog without a restart of an already-running session. When the file install fails, the Adapter falls back to the runtime registration below and logs one warning.
220
+ - `runtime`: the Adapter registers the skill in memory on the DSH `skills` service only. It reaches agents but never appears on the Settings > Skills page.
221
+ - `off`: the Adapter installs no skill.
222
+
223
+ The installed file is never overwritten. If `$DSH_HOME/skills/mcp-adapter/SKILL.md` already exists, your edit wins: an identical file is left untouched, and a differing file gets one Host warning naming the installed path and the package version. To refresh the installed copy from the package, delete `$DSH_HOME/skills/mcp-adapter/` and restart DSH. Editing the installed file changes what the session catalog serves; the edit survives plugin restarts.
215
224
 
216
225
  ## Architecture
217
226
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@auggieteo/dsh-mcp-adapter",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "DSH plugin connecting the agent to MCP servers through one mcp proxy tool and a Settings > MCP page.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/host/index.js CHANGED
@@ -19,9 +19,13 @@ export const inject = []
19
19
 
20
20
  export function apply(ctx) {
21
21
  // The bundled agent skill is independent of every other Adapter feature: a
22
- // deployment without the skills service simply gets no skill entry.
23
- ctx.inject(['skills'], (skillCtx) => installMcpSkill(skillCtx))
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
24
27
  installMcpSettings(ctx, (settingsCtx, scope) => {
28
+ skillInstall = scope.get()?.skillInstall
25
29
  const layeredScope = installWorkspaceLayer(settingsCtx, scope)
26
30
  settingsCtx.inject(['timer'], (managerCtx) => {
27
31
  const store = createFileTokenStore()
@@ -59,6 +63,7 @@ export function apply(ctx) {
59
63
  })
60
64
  })
61
65
  })
66
+ ctx.inject(['skills'], (skillCtx) => installMcpSkill(skillCtx, { skillInstall }))
62
67
  }
63
68
 
64
69
  export * from './commands.js'
@@ -69,9 +69,18 @@ export const McpSettingsSchema = z.object({
69
69
  .dict(McpServerSchema)
70
70
  .default({})
71
71
  .description('Global MCP servers, keyed by their unique server name.'),
72
+ skillInstall: z
73
+ .union(['file', 'runtime', 'off'])
74
+ .default('file')
75
+ .description(
76
+ 'How the bundled mcp-adapter agent skill is installed: file (copy to '
77
+ + '$DSH_HOME/skills/mcp-adapter/ so Settings > Skills lists it; an '
78
+ + 'existing file is never overwritten), runtime (register in memory '
79
+ + 'only), or off (no skill). Read once at Adapter startup.',
80
+ ),
72
81
  })
73
82
 
74
- const TOP_LEVEL_KEYS = new Set(['mcpServers'])
83
+ const TOP_LEVEL_KEYS = new Set(['mcpServers', 'skillInstall'])
75
84
  const SERVER_KEYS = new Set([
76
85
  'command',
77
86
  'args',
package/src/host/skill.js CHANGED
@@ -1,4 +1,12 @@
1
- import { readFileSync as defaultReadFileSync } from 'node:fs'
1
+ import {
2
+ mkdirSync as defaultMkdirSync,
3
+ readFileSync as defaultReadFileSync,
4
+ renameSync as defaultRenameSync,
5
+ writeFileSync as defaultWriteFileSync,
6
+ } from 'node:fs'
7
+ import { createRequire } from 'node:module'
8
+ import { homedir as defaultHomedir } from 'node:os'
9
+ import { join } from 'node:path'
2
10
  import { fileURLToPath } from 'node:url'
3
11
 
4
12
  import { errorMessage } from './errors.js'
@@ -7,8 +15,26 @@ import { errorMessage } from './errors.js'
7
15
  export const SKILL_DIR = fileURLToPath(new URL('../../skills/mcp-adapter/', import.meta.url))
8
16
 
9
17
  const SKILL_FILE = 'SKILL.md'
18
+ const SKILL_TEMP_FILE = '.SKILL.md.tmp'
10
19
  const SKILL_NAME = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/
11
20
 
21
+ /** The Adapter package version, read lazily so a missing manifest never breaks load. */
22
+ function packageVersion() {
23
+ try {
24
+ return createRequire(import.meta.url)('../../package.json').version
25
+ } catch {
26
+ return 'unknown'
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Resolve the DSH skills root the same way the harness does: `$DSH_HOME`
32
+ * when set, otherwise `.dsh` under the OS home directory.
33
+ */
34
+ export function resolveSkillsRoot(env = process.env, homedir = defaultHomedir) {
35
+ return join(env.DSH_HOME ?? join(homedir(), '.dsh'), 'skills')
36
+ }
37
+
12
38
  /**
13
39
  * Split one `SKILL.md` into its frontmatter fields and body. The block must
14
40
  * open the file: `---`, flat `key: value` lines, then a closing `---`. Only
@@ -34,16 +60,96 @@ export function parseSkillFrontmatter(text) {
34
60
  }
35
61
 
36
62
  /**
37
- * Register the bundled `mcp-adapter` skill on the `skills` service so agents
38
- * discover how to drive the Proxy Tool. The body lives in
39
- * `skills/mcp-adapter/SKILL.md` next to the package root, so a human can edit
40
- * the wording without touching code; frontmatter still drives routing.
63
+ * Copy the bundled `SKILL.md` to `$DSH_HOME/skills/<name>/SKILL.md` so the
64
+ * filesystem skill provider (and with it Settings > Skills) discovers it.
65
+ * The copy is atomic — temp file in the target directory, then rename and
66
+ * never a symlink or hardlink. An installed file is never overwritten: when
67
+ * it exists and matches, nothing happens; when it differs, the human's edit
68
+ * wins and one warning names the path and the package version. Returns true
69
+ * when the file now carries the skill (install done or already present).
70
+ */
71
+ function installSkillFile(ctx, text, name, {
72
+ readInstalledFileSync,
73
+ writeFileSync,
74
+ mkdirSync,
75
+ renameSync,
76
+ env,
77
+ homedir,
78
+ }) {
79
+ const warn = (reason) => {
80
+ ctx.logger?.warn?.(`dsh-mcp-adapter: ${reason}`)
81
+ }
82
+ const targetDir = join(resolveSkillsRoot(env, homedir), name)
83
+ const target = join(targetDir, SKILL_FILE)
84
+ let installed
85
+ try {
86
+ installed = String(readInstalledFileSync(target, 'utf8'))
87
+ } catch (error) {
88
+ if (error?.code !== 'ENOENT') {
89
+ warn(
90
+ `cannot read the installed skill file ${target} (${errorMessage(error)}); `
91
+ + 'registering the bundled skill at runtime instead',
92
+ )
93
+ return false
94
+ }
95
+ }
96
+ if (installed !== undefined) {
97
+ if (installed === text) return true
98
+ // The human's edit wins: leave the file untouched and treat it as the
99
+ // one definition for this name.
100
+ warn(
101
+ `the installed skill file ${target} differs from the bundled copy `
102
+ + `(dsh-mcp-adapter ${packageVersion()}); delete ${targetDir} to refresh `
103
+ + 'it from the package on the next start',
104
+ )
105
+ return true
106
+ }
107
+ try {
108
+ mkdirSync(targetDir, { recursive: true })
109
+ const temp = join(targetDir, SKILL_TEMP_FILE)
110
+ writeFileSync(temp, text, 'utf8')
111
+ renameSync(temp, target)
112
+ return true
113
+ } catch (error) {
114
+ warn(
115
+ `cannot install the skill file at ${target} (${errorMessage(error)}); `
116
+ + 'registering the bundled skill at runtime instead',
117
+ )
118
+ return false
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Install the bundled `mcp-adapter` skill so agents discover how to drive the
124
+ * Proxy Tool. The body lives in `skills/mcp-adapter/SKILL.md` next to the
125
+ * package root, so a human can edit the wording without touching code;
126
+ * frontmatter still drives routing.
41
127
  *
42
- * The skill is a convenience, never a dependency: an unreadable file or bad
43
- * frontmatter logs a warning and registers nothing. `readFileSync` and `dir`
44
- * are injectable for tests.
128
+ * The `skillInstall` mode comes from the Adapter Config (default `'file'`).
129
+ * `'file'` copies the skill to `$DSH_HOME/skills/<name>/SKILL.md` and lets
130
+ * the filesystem provider pick it up — one definition per name, so the
131
+ * runtime registration is skipped. `'runtime'` registers directly on the
132
+ * `skills` service as before. `'off'` installs nothing.
133
+ *
134
+ * The skill is a convenience, never a dependency: an unreadable file, bad
135
+ * frontmatter, or a failed install logs a warning and never throws; a failed
136
+ * file install falls back to the runtime registration. Every filesystem
137
+ * access (`readFileSync` for the bundled copy, `readInstalledFileSync` for
138
+ * the installed one, plus `writeFileSync`, `mkdirSync`, and `renameSync`)
139
+ * and `dir`, `env`, and `homedir` are injectable for tests.
45
140
  */
46
- export function installMcpSkill(ctx, { readFileSync = defaultReadFileSync, dir = SKILL_DIR } = {}) {
141
+ export function installMcpSkill(ctx, {
142
+ readFileSync = defaultReadFileSync,
143
+ readInstalledFileSync = defaultReadFileSync,
144
+ writeFileSync = defaultWriteFileSync,
145
+ mkdirSync = defaultMkdirSync,
146
+ renameSync = defaultRenameSync,
147
+ env = process.env,
148
+ homedir = defaultHomedir,
149
+ dir = SKILL_DIR,
150
+ skillInstall = 'file',
151
+ } = {}) {
152
+ if (skillInstall === 'off') return undefined
47
153
  const fail = (reason) => {
48
154
  ctx.logger?.warn?.(`dsh-mcp-adapter: bundled skill unavailable (${reason})`)
49
155
  return undefined
@@ -57,7 +163,7 @@ export function installMcpSkill(ctx, { readFileSync = defaultReadFileSync, dir =
57
163
  if (typeof description !== 'string' || description === '') {
58
164
  return fail('frontmatter requires a non-empty description')
59
165
  }
60
- return ctx.skills.register({
166
+ const register = () => ctx.skills.register({
61
167
  name,
62
168
  description,
63
169
  ...(whenToUse === undefined ? {} : { whenToUse }),
@@ -65,6 +171,15 @@ export function installMcpSkill(ctx, { readFileSync = defaultReadFileSync, dir =
65
171
  content: body,
66
172
  resourceBase: { kind: 'directory', path: dir },
67
173
  })
174
+ if (skillInstall !== 'runtime') {
175
+ const installed = installSkillFile(ctx, text, name, {
176
+ readInstalledFileSync, writeFileSync, mkdirSync, renameSync, env, homedir,
177
+ })
178
+ // One definition per name: the filesystem provider owns the skill once
179
+ // the file is in place (or the human's edit already covers it).
180
+ if (installed) return undefined
181
+ }
182
+ return register()
68
183
  } catch (error) {
69
184
  return fail(errorMessage(error))
70
185
  }