dsh-artcraft-plugin-soundcraft 0.2.0 → 0.2.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-artcraft-plugin-soundcraft",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "DSH plugin: drive SoundCraft (audio production) headlessly through its own CLI and MCP server.",
package/src/kit/mcp.js CHANGED
@@ -14,6 +14,8 @@
14
14
 
15
15
  import { spawn } from 'node:child_process'
16
16
 
17
+ import { mkdirSync, writeFileSync } from 'node:fs'
18
+ import { join } from 'node:path'
17
19
  import { killTree } from './exec.js'
18
20
 
19
21
 
@@ -31,6 +33,33 @@ import { killTree } from './exec.js'
31
33
 
32
34
 
33
35
 
36
+ /** Extensions for the image types an MCP tool can return. */
37
+ const IMAGE_EXTENSIONS = {
38
+ 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif', 'image/svg+xml': 'svg',
39
+ }
40
+
41
+ /**
42
+ * Write one base64 image from a tool result to disk.
43
+ * @param data - base64 payload.
44
+ * @param mimeType - media type the server declared.
45
+ * @param dir - directory to write into.
46
+ * @param craft - craft id, used in the file name.
47
+ * @param tool - tool name, used in the file name.
48
+ * @returns the absolute path, or `undefined` when the payload cannot be written.
49
+ */
50
+ function saveImage(data , mimeType , dir , craft , tool ) {
51
+ try {
52
+ const extension = IMAGE_EXTENSIONS[mimeType ?? ''] ?? 'png'
53
+ const safeTool = tool.replace(/[^\w.-]+/g, '-')
54
+ mkdirSync(dir, { recursive: true })
55
+ const file = join(dir, `${craft}-${safeTool}-${Date.now()}.${extension}`)
56
+ writeFileSync(file, Buffer.from(data, 'base64'))
57
+ return file
58
+ } catch {
59
+ return undefined
60
+ }
61
+ }
62
+
34
63
  /** Default MCP protocol revision; servers negotiate down if they need to. */
35
64
  const PROTOCOL_VERSION = '2024-11-05'
36
65
 
