dsh-plugin-capabilities 0.1.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/http.ts ADDED
@@ -0,0 +1,42 @@
1
+ /** HTTP helpers: JSON body reading, same-origin check, JSON responses. */
2
+
3
+ import type { IncomingMessage, ServerResponse } from 'node:http'
4
+
5
+ /** Read and JSON-parse a request body, bounded to 1 MiB (skill bodies live here). */
6
+ export async function readJsonBody(request: IncomingMessage): Promise<unknown> {
7
+ const chunks: Buffer[] = []
8
+ let received = 0
9
+ for await (const chunk of request) {
10
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
11
+ received += buffer.length
12
+ if (received > 1024 * 1024) throw new Error('request body too large')
13
+ chunks.push(buffer)
14
+ }
15
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
16
+ }
17
+
18
+ /**
19
+ * True when the request is a same-origin POST a browser page could have made.
20
+ * CSRF fence (the loopback server already trusts its local peer for reads).
21
+ */
22
+ export function sameOrigin(request: IncomingMessage): boolean {
23
+ const origin = request.headers.origin
24
+ const host = request.headers.host
25
+ if (origin === undefined || host === undefined) return false
26
+ try {
27
+ const parsed = new URL(origin)
28
+ return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.host === host
29
+ } catch {
30
+ return false
31
+ }
32
+ }
33
+
34
+ /** Write a JSON response. */
35
+ export function sendJson(response: ServerResponse, status: number, body: unknown): void {
36
+ const payload = JSON.stringify(body)
37
+ response.writeHead(status, {
38
+ 'content-type': 'application/json; charset=utf-8',
39
+ 'cache-control': 'no-store',
40
+ })
41
+ response.end(payload)
42
+ }
package/src/index.ts ADDED
@@ -0,0 +1,56 @@
1
+ /** dsh-plugin-capabilities host entry: mount the manager's HTTP routes once
2
+ * the profile composes both the web server and the skill registry, and mount
3
+ * a host-plane filesystem skill provider so the Settings page sees a live
4
+ * catalog (the web composition deliberately leaves the host row to presets). */
5
+
6
+ import type { Context } from '@deepseek-ai/cordis'
7
+ import { agentSkillRoots } from './agents.ts'
8
+ import { argvProfile, profileDir } from './profile.ts'
9
+ import { mountCapabilitiesRoutes } from './routes.ts'
10
+ import type { CapabilitiesHost } from './types.ts'
11
+
12
+ export const name = 'dsh-plugin-capabilities'
13
+
14
+ /** Optional cordis.yml configuration; profile defaults to the booted one. */
15
+ export interface Config {
16
+ /** Profile whose patch layer holds the MCP rows; defaults to argv or `web`. */
17
+ profile?: string
18
+ }
19
+
20
+ export const inject = ['webServer', 'skills']
21
+
22
+ /** The provider plugin's structural shape (name/apply export). */
23
+ interface FilesystemSkillPlugin {
24
+ name: string
25
+ apply(context: Context, config?: unknown): void
26
+ }
27
+
28
+ export function apply(ctx: Context, config?: Config): void {
29
+ const profile = config?.profile ?? argvProfile() ?? 'web'
30
+ ctx.inject(['webServer', 'skills'], (hostCtx: Context) => {
31
+ // The web bundle disables the host-plane `skill-filesystem` row on
32
+ // purpose (presets own per-session discovery). The Settings manager
33
+ // mounts its own host-plane provider as a CHILD of this plugin: it dies
34
+ // with us, registers into the registry's global layer, and preset layers
35
+ // keep their semantics (nearest layer still wins duplicate names). Other
36
+ // agents' skill roots (~/.claude/skills, ~/.codex/skills) join as custom
37
+ // dirs — zero-copy, live-synced both ways. A failed load only means an
38
+ // empty catalog — the routes keep serving.
39
+ void (async () => {
40
+ try {
41
+ const mod = (await import('@deepseek-ai/dsh-skill-filesystem')) as unknown as
42
+ (FilesystemSkillPlugin & { default?: FilesystemSkillPlugin })
43
+ const plugin = mod.default ?? mod
44
+ const roots = agentSkillRoots()
45
+ hostCtx.plugin(plugin, roots.length > 0 ? { customSkillDirs: roots } : {})
46
+ } catch {
47
+ // Unresolvable provider: skills list stays empty; MCP tab unaffected.
48
+ }
49
+ })()
50
+
51
+ ctx.effect(
52
+ () => mountCapabilitiesRoutes(hostCtx as unknown as CapabilitiesHost, { profileDirPath: profileDir(profile) }),
53
+ 'dsh-plugin-capabilities: http routes',
54
+ )
55
+ })
56
+ }
@@ -0,0 +1,85 @@
1
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterAll, describe, expect, it } from 'vitest'
5
+ import { listMcp, removeMcp, setMcpDisabled, upsertMcp, validateMcpInput, type McpInput } from './mcp.ts'
6
+
7
+ const root = mkdtempSync(join(tmpdir(), 'dsh-caps-mcp-'))
8
+ const profile = join(root, 'profiles', 'web')
9
+ afterAll(() => rmSync(root, { recursive: true, force: true }))
10
+
11
+ const patch = () => join(profile, 'cordis.patch.yml')
12
+
13
+ const stdio: McpInput = {
14
+ id: '',
15
+ serverName: 'github',
16
+ transport: 'stdio',
17
+ command: 'npx',
18
+ args: ['-y', '@modelcontextprotocol/server-github'],
19
+ env: { GITHUB_TOKEN: 'secret' },
20
+ }
21
+
22
+ describe('validateMcpInput', () => {
23
+ it('enforces serverName grammar and per-transport requirements', () => {
24
+ expect(validateMcpInput(stdio)).toBeNull()
25
+ expect(validateMcpInput({ ...stdio, serverName: 'has space' })).toContain('serverName')
26
+ expect(validateMcpInput({ ...stdio, command: ' ' })).toContain('command')
27
+ expect(validateMcpInput({ id: '', serverName: 'web', transport: 'streamable-http' })).toContain('url')
28
+ expect(validateMcpInput({ id: '', serverName: 'web', transport: 'streamable-http', url: 'ftp://x' })).toContain('url')
29
+ expect(validateMcpInput({ id: 'a/b', serverName: 'web', transport: 'streamable-http', url: 'http://x' })).toContain('id')
30
+ })
31
+ })
32
+
33
+ describe('profile patch CRUD', () => {
34
+ it('creates rows in a missing file, dedupes ids, and reads them back', () => {
35
+ const id1 = upsertMcp(profile, stdio)
36
+ expect(id1).toBe('mcp-github')
37
+ const id2 = upsertMcp(profile, { ...stdio, env: undefined })
38
+ expect(id2).toBe('mcp-github-2')
39
+
40
+ const rows = listMcp(profile)
41
+ expect(rows).toHaveLength(2)
42
+ expect(rows[0]).toMatchObject({ id: 'mcp-github', serverName: 'github', transport: 'stdio', command: 'npx', disabled: false })
43
+ expect(rows[0].args).toEqual(['-y', '@modelcontextprotocol/server-github'])
44
+ expect(rows[0].env).toEqual({ GITHUB_TOKEN: 'secret' })
45
+ })
46
+
47
+ it('preserves foreign rows and comments across edits', () => {
48
+ writeFileSync(patch(), [
49
+ '# user comment',
50
+ '- id: something-else',
51
+ " name: 'other-plugin'",
52
+ ' config:',
53
+ ' a: 1',
54
+ '',
55
+ ].join('\n'))
56
+ upsertMcp(profile, { ...stdio, serverName: 'web', transport: 'streamable-http', url: 'http://localhost:3000/mcp' })
57
+ const text = readFileSync(patch(), 'utf8')
58
+ expect(text).toContain('# user comment')
59
+ expect(text).toContain('something-else')
60
+ expect(text).toContain('streamable-http')
61
+ expect(listMcp(profile)).toHaveLength(1)
62
+ expect(listMcp(profile)[0]).toMatchObject({ transport: 'streamable-http', url: 'http://localhost:3000/mcp' })
63
+ })
64
+
65
+ it('updates an existing row in place when the id matches', () => {
66
+ upsertMcp(profile, stdio)
67
+ expect(listMcp(profile)).toHaveLength(2)
68
+ upsertMcp(profile, { ...stdio, id: 'mcp-github', command: 'pnpm' })
69
+ const rows = listMcp(profile)
70
+ expect(rows).toHaveLength(2)
71
+ expect(rows.find(row => row.id === 'mcp-github')?.command).toBe('pnpm')
72
+ })
73
+
74
+ it('toggles disabled and removes rows', () => {
75
+ expect(setMcpDisabled(profile, 'mcp-github', true)).toBe(true)
76
+ expect(listMcp(profile).find(row => row.id === 'mcp-github')?.disabled).toBe(true)
77
+ expect(setMcpDisabled(profile, 'mcp-github', false)).toBe(true)
78
+ expect(listMcp(profile).find(row => row.id === 'mcp-github')?.disabled).toBe(false)
79
+ expect(setMcpDisabled(profile, 'no-such', true)).toBe(false)
80
+
81
+ expect(removeMcp(profile, 'mcp-github')).toBe(true)
82
+ expect(listMcp(profile).find(row => row.id === 'mcp-github')).toBeUndefined()
83
+ expect(removeMcp(profile, 'mcp-github')).toBe(false)
84
+ })
85
+ })
package/src/mcp.ts ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * MCP server rows in the profile's own patch layer: one
3
+ * `@deepseek-ai/dsh-mcp-client` row per server. The YAML document API keeps
4
+ * foreign rows and comments intact across edits. Row changes need a dsh
5
+ * restart to compose — callers surface that as a pending-restart notice.
6
+ */
7
+
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+ import { parseDocument, Document, type YAMLMap, type YAMLSeq } from 'yaml'
11
+ /** The plugin every managed row instantiates. */
12
+ export const MCP_PLUGIN = '@deepseek-ai/dsh-mcp-client'
13
+
14
+ /** MCP serverName grammar (dsh-mcp-client's contract). */
15
+ export const SERVER_NAME_RE = /^[A-Za-z0-9_-]{1,32}$/
16
+
17
+ /** Transport choices the client supports. */
18
+ export type McpTransport = 'stdio' | 'streamable-http'
19
+
20
+ /** One managed row, as shown to the browser. */
21
+ export interface McpRow {
22
+ id: string
23
+ serverName: string
24
+ transport: McpTransport
25
+ disabled: boolean
26
+ command?: string
27
+ args?: string[]
28
+ env?: Record<string, string>
29
+ cwd?: string
30
+ url?: string
31
+ headers?: Record<string, string>
32
+ }
33
+
34
+ /** Write request for one server row (id empty = create). */
35
+ export type McpInput = Omit<McpRow, 'disabled'> & { disabled?: boolean }
36
+
37
+ /** Load the profile patch as a YAML document; `[]` for a missing file. */
38
+ function loadPatch(profileDirPath: string): Document {
39
+ const path = join(profileDirPath, 'cordis.patch.yml')
40
+ const text = existsSync(path) ? readFileSync(path, 'utf8') : '[]'
41
+ return parseDocument(text)
42
+ }
43
+
44
+ function savePatch(profileDirPath: string, doc: Document): void {
45
+ mkdirSync(profileDirPath, { recursive: true })
46
+ writeFileSync(join(profileDirPath, 'cordis.patch.yml'), String(doc), 'utf8')
47
+ }
48
+
49
+ /** Wrap a plain value into a YAML node (yaml v2 exposes no standalone createNode). */
50
+ function toNode<T>(value: unknown): T {
51
+ return new Document(value as never).contents as T
52
+ }
53
+
54
+ /** The patch row sequence; an empty file's null root becomes an empty seq. */
55
+ function rowSeq(doc: Document): YAMLSeq<YAMLMap> {
56
+ if (doc.contents === null) doc.contents = toNode<YAMLSeq<YAMLMap>>([])
57
+ return doc.contents as YAMLSeq<YAMLMap>
58
+ }
59
+
60
+ /** Rows whose `name` is the MCP client plugin. */
61
+ function mcpRows(doc: Document): YAMLMap[] {
62
+ return (rowSeq(doc).items ?? []).filter(item => item.get('name') === MCP_PLUGIN)
63
+ }
64
+
65
+ function isStringMap(value: unknown): value is Record<string, string> {
66
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
67
+ return Object.values(value).every(entry => typeof entry === 'string')
68
+ }
69
+
70
+ /** Read every mcp-client row in the profile layer. */
71
+ export function listMcp(profileDirPath: string): McpRow[] {
72
+ const doc = loadPatch(profileDirPath)
73
+ return mcpRows(doc).map(item => {
74
+ // config is a YAMLMap node — materialize it before property access.
75
+ const configNode = item.get('config') as unknown
76
+ const plain = (typeof configNode === 'object' && configNode !== null && typeof (configNode as { toJS?: unknown }).toJS === 'function'
77
+ ? (configNode as { toJS(document: Document): unknown }).toJS(doc)
78
+ : {}) as Record<string, unknown>
79
+ return {
80
+ id: String(item.get('id') ?? ''),
81
+ serverName: String(plain.serverName ?? ''),
82
+ transport: plain.transport === 'streamable-http' ? 'streamable-http' : 'stdio',
83
+ disabled: item.get('disabled') === true,
84
+ ...(typeof plain.command === 'string' && plain.command !== '' ? { command: plain.command } : {}),
85
+ ...(Array.isArray(plain.args) ? { args: plain.args.map(String) } : {}),
86
+ ...(isStringMap(plain.env) ? { env: plain.env } : {}),
87
+ ...(typeof plain.cwd === 'string' && plain.cwd !== '' ? { cwd: plain.cwd } : {}),
88
+ ...(typeof plain.url === 'string' && plain.url !== '' ? { url: plain.url } : {}),
89
+ ...(isStringMap(plain.headers) ? { headers: plain.headers } : {}),
90
+ }
91
+ })
92
+ }
93
+
94
+ /** Validate one write request; returns the rejection reason or null. */
95
+ export function validateMcpInput(input: McpInput): string | null {
96
+ if (!SERVER_NAME_RE.test(input.serverName)) return 'serverName must be 1-32 chars of A-Z a-z 0-9 _ -'
97
+ if (input.id.includes('/') || input.id.includes('..')) return 'invalid id'
98
+ if (input.transport === 'stdio') {
99
+ if (input.command === undefined || input.command.trim() === '') return 'stdio transport requires a command'
100
+ } else if (input.url === undefined || !/^https?:\/\//.test(input.url)) {
101
+ return 'http transport requires an http(s) url'
102
+ }
103
+ return null
104
+ }
105
+
106
+ /** Add or replace one server row. Returns the (possibly deduplicated) id. */
107
+ export function upsertMcp(profileDirPath: string, input: McpInput): string {
108
+ const doc = loadPatch(profileDirPath)
109
+ const seq = rowSeq(doc)
110
+
111
+ const existing = input.id !== ''
112
+ ? mcpRows(doc).find(item => item.get('id') === input.id)
113
+ : undefined
114
+
115
+ let id = input.id !== '' ? input.id : `mcp-${input.serverName}`
116
+ if (existing === undefined) {
117
+ const taken = new Set(
118
+ (seq.items ?? []).map(item => String(item.get('id') ?? '')).filter(id => id !== ''),
119
+ )
120
+ let suffix = 2
121
+ while (taken.has(id)) id = `mcp-${input.serverName}-${suffix++}`
122
+ }
123
+
124
+ const config: Record<string, unknown> = input.transport === 'stdio'
125
+ ? {
126
+ serverName: input.serverName,
127
+ transport: input.transport,
128
+ command: input.command,
129
+ ...(input.args !== undefined && input.args.length > 0 ? { args: input.args } : {}),
130
+ ...(input.env !== undefined && Object.keys(input.env).length > 0 ? { env: input.env } : {}),
131
+ ...(input.cwd !== undefined && input.cwd !== '' ? { cwd: input.cwd } : {}),
132
+ }
133
+ : {
134
+ serverName: input.serverName,
135
+ transport: input.transport,
136
+ url: input.url,
137
+ ...(input.headers !== undefined && Object.keys(input.headers).length > 0 ? { headers: input.headers } : {}),
138
+ }
139
+ const row: Record<string, unknown> = { id, name: MCP_PLUGIN, config }
140
+ if (input.disabled === true) row.disabled = true
141
+
142
+ const node = toNode<YAMLMap>(row)
143
+ if (existing === undefined) seq.add(node)
144
+ else seq.items[seq.items.indexOf(existing)] = node
145
+
146
+ savePatch(profileDirPath, doc)
147
+ return id
148
+ }
149
+
150
+ /** Flip one row's disabled flag (absent = enabled). Returns false when missing. */
151
+ export function setMcpDisabled(profileDirPath: string, id: string, disabled: boolean): boolean {
152
+ const doc = loadPatch(profileDirPath)
153
+ const item = mcpRows(doc).find(row => row.get('id') === id)
154
+ if (item === undefined) return false
155
+ if (disabled) item.set('disabled', true)
156
+ else item.delete('disabled')
157
+ savePatch(profileDirPath, doc)
158
+ return true
159
+ }
160
+
161
+ /** Remove one server row. Returns false when missing. */
162
+ export function removeMcp(profileDirPath: string, id: string): boolean {
163
+ const doc = loadPatch(profileDirPath)
164
+ const item = mcpRows(doc).find(row => row.get('id') === id)
165
+ if (item === undefined) return false
166
+ const seq = rowSeq(doc)
167
+ seq.items.splice(seq.items.indexOf(item), 1)
168
+ savePatch(profileDirPath, doc)
169
+ return true
170
+ }
package/src/profile.ts ADDED
@@ -0,0 +1,17 @@
1
+ /** Profile discovery (pure reads; same contract as dsh-plugin-install). */
2
+
3
+ import { homedir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ /** Profile that boots this UI: `--profile <name>` on the CLI invocation. */
7
+ export function argvProfile(argv: readonly string[] = process.argv): string | undefined {
8
+ const flag = argv.indexOf('--profile')
9
+ if (flag !== -1 && flag + 1 < argv.length && !argv[flag + 1].startsWith('-')) return argv[flag + 1]
10
+ return undefined
11
+ }
12
+
13
+ /** Directory of a profile under DSH_HOME (default `~/.dsh`). */
14
+ export function profileDir(profile: string, dshHome: string | undefined = process.env.DSH_HOME): string {
15
+ const home = dshHome ?? join(homedir(), '.dsh')
16
+ return join(home, 'profiles', profile)
17
+ }
package/src/routes.ts ADDED
@@ -0,0 +1,285 @@
1
+ /** HTTP routes bridging the Settings UI to the capabilities manager. */
2
+
3
+ import type { IncomingMessage, ServerResponse } from 'node:http'
4
+ import { scanAllMcp } from './agents.ts'
5
+ import { readJsonBody, sameOrigin, sendJson } from './http.ts'
6
+ import { deleteSkill, validateSkillInput, writeSkill, type SkillInput } from './skills.ts'
7
+ import { listMcp, removeMcp, setMcpDisabled, upsertMcp, validateMcpInput, type McpInput } from './mcp.ts'
8
+ import type { CapabilitiesHost } from './types.ts'
9
+
10
+ /** Only this source is writable from the Settings page (provider rank 400). */
11
+ const EDITABLE_SOURCE = 'user-dsh'
12
+
13
+ /** Register the manager's routes; returns the disposer removing them all. */
14
+ export function mountCapabilitiesRoutes(host: CapabilitiesHost, config: { profileDirPath: string }): () => void {
15
+ const disposers = [
16
+ host.webServer.register({
17
+ kind: 'exact',
18
+ path: '/dsh-plugin-capabilities/skills',
19
+ handler: async (request: IncomingMessage, response: ServerResponse) => {
20
+ if (request.method !== 'GET') {
21
+ response.writeHead(405, { allow: 'GET' })
22
+ response.end()
23
+ return
24
+ }
25
+ try {
26
+ const skills = await host.skills.list()
27
+ sendJson(response, 200, {
28
+ skills: skills.map(skill => ({ ...skill, editable: skill.source === EDITABLE_SOURCE })),
29
+ })
30
+ } catch (error) {
31
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
32
+ }
33
+ },
34
+ }),
35
+
36
+ host.webServer.register({
37
+ kind: 'exact',
38
+ path: '/dsh-plugin-capabilities/skill',
39
+ handler: async (request: IncomingMessage, response: ServerResponse) => {
40
+ if (request.method !== 'GET') {
41
+ response.writeHead(405, { allow: 'GET' })
42
+ response.end()
43
+ return
44
+ }
45
+ const url = new URL(request.url ?? '/', 'http://localhost')
46
+ const name = url.searchParams.get('name') ?? ''
47
+ try {
48
+ const definition = await host.skills.get(name)
49
+ sendJson(response, 200, { name: definition.name, content: definition.content })
50
+ } catch (error) {
51
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
52
+ }
53
+ },
54
+ }),
55
+
56
+ host.webServer.register({
57
+ kind: 'exact',
58
+ path: '/dsh-plugin-capabilities/skill/save',
59
+ handler: async (request: IncomingMessage, response: ServerResponse) => {
60
+ if (request.method !== 'POST') {
61
+ response.writeHead(405, { allow: 'POST' })
62
+ response.end()
63
+ return
64
+ }
65
+ if (!sameOrigin(request)) {
66
+ sendJson(response, 403, { error: 'untrusted origin' })
67
+ return
68
+ }
69
+ try {
70
+ const body = (await readJsonBody(request)) as Partial<SkillInput>
71
+ const input: SkillInput = {
72
+ name: typeof body.name === 'string' ? body.name : '',
73
+ description: typeof body.description === 'string' ? body.description : '',
74
+ whenToUse: typeof body.whenToUse === 'string' ? body.whenToUse : undefined,
75
+ modelInvocable: body.modelInvocable !== false,
76
+ userInvocable: body.userInvocable !== false,
77
+ content: typeof body.content === 'string' ? body.content : '',
78
+ }
79
+ const invalid = validateSkillInput(input)
80
+ if (invalid !== null) {
81
+ sendJson(response, 400, { error: invalid })
82
+ return
83
+ }
84
+ writeSkill(input)
85
+ sendJson(response, 200, { ok: true, name: input.name })
86
+ } catch (error) {
87
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
88
+ }
89
+ },
90
+ }),
91
+
92
+ host.webServer.register({
93
+ kind: 'exact',
94
+ path: '/dsh-plugin-capabilities/skill/delete',
95
+ handler: async (request: IncomingMessage, response: ServerResponse) => {
96
+ if (request.method !== 'POST') {
97
+ response.writeHead(405, { allow: 'POST' })
98
+ response.end()
99
+ return
100
+ }
101
+ if (!sameOrigin(request)) {
102
+ sendJson(response, 403, { error: 'untrusted origin' })
103
+ return
104
+ }
105
+ try {
106
+ const body = (await readJsonBody(request)) as { name?: unknown }
107
+ const name = typeof body.name === 'string' ? body.name : ''
108
+ const removed = deleteSkill(name)
109
+ sendJson(response, removed ? 200 : 404, removed ? { ok: true, name } : { error: 'skill not found' })
110
+ } catch (error) {
111
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
112
+ }
113
+ },
114
+ }),
115
+
116
+ host.webServer.register({
117
+ kind: 'exact',
118
+ path: '/dsh-plugin-capabilities/mcp',
119
+ handler: async (request: IncomingMessage, response: ServerResponse) => {
120
+ if (request.method !== 'GET') {
121
+ response.writeHead(405, { allow: 'GET' })
122
+ response.end()
123
+ return
124
+ }
125
+ sendJson(response, 200, { servers: listMcp(config.profileDirPath), restartNeeded: true })
126
+ },
127
+ }),
128
+
129
+ host.webServer.register({
130
+ kind: 'exact',
131
+ path: '/dsh-plugin-capabilities/mcp/save',
132
+ handler: async (request: IncomingMessage, response: ServerResponse) => {
133
+ if (request.method !== 'POST') {
134
+ response.writeHead(405, { allow: 'POST' })
135
+ response.end()
136
+ return
137
+ }
138
+ if (!sameOrigin(request)) {
139
+ sendJson(response, 403, { error: 'untrusted origin' })
140
+ return
141
+ }
142
+ try {
143
+ const input = (await readJsonBody(request)) as McpInput
144
+ const invalid = validateMcpInput(input)
145
+ if (invalid !== null) {
146
+ sendJson(response, 400, { error: invalid })
147
+ return
148
+ }
149
+ const id = upsertMcp(config.profileDirPath, input)
150
+ sendJson(response, 200, { ok: true, id, restartNeeded: true })
151
+ } catch (error) {
152
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
153
+ }
154
+ },
155
+ }),
156
+
157
+ host.webServer.register({
158
+ kind: 'exact',
159
+ path: '/dsh-plugin-capabilities/mcp/toggle',
160
+ handler: async (request: IncomingMessage, response: ServerResponse) => {
161
+ if (request.method !== 'POST') {
162
+ response.writeHead(405, { allow: 'POST' })
163
+ response.end()
164
+ return
165
+ }
166
+ if (!sameOrigin(request)) {
167
+ sendJson(response, 403, { error: 'untrusted origin' })
168
+ return
169
+ }
170
+ try {
171
+ const body = (await readJsonBody(request)) as { id?: unknown; disabled?: unknown }
172
+ if (typeof body.id !== 'string' || typeof body.disabled !== 'boolean') {
173
+ sendJson(response, 400, { error: 'id and disabled are required' })
174
+ return
175
+ }
176
+ const ok = setMcpDisabled(config.profileDirPath, body.id, body.disabled)
177
+ sendJson(response, ok ? 200 : 404, ok ? { ok: true, restartNeeded: true } : { error: 'server row not found' })
178
+ } catch (error) {
179
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
180
+ }
181
+ },
182
+ }),
183
+
184
+ host.webServer.register({
185
+ kind: 'exact',
186
+ path: '/dsh-plugin-capabilities/mcp/remove',
187
+ handler: async (request: IncomingMessage, response: ServerResponse) => {
188
+ if (request.method !== 'POST') {
189
+ response.writeHead(405, { allow: 'POST' })
190
+ response.end()
191
+ return
192
+ }
193
+ if (!sameOrigin(request)) {
194
+ sendJson(response, 403, { error: 'untrusted origin' })
195
+ return
196
+ }
197
+ try {
198
+ const body = (await readJsonBody(request)) as { id?: unknown }
199
+ if (typeof body.id !== 'string') {
200
+ sendJson(response, 400, { error: 'id is required' })
201
+ return
202
+ }
203
+ const ok = removeMcp(config.profileDirPath, body.id)
204
+ sendJson(response, ok ? 200 : 404, ok ? { ok: true, restartNeeded: true } : { error: 'server row not found' })
205
+ } catch (error) {
206
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
207
+ }
208
+ },
209
+ }),
210
+ host.webServer.register({
211
+ kind: 'exact',
212
+ path: '/dsh-plugin-capabilities/import/scan',
213
+ handler: async (request: IncomingMessage, response: ServerResponse) => {
214
+ if (request.method !== 'GET') {
215
+ response.writeHead(405, { allow: 'GET' })
216
+ response.end()
217
+ return
218
+ }
219
+ try {
220
+ sendJson(response, 200, {
221
+ servers: scanAllMcp(),
222
+ // Profile serverNames, so the browser can grey out existing ones.
223
+ existing: listMcp(config.profileDirPath).map(row => row.serverName),
224
+ })
225
+ } catch (error) {
226
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
227
+ }
228
+ },
229
+ }),
230
+
231
+ host.webServer.register({
232
+ kind: 'exact',
233
+ path: '/dsh-plugin-capabilities/import/apply',
234
+ handler: async (request: IncomingMessage, response: ServerResponse) => {
235
+ if (request.method !== 'POST') {
236
+ response.writeHead(405, { allow: 'POST' })
237
+ response.end()
238
+ return
239
+ }
240
+ if (!sameOrigin(request)) {
241
+ sendJson(response, 403, { error: 'untrusted origin' })
242
+ return
243
+ }
244
+ try {
245
+ const body = (await readJsonBody(request)) as { items?: unknown }
246
+ const wanted = new Set(
247
+ (Array.isArray(body.items) ? body.items : [])
248
+ .filter((item): item is { agent: string; name: string } =>
249
+ typeof item === 'object' && item !== null && typeof (item as { agent?: unknown }).agent === 'string' && typeof (item as { name?: unknown }).name === 'string')
250
+ .map(item => `${item.agent}/${item.name}`),
251
+ )
252
+ const results: Array<{ name: string; ok: boolean; error?: string }> = []
253
+ for (const server of scanAllMcp()) {
254
+ if (!wanted.has(`${server.agent}/${server.name}`)) continue
255
+ const existing = listMcp(config.profileDirPath).some(row => row.serverName === server.name)
256
+ if (existing) {
257
+ results.push({ name: server.name, ok: false, error: 'already in profile' })
258
+ continue
259
+ }
260
+ const input: McpInput = {
261
+ id: '',
262
+ serverName: server.name,
263
+ transport: server.transport,
264
+ ...(server.transport === 'stdio'
265
+ ? { command: server.command, args: server.args, env: server.env }
266
+ : { url: server.url, headers: server.headers }),
267
+ }
268
+ const invalid = validateMcpInput(input)
269
+ if (invalid !== null) {
270
+ results.push({ name: server.name, ok: false, error: invalid })
271
+ continue
272
+ }
273
+ upsertMcp(config.profileDirPath, input)
274
+ results.push({ name: server.name, ok: true })
275
+ }
276
+ sendJson(response, 200, { ok: results.every(item => item.ok), results, restartNeeded: true })
277
+ } catch (error) {
278
+ sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
279
+ }
280
+ },
281
+ }),
282
+ ]
283
+
284
+ return () => { for (const dispose of disposers) dispose() }
285
+ }