@@ -52,6 +81,8 @@ export class CraftMcpClient {
52
81
  #idleTimer
53
82
  #handshake
54
83
  #tools
84
+ /** Last stderr lines, so a failed attach can say why the app refused. */
85
+ #stderrTail = []
55
86
  #closed = false
56
87
 
57
88
  constructor(profile , binary , args = []) {
@@ -91,17 +122,26 @@ export class CraftMcpClient {
91
122
  child.stdout.on('data', (chunk ) => { this.#onData(chunk) })
92
123
  child.stderr.setEncoding('utf8')
93
124
  // Craft servers announce themselves on stderr; it is not an error stream.
125
+ // The tail is kept so a refused attach can repeat the app's own reason
126
+ // ("no control token", "cannot connect to 127.0.0.1:7979") instead of a
127
+ // bare "server exited".
94
128
  child.stderr.on('data', (chunk ) => {
95
129
  const text = chunk.trim()
96
- if (text.length > 0 && process.env.DSH_ARTCRAFT_TRACE !== undefined) {
130
+ if (text.length === 0) return
131
+ for (const line of text.split('\n')) {
132
+ this.#stderrTail.push(line.trim())
133
+ if (this.#stderrTail.length > 8) this.#stderrTail.shift()
134
+ }
135
+ if (process.env.DSH_ARTCRAFT_TRACE !== undefined) {
97
136
  process.stderr.write(`[${this.#profile.id} mcp] ${text}\n`)
98
137
  }
99
138
  })
100
139
  child.on('exit', () => {
101
140
  this.#child = undefined
141
+ const reason = this.#stderrTail.filter(line => line.length > 0).slice(-2).join(' ')
102
142
  for (const [, pending] of this.#pending) {
103
143
  clearTimeout(pending.timer)
104
- pending.reject(new Error(`${this.#profile.product}: MCP server exited`))
144
+ pending.reject(new Error(`${this.#profile.product}: MCP server exited${reason.length > 0 ? ` (${reason})` : ''}`))
105
145
  }
106
146
  this.#pending.clear()
107
147
  })
@@ -134,22 +174,60 @@ export class CraftMcpClient {
134
174
 
135
175
  /**
136
176
  * Call one tool.
177
+ *
178
+ * Image results — a render, a screenshot, a page thumbnail — are written to
179
+ * `artifactsDir` and referenced by path instead of being dropped: the caller
180
+ * can then look at them with `read_image`, which is the only way an agent
181
+ * verifies what it drew.
137
182
  * @param name - tool name as declared by the server.
138
183
  * @param args - the tool's JSON arguments.
139
- * @returns the concatenated text content of the result.
184
+ * @param artifactsDir - directory for returned images, when one is configured.
185
+ * @returns the text content, with any images saved and named by path.
140
186
  */
141
- async callTool(name , args ) {
187
+ async callTool(name , args , artifactsDir ) {
142
188
  const result = await this.#request('tools/call', { name, arguments: args })
143
-
189
+
144
190
 
145
191
 
146
- const text = (result.content ?? [])
147
- .map(block => (block.type === 'text' ? block.text ?? '' : `[${block.type ?? 'unknown'} content]`))
148
- .join('\n')
149
- .trim()
192
+ const parts = []
193
+ for (const block of result.content ?? []) {
194
+ if (block.type === 'text') {
195
+ parts.push(block.text ?? '')
196
+ continue
197
+ }
198
+ if (block.type === 'image' && typeof block.data === 'string' && artifactsDir !== undefined) {
199
+ const saved = saveImage(block.data, block.mimeType, artifactsDir, this.#profile.id, name)
200
+ parts.push(saved === undefined
201
+ ? '[image content]'
202
+ : `[image] ${saved}\nUse read_image on that path to look at it.`)
203
+ continue
204
+ }
205
+ parts.push(`[${block.type ?? 'unknown'} content]`)
206
+ }
207
+ const text = parts.join('\n').trim()
150
208
  return `${text.length > 0 ? text : '(no content)'}${result.isError === true ? '\n[mcp: tool reported an error]' : ''}`
151
209
  }
152
210
 
211
+ /** List the resources the server advertises (documents, command catalogues). */
212
+ async listResources() {
213
+ try {
214
+ const result = await this.#request('resources/list', {})
215
+ return Array.isArray(result.resources) ? result.resources : []
216
+ } catch {
217
+ return []
218
+ }
219
+ }
220
+
221
+ /** List the prompts the server advertises (curated multi-step workflows). */
222
+ async listPrompts() {
223
+ try {
224
+ const result = await this.#request('prompts/list', {})
225
+ return Array.isArray(result.prompts) ? result.prompts : []
226
+ } catch {
227
+ return []
228
+ }
229
+ }
230
+
153
231
  /** Terminate the server process now. */
154
232
  close() {
155
233
  this.#closed = true
@@ -54,6 +54,7 @@ export const CRAFT_PROFILES = [
54
54
  catalogJson: true,
55
55
  catalogArgs: ['commands'],
56
56
  mcpArgs: ['mcp'],
57
+ mcpAttach: { flag: '--connect', format: 'hostPort' },
57
58
  },
58
59
  {
59
60
  id: 'gridcraft',
@@ -80,6 +81,7 @@ export const CRAFT_PROFILES = [
80
81
  catalogJson: true,
81
82
  catalogArgs: ['commands', '--json'],
82
83
  mcpArgs: ['mcp'],
84
+ mcpAttach: { flag: '--connect', format: 'port' },
83
85
  },
84
86
  {
85
87
  id: 'soundcraft',
@@ -107,6 +109,7 @@ export const CRAFT_PROFILES = [
107
109
  describeArgs: ['describe'],
108
110
  catalogArgs: ['commands'],
109
111
  mcpArgs: ['mcp'],
112
+ mcpAttach: { flag: '--connect', format: 'port' },
110
113
  },
111
114
  {
112
115
  id: 'deckcraft',
@@ -133,6 +136,7 @@ export const CRAFT_PROFILES = [
133
136
  catalogJson: true,
134
137
  catalogArgs: ['commands'],
135
138
  mcpArgs: ['mcp'],
139
+ mcpAttach: { flag: '--connect', format: 'port' },
136
140
  },
137
141
  {
138
142
  id: 'wordcraft',
@@ -158,6 +162,7 @@ export const CRAFT_PROFILES = [
158
162
  catalogJson: true,
159
163
  catalogArgs: ['commands', '--json'],
160
164
  mcpArgs: ['mcp'],
165
+ mcpAttach: { flag: '--connect', format: 'hostPort' },
161
166
  },
162
167
  {
163
168
  id: 'photocraft',
@@ -186,6 +191,7 @@ export const CRAFT_PROFILES = [
186
191
  catalogJson: true,
187
192
  catalogArgs: ['commands', '--json'],
188
193
  mcpArgs: ['mcp'],
194
+ mcpAttach: { flag: '--bridge', format: 'hostPort' },
189
195
  },
190
196
  {
191
197
  id: 'lightcraft',
@@ -272,6 +278,7 @@ export const CRAFT_PROFILES = [
272
278
  catalogJson: true,
273
279
  catalogArgs: ['commands'],
274
280
  mcpArgs: ['mcp'],
281
+ mcpAttach: { flag: '--connect', format: 'hostPort' },
275
282
  },
276
283
  {
277
284
  id: 'effectcraft',
@@ -297,6 +304,7 @@ export const CRAFT_PROFILES = [
297
304
  catalogJson: false,
298
305
  catalogArgs: ['commands', '--json'],
299
306
  mcpArgs: ['mcp'],
307
+ mcpAttach: { flag: '--bridge', format: 'port' },
300
308
  },
301
309
  {
302
310
  id: 'filmcraft',
@@ -334,6 +342,7 @@ export const CRAFT_PROFILES = [
334
342
  describeArgs: ['describe'],
335
343
  catalogArgs: ['commands', '--json'],
336
344
  mcpArgs: ['mcp'],
345
+ mcpAttach: { flag: '--bridge', format: 'hostPort' },
337
346
  },
338
347
  {
339
348
  id: 'designcraft',
@@ -358,6 +367,7 @@ export const CRAFT_PROFILES = [
358
367
  catalogJson: true,
359
368
  catalogArgs: ['commands'],
360
369
  mcpArgs: ['mcp'],
370
+ mcpAttach: { flag: '--connect', format: 'port' },
361
371
  },
362
372
  {
363
373
  id: 'figcraft',
@@ -385,6 +395,7 @@ export const CRAFT_PROFILES = [
385
395
  catalogJson: true,
386
396
  catalogArgs: ['commands', '--json'],
387
397
  mcpArgs: ['mcp'],
398
+ mcpAttach: { flag: '--attach', format: 'port' },
388
399
  },
389
400
  {
390
401
  id: 'texturecraft',
@@ -415,6 +426,7 @@ export const CRAFT_PROFILES = [
415
426
  catalogJson: false,
416
427
  catalogArgs: ['commands'],
417
428
  mcpArgs: ['mcp'],
429
+ mcpAttach: { flag: '--bridge', format: 'port' },
418
430
  },
419
431
  ]
420
432
 
package/src/kit/tools.js CHANGED
@@ -157,23 +157,37 @@ function craftWorkDir(profile ) {
157
157
  */
158
158
  export function createRuntime(profile , packageRoot , config = {}) {
159
159
  let catalogCache
160
- let mcpClient
160
+ const mcpClients = new Map ()
161
+ const artifacts = config.artifactsDir ?? join(tmpdir(), 'dsh-artcraft', profile.id)
161
162
  return {
163
+ artifactsDir() {
164
+ return artifacts
165
+ },
162
166
  profile,
163
167
  config,
164
- mcp() {
168
+ mcp(attach ) {
165
169
  if (config.enableMcp === false) return undefined
166
170
  if (profile.mcpArgs === undefined) return undefined
171
+ if (attach !== undefined && profile.mcpAttach === undefined) {
172
+ throw new Error(`${profile.product} cannot attach to a running app: its MCP server is headless only`)
173
+ }
167
174
  const binary = this.binary()
168
175
  if (binary === undefined) return undefined
176
+ const args = [...profile.mcpSessionArgs ?? []]
177
+ if (attach !== undefined && profile.mcpAttach !== undefined) {
178
+ args.push(profile.mcpAttach.flag, profile.mcpAttach.format === 'hostPort' ? `127.0.0.1:${attach}` : String(attach))
179
+ }
180
+ const key = String(attach ?? '')
181
+ const existing = mcpClients.get(key)
169
182
  // A CLI upgrade mid-session invalidates the running server.
170
- if (mcpClient !== undefined && !mcpClient.running && mcpClient.cachedTools !== undefined) mcpClient = undefined
171
- mcpClient ??= new CraftMcpClient(profile, binary, profile.mcpSessionArgs ?? [])
172
- return mcpClient
183
+ if (existing !== undefined && !existing.running && existing.cachedTools !== undefined) mcpClients.delete(key)
184
+ const fresh = mcpClients.get(key) ?? new CraftMcpClient(profile, binary, args)
185
+ mcpClients.set(key, fresh)
186
+ return fresh
173
187
  },
174
188
  dispose() {
175
- mcpClient?.close()
176
- mcpClient = undefined
189
+ for (const client of mcpClients.values()) client.close()
190
+ mcpClients.clear()
177
191
  },
178
192
  binary() {
179
193
  const found = discoverCraft(profile, config.path, packageRoot)
@@ -666,13 +680,19 @@ export function createToolSet(runtime ) {
666
680
  `${profile.product}'s own MCP server tools, each with a real JSON Schema. `
667
681
  + `These are the modelled operations (think \`write_range\`, \`render\`), whereas \`${profile.id}_run\` takes generic `
668
682
  + `command strings. Use this to discover them, then call one with \`${profile.id}_mcp_call\`. `
669
- + `Search by name or description; omit \`search\` to list everything.`,
683
+ + `Search by name or description, page with \`offset\`/\`limit\` (large servers declare hundreds). `
684
+ + `Also lists the server's MCP resources and prompts when it has them.`,
670
685
  parameters: {
671
686
  search: { type: 'string', description: 'Case-insensitive filter over tool names and descriptions.' },
672
- limit: { type: 'number', description: 'Maximum tools to describe (default 30).' },
687
+ limit: { type: 'number', description: 'Maximum tools to describe (default 30, at most 200).' },
688
+ offset: { type: 'number', description: 'Skip this many matches, to page through a long list.' },
689
+ ...profile.mcpAttach !== undefined
690
+ ? { attach: { type: 'number', description: `Control-channel port of a running ${profile.product} app, to drive the user's open document instead of a headless one.` } }
691
+ : {},
673
692
  },
674
693
  async execute(args) {
675
- const client = runtime.mcp()
694
+ const attach = typeof args.attach === 'number' ? Math.floor(args.attach) : undefined
695
+ const client = runtime.mcp(attach)
676
696
  if (client === undefined) throw new Error(`${profile.product} has no MCP server configured`)
677
697
  await client.start(idleMs)
678
698
  const all = await client.listTools()
@@ -681,10 +701,11 @@ export function createToolSet(runtime ) {
681
701
  ? all
682
702
  : all.filter(tool => tool.name.toLowerCase().includes(needle) || (tool.description ?? '').toLowerCase().includes(needle))
683
703
  const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.min(Math.floor(args.limit), 200) : 30
684
- const shown = matched.slice(0, limit)
704
+ const offset = typeof args.offset === 'number' && args.offset > 0 ? Math.floor(args.offset) : 0
705
+ const shown = matched.slice(offset, offset + limit)
685
706
  const lines = [
686
707
  `${profile.product} MCP server — ${matched.length} tool(s)${needle.length > 0 ? ` matching ${JSON.stringify(args.search)}` : ''}`
687
- + (matched.length > shown.length ? `, showing ${shown.length}` : ''),
708
+ + (matched.length > offset + shown.length ? `, showing ${offset}–${offset + shown.length}` : ''),
688
709
  `server: ${client.describe()}`,
689
710
  '',
690
711
  ]
@@ -693,6 +714,17 @@ export function createToolSet(runtime ) {
693
714
  lines.push(renderSchema(tool.inputSchema))
694
715
  }
695
716
  if (shown.length === 0) lines.push('(no matching tools)')
717
+ // Resources and prompts are the parts a tool-only bridge would miss: the
718
+ // document, the command catalogue, and curated multi-step workflows.
719
+ const [resources, prompts] = await Promise.all([client.listResources(), client.listPrompts()])
720
+ if (resources.length > 0) {
721
+ lines.push('', 'resources:')
722
+ for (const resource of resources) lines.push(` ${resource.uri}${resource.name !== undefined ? ` — ${resource.name}` : ''}`)
723
+ }
724
+ if (prompts.length > 0) {
725
+ lines.push('', 'prompts:')
726
+ for (const prompt of prompts) lines.push(` ${prompt.name}${prompt.description !== undefined ? ` — ${prompt.description}` : ''}`)
727
+ }
696
728
  return lines.join('\n')
697
729
  },
698
730
  }
@@ -703,13 +735,18 @@ export function createToolSet(runtime ) {
703
735
  description:
704
736
  `Call one tool on ${profile.product}'s MCP server with JSON arguments. `
705
737
  + `Discover names and schemas with \`${profile.id}_mcp_tools\` first. `
706
- + `Use this for modelled operations; use \`${profile.id}_run\` for arbitrary command ids.`,
738
+ + `Use this for modelled operations; use \`${profile.id}_run\` for arbitrary command ids. `
739
+ + `Images a tool returns (render, screenshot) are saved to disk and named by path — look at them with \`read_image\`.`,
707
740
  parameters: {
708
741
  tool: { type: 'string', required: true, description: 'MCP tool name, exactly as listed.' },
709
742
  arguments: { type: 'object', additionalProperties: true, description: "The tool's arguments object; see its schema in the listing." },
743
+ ...profile.mcpAttach !== undefined
744
+ ? { attach: { type: 'number', description: `Control-channel port of a running ${profile.product} app, to drive the user's open document instead of a headless one.` } }
745
+ : {},
710
746
  },
711
747
  async execute(args) {
712
- const client = runtime.mcp()
748
+ const attach = typeof args.attach === 'number' ? Math.floor(args.attach) : undefined
749
+ const client = runtime.mcp(attach)
713
750
  if (client === undefined) throw new Error(`${profile.product} has no MCP server configured`)
714
751
  const name = String(args.tool ?? '').trim()
715
752
  if (name.length === 0) throw new Error('tool is required')
@@ -722,7 +759,7 @@ export function createToolSet(runtime ) {
722
759
  }
723
760
  const raw = args.arguments
724
761
  const params = typeof raw === 'object' && raw !== null && !Array.isArray(raw) ? raw : {}
725
- return await client.callTool(name, params)
762
+ return await client.callTool(name, params, runtime.artifactsDir())
726
763
  },
727
764
  }
728
765
 
@@ -739,6 +776,9 @@ export function createToolSet(runtime ) {
739
776
  `command: ${client.describe()}`,
740
777
  `running: ${client.running}`,
741
778
  `tools cached: ${client.cachedTools === undefined ? 'no' : client.cachedTools.length}`,
779
+ profile.mcpAttach === undefined
780
+ ? 'attach: not supported (headless MCP server only)'
781
+ : `attach: pass \`attach: <port>\` to drive a running app (it must be started with its control channel, e.g. \`${profile.bin.replace(/-cli$/, '')} --control <port>\`)`,
742
782
  ].join('\n')
743
783
  },
744
784
  }
@@ -825,11 +865,17 @@ export function applyCraftPlugin(
825
865
  }
826
866
  ctx.systemPrompt.section({
827
867
  name: `artcraft:${profile.id}`,
828
- text: `${profile.product} is available through the ${profile.id}_* tools. `
829
- + `Documents are ${profile.extensions.join('/')}. `
830
- + `Discover operations with \`${profile.id}_commands\` (generic command ids) or `
831
- + `\`${profile.id}_mcp_tools\` (modelled operations with JSON schemas), read them back with `
832
- + `\`${profile.id}_inspect\`, and apply them with \`${profile.id}_run\` or \`${profile.id}_mcp_call\`.`,
868
+ text:
869
+ `${profile.product} is available through the ${profile.id}_* tools; documents are ${profile.extensions.join('/')}. `
870
+ + `Two surfaces reach the same engine. Use \`${profile.id}_commands\` for the full command registry `
871
+ + `(every menu action; fetch exact parameter grammar with \`${profile.id}_describe\`) and \`${profile.id}_run\` to apply `
872
+ + `an ordered list. Use \`${profile.id}_mcp_tools\` for the app's modelled operations with JSON Schemas when you want `
873
+ + `a safer first call, then \`${profile.id}_mcp_call\`. `
874
+ + `Read a document back with \`${profile.id}_inspect\` before editing it. `
875
+ + (profile.mcpAttach === undefined
876
+ ? ''
877
+ : `When the user has ${profile.product} open, pass \`attach: <control port>\` to an mcp call to work in their window instead of a headless copy. `)
878
+ + `Images returned by a render or screenshot are saved and named by path; look at them with \`read_image\`.`,
833
879
  order: CRAFT_SECTION_ORDER,
834
880
  })
835
881
  return () => {