@davait/aipictury 1.9.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.
Files changed (54) hide show
  1. package/LICENSE.md +77 -0
  2. package/README.md +39 -0
  3. package/ext/about.js +209 -0
  4. package/ext/account.js +242 -0
  5. package/ext/accountUi.js +287 -0
  6. package/ext/agent.js +1929 -0
  7. package/ext/browser.js +263 -0
  8. package/ext/cloud.js +385 -0
  9. package/ext/context.js +241 -0
  10. package/ext/deps.js +142 -0
  11. package/ext/dialogs.js +803 -0
  12. package/ext/diff.js +150 -0
  13. package/ext/extension.js +2483 -0
  14. package/ext/feedback.js +55 -0
  15. package/ext/files.js +565 -0
  16. package/ext/firestore.js +131 -0
  17. package/ext/gpuRun.js +77 -0
  18. package/ext/gpuServer.js +76 -0
  19. package/ext/hooks.js +153 -0
  20. package/ext/log.js +29 -0
  21. package/ext/lsp.js +122 -0
  22. package/ext/mcp.js +469 -0
  23. package/ext/mcpAuth.js +128 -0
  24. package/ext/media/vendor/firebase-config.json +9 -0
  25. package/ext/media/vendor/signin.js +1 -0
  26. package/ext/mediagen.js +174 -0
  27. package/ext/memory.js +117 -0
  28. package/ext/notebook.js +82 -0
  29. package/ext/package.json +1234 -0
  30. package/ext/paths.js +50 -0
  31. package/ext/permissions.js +229 -0
  32. package/ext/plans.js +110 -0
  33. package/ext/plugins.js +243 -0
  34. package/ext/prefs.js +157 -0
  35. package/ext/remote.js +170 -0
  36. package/ext/review.js +126 -0
  37. package/ext/sandbox.js +79 -0
  38. package/ext/scheduler.js +87 -0
  39. package/ext/sessions.js +228 -0
  40. package/ext/sessionsView.js +449 -0
  41. package/ext/shell.js +357 -0
  42. package/ext/storage.js +80 -0
  43. package/ext/tasklist.js +112 -0
  44. package/ext/text.js +31 -0
  45. package/ext/tools.js +781 -0
  46. package/ext/usage.js +77 -0
  47. package/ext/voice.js +135 -0
  48. package/ext/workbench.js +162 -0
  49. package/ext/workflow.js +51 -0
  50. package/ext/worktree.js +83 -0
  51. package/main.js +758 -0
  52. package/package.json +43 -0
  53. package/terminal.js +124 -0
  54. package/vscode-shim.js +231 -0
package/ext/mcp.js ADDED
@@ -0,0 +1,469 @@
1
+ 'use strict'
2
+ // MCP servers: tools from outside — a database, GitHub, a browser — the way Claude Code
3
+ // connects them. Configured in the project's .mcp.json (Claude Code's own file, so one
4
+ // written for it works here unchanged), .aipictury/mcp.json, or the aipictury.mcpServers
5
+ // setting:
6
+ // { "mcpServers": { "name": { "command": "npx", "args": ["-y", "pkg"], "env": {…} } } }
7
+ // { "mcpServers": { "name": { "type": "http", "url": "https://…", "headers": {…} } } }
8
+ // ${VAR} and ${VAR:-default} in any string are read from the environment.
9
+ // Scopes, as Claude Code's: user (~/.aipictury/mcp.json; and Claude Code's ~/.claude.json,
10
+ // read-only), project (.mcp.json — shared, Claude Code's too — and .aipictury/mcp.json), local
11
+ // (.aipictury/mcp.local.json; and Claude Code's per-project entry, read-only). Local beats
12
+ // project beats user. A project's own servers start processes from the repository: they
13
+ // connect only once the user trusts them (asked again when they change).
14
+ // Their tools reach the model as mcp__<server>__<tool>. A tool that declares itself
15
+ // read-only needs no approval; any other is approved like a command.
16
+
17
+ const vscode = require('vscode')
18
+ const { spawn } = require('child_process')
19
+ const path = require('path')
20
+ const os = require('os')
21
+ const fs = require('fs')
22
+ const crypto = require('crypto')
23
+ const { root } = require('./paths')
24
+ const { home } = require('./sessions')
25
+ const prefs = require('./prefs')
26
+ const mcpAuth = require('./mcpAuth')
27
+
28
+ // OAuth for remote servers: where tokens are kept, how the browser is opened, where it comes
29
+ // back — given by the extension (VS Code's secret storage, openExternal, the vscode:// link).
30
+ let auth = null
31
+ function configureAuth(options) { auth = options }
32
+
33
+ const PROTOCOL = '2025-06-18'
34
+ const CONNECT_TIMEOUT = 30_000
35
+ const CALL_TIMEOUT = 300_000
36
+
37
+ const servers = new Map() // name → { name, config, state, tools, error, client }
38
+ let untrusted = [] // a project's servers not connected: not trusted (yet)
39
+
40
+ const expand = (v) => (typeof v === 'string'
41
+ ? v.replace(/\$\{(\w+)(?::-([^}]*))?\}/g, (_, k, d) => process.env[k] ?? d ?? '')
42
+ : Array.isArray(v) ? v.map(expand)
43
+ : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, expand(x)])) : v)
44
+
45
+ const readFile = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return null } }
46
+ const claudeJsonFile = () => path.join(process.env.CLAUDE_CONFIG_DIR || os.homedir(), '.claude.json')
47
+
48
+ const extraConfigs = []
49
+ /** MCP servers given for this run (the CLI's --mcp-config): files with "mcpServers", or { servers }. */
50
+ function addConfigs(files) { for (const f of files || []) if (!extraConfigs.includes(f)) extraConfigs.push(f) }
51
+
52
+ /** Every place servers are configured, least specific first: { scope, file, editable, origin, servers }. */
53
+ function sources(folder) {
54
+ const claude = readFile(claudeJsonFile()) || {}
55
+ const out = [
56
+ { scope: 'user', file: path.join(home(), 'mcp.json'), editable: true, origin: 'AIPictury' },
57
+ { scope: 'user', file: claudeJsonFile(), editable: false, origin: 'Claude Code', servers: claude.mcpServers || {} },
58
+ { scope: 'user', file: 'aipictury.mcpServers', editable: false, origin: 'VS Code settings',
59
+ servers: vscode.workspace.getConfiguration('aipictury').get('mcpServers', {}) || {} },
60
+ ]
61
+ if (folder) {
62
+ out.push({ scope: 'project', file: path.join(folder, '.mcp.json'), editable: true, origin: 'project' },
63
+ { scope: 'project', file: path.join(folder, '.aipictury', 'mcp.json'), editable: true, origin: 'AIPictury' },
64
+ { scope: 'local', file: path.join(folder, '.aipictury', 'mcp.local.json'), editable: true, origin: 'AIPictury' },
65
+ { scope: 'local', file: claudeJsonFile(), editable: false, origin: 'Claude Code', servers: claude.projects?.[folder]?.mcpServers || {} })
66
+ }
67
+ // This run's own (the CLI's --mcp-config): given on the command line, so trusted as the user's.
68
+ for (const c of extraConfigs) out.push(typeof c === 'string' ? { scope: 'user', file: c, editable: false, origin: '--mcp-config' }
69
+ : { scope: 'user', file: '--mcp-config', editable: false, origin: '--mcp-config', servers: c.servers })
70
+ for (const s of out) if (!s.servers) s.servers = readFile(s.file)?.mcpServers || {}
71
+ // Plugins' servers: installed by the user, so not asked about like a project's.
72
+ try {
73
+ const fromPlugins = require('./plugins').mcpServers(folder)
74
+ if (Object.keys(fromPlugins).length) out.splice(3, 0, { scope: 'user', file: 'plugins', editable: false, origin: 'plugin', servers: fromPlugins })
75
+ } catch { /* no plugins */ }
76
+ return out
77
+ }
78
+
79
+ /** Every configured server with where it is from: name → { config, scope, file, editable, origin, disabled }. */
80
+ function detailed(folder = folderPath()) {
81
+ const merged = prefs.load(folder).merged
82
+ const disabled = new Set([...(merged.disabledMcpServers || []), ...(merged.disabledMcpjsonServers || [])])
83
+ const all = new Map()
84
+ for (const s of sources(folder)) {
85
+ for (const [name, config] of Object.entries(s.servers)) {
86
+ all.set(name, { name, config: expand(config), scope: s.scope, file: s.file, editable: s.editable, origin: s.origin,
87
+ disabled: disabled.has(name) || !!config.disabled })
88
+ }
89
+ }
90
+ return all
91
+ }
92
+
93
+ function folderPath() { try { return root().fsPath } catch { return undefined } }
94
+
95
+ /** Every configured server that is not turned off, as { name: config } (later scopes win). */
96
+ async function configured() {
97
+ return Object.fromEntries([...detailed().values()].filter((s) => !s.disabled).map((s) => [s.name, s.config]))
98
+ }
99
+
100
+ /** A fingerprint of the project's servers: trust given to them is for these exactly. */
101
+ const fingerprint = (list) => crypto.createHash('sha256').update(JSON.stringify(list.map((s) => [s.name, s.config]))).digest('hex').slice(0, 16)
102
+
103
+ /** Adds or replaces a server in an editable file of a scope. Returns the file. */
104
+ function save(folder, scope, name, config) {
105
+ // user: yours everywhere; project: the shared .mcp.json (Claude Code reads it too); local:
106
+ // yours, in this project only.
107
+ const file = { user: path.join(home(), 'mcp.json'),
108
+ project: folder && path.join(folder, '.mcp.json'), local: folder && path.join(folder, '.aipictury', 'mcp.local.json') }[scope]
109
+ if (!file) throw new Error(scope === 'user' ? 'no such scope' : `a ${scope} server needs a folder open`)
110
+ if (!/^[\w.-]{1,40}$/.test(name)) throw new Error('a server name is letters, digits, - _ or . (up to 40)')
111
+ const data = readFile(file) || {}
112
+ data.mcpServers = { ...(data.mcpServers || {}), [name]: config }
113
+ fs.mkdirSync(path.dirname(file), { recursive: true })
114
+ fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`)
115
+ if (scope === 'local') keepOutOfGit(folder, '.aipictury/mcp.local.json')
116
+ return file
117
+ }
118
+
119
+ /** Removes a server from the file it is in (never Claude Code's). */
120
+ function remove(folder, name) {
121
+ const found = detailed(folder).get(name)
122
+ if (!found) throw new Error(`no server called ${name}`)
123
+ if (!found.editable) throw new Error(`${name} is configured in ${found.origin === 'Claude Code' ? 'Claude Code\'s file' : 'VS Code settings'} — change it there`)
124
+ const data = readFile(found.file) || {}
125
+ delete data.mcpServers?.[name]
126
+ fs.writeFileSync(found.file, `${JSON.stringify(data, null, 2)}\n`)
127
+ return found.file
128
+ }
129
+
130
+ /** Turns a server off or on — kept in your local settings, as Claude Code keeps it. */
131
+ function setEnabled(folder, name, on) {
132
+ return prefs.edit(folder, folder ? 'local' : 'user', (data) => {
133
+ const list = new Set(data.disabledMcpServers || [])
134
+ if (on) list.delete(name); else list.add(name)
135
+ data.disabledMcpServers = [...list]
136
+ if (!data.disabledMcpServers.length) delete data.disabledMcpServers
137
+ })
138
+ }
139
+
140
+ function keepOutOfGit(folder, entry) {
141
+ const ignore = path.join(folder, '.gitignore')
142
+ try {
143
+ const text = fs.readFileSync(ignore, 'utf8')
144
+ if (!text.split('\n').includes(entry)) fs.appendFileSync(ignore, `${text.endsWith('\n') ? '' : '\n'}${entry}\n`)
145
+ } catch { /* no .gitignore: not ours to create */ }
146
+ }
147
+
148
+ // ---- transports ----
149
+
150
+ class StdioClient {
151
+ constructor(config) {
152
+ this.pending = new Map()
153
+ this.next = 1
154
+ this.buffer = ''
155
+ this.stderr = ''
156
+ const cwd = config.cwd ? path.resolve(root().fsPath, config.cwd) : root().fsPath
157
+ this.child = spawn(config.command, config.args || [], { cwd, env: { ...process.env, ...(config.env || {}) },
158
+ stdio: ['pipe', 'pipe', 'pipe'], shell: process.platform === 'win32' })
159
+ this.child.stdout.on('data', (d) => this.onData(d))
160
+ this.child.stderr.on('data', (d) => { this.stderr = (this.stderr + d).slice(-4000) })
161
+ this.child.on('error', (e) => this.failAll(e))
162
+ this.child.on('close', (code) => this.failAll(new Error(`the server exited (${code})${this.stderr ? `: ${this.stderr.trim().split('\n').pop()}` : ''}`)))
163
+ }
164
+
165
+ onData(chunk) {
166
+ this.buffer += chunk
167
+ let at
168
+ while ((at = this.buffer.indexOf('\n')) !== -1) {
169
+ const line = this.buffer.slice(0, at).trim()
170
+ this.buffer = this.buffer.slice(at + 1)
171
+ if (!line) continue
172
+ let msg
173
+ try { msg = JSON.parse(line) } catch { continue }
174
+ this.receive(msg)
175
+ }
176
+ }
177
+
178
+ receive(msg) {
179
+ if (msg.id !== undefined && this.pending.has(msg.id)) {
180
+ const { ok, fail, timer } = this.pending.get(msg.id)
181
+ clearTimeout(timer)
182
+ this.pending.delete(msg.id)
183
+ if (msg.error) fail(new Error(msg.error.message || JSON.stringify(msg.error)))
184
+ else ok(msg.result)
185
+ } else if (msg.id !== undefined && msg.method) {
186
+ // A request from the server (roots, sampling…): politely unsupported.
187
+ this.send({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'not supported by this client' } })
188
+ }
189
+ }
190
+
191
+ failAll(error) {
192
+ this.dead = error
193
+ for (const { fail, timer } of this.pending.values()) { clearTimeout(timer); fail(error) }
194
+ this.pending.clear()
195
+ }
196
+
197
+ send(msg) { if (!this.dead) this.child.stdin.write(`${JSON.stringify(msg)}\n`) }
198
+
199
+ request(method, params, timeout = CONNECT_TIMEOUT) {
200
+ if (this.dead) return Promise.reject(this.dead)
201
+ const id = this.next++
202
+ return new Promise((ok, fail) => {
203
+ const timer = setTimeout(() => { this.pending.delete(id); fail(new Error(`${method} timed out after ${timeout / 1000}s`)) }, timeout)
204
+ this.pending.set(id, { ok, fail, timer })
205
+ this.send({ jsonrpc: '2.0', id, method, params })
206
+ })
207
+ }
208
+
209
+ notify(method, params) { this.send({ jsonrpc: '2.0', method, params }) }
210
+ close() { try { this.child.kill() } catch { /* gone */ } }
211
+ }
212
+
213
+ class HttpClient {
214
+ constructor(config) {
215
+ this.url = config.url
216
+ this.headers = config.headers || {}
217
+ this.next = 1
218
+ }
219
+
220
+ /** The headers of a request, with the server's OAuth token when it has one. */
221
+ async headersFor(force = false) {
222
+ const bearer = auth && !Object.keys(this.headers).some((h) => h.toLowerCase() === 'authorization')
223
+ ? await mcpAuth.token(this.url, { store: auth.store, fetchImpl: auth.fetch || fetch, force }).catch(() => null) : null
224
+ return { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', 'MCP-Protocol-Version': PROTOCOL,
225
+ ...(this.session ? { 'Mcp-Session-Id': this.session } : {}), ...this.headers, ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}) }
226
+ }
227
+
228
+ async request(method, params, timeout = CONNECT_TIMEOUT) {
229
+ const id = this.next++
230
+ const send = async (force) => fetch(this.url, { method: 'POST', headers: await this.headersFor(force),
231
+ body: JSON.stringify({ jsonrpc: '2.0', id, method, params }), signal: AbortSignal.timeout(timeout) })
232
+ let response = await send(false)
233
+ // 401: an expired token is refreshed once; otherwise the user must sign in.
234
+ if (response.status === 401) {
235
+ const header = response.headers.get('www-authenticate')
236
+ const fresh = auth ? await mcpAuth.token(this.url, { store: auth.store, fetchImpl: auth.fetch || fetch, force: true }).catch(() => null) : null
237
+ if (fresh) response = await send(false)
238
+ if (response.status === 401) throw new mcpAuth.AuthNeeded(this.url, response.headers.get('www-authenticate') || header)
239
+ }
240
+ this.session = response.headers.get('mcp-session-id') || this.session
241
+ if (!response.ok) throw new Error(`${response.status} ${(await response.text()).slice(0, 200)}`)
242
+ const type = response.headers.get('content-type') || ''
243
+ let msg
244
+ if (type.includes('text/event-stream')) {
245
+ const text = await response.text()
246
+ for (const block of text.split(/\n\n/)) {
247
+ const data = block.split('\n').filter((l) => l.startsWith('data:')).map((l) => l.slice(5).trim()).join('\n')
248
+ if (!data) continue
249
+ try { const m = JSON.parse(data); if (m.id === id) msg = m } catch { /* keep reading */ }
250
+ }
251
+ } else {
252
+ msg = await response.json()
253
+ }
254
+ if (!msg) throw new Error(`no answer to ${method}`)
255
+ if (msg.error) throw new Error(msg.error.message || JSON.stringify(msg.error))
256
+ return msg.result
257
+ }
258
+
259
+ notify(method, params) {
260
+ this.headersFor().then((headers) => fetch(this.url, { method: 'POST', headers,
261
+ body: JSON.stringify({ jsonrpc: '2.0', method, params }) })).catch(() => {})
262
+ }
263
+
264
+ close() {}
265
+ }
266
+
267
+ // ---- servers ----
268
+
269
+ const toolName = (server, tool) => `mcp__${server}__${tool}`.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 64)
270
+
271
+ async function connect(name, config) {
272
+ const entry = { name, config, state: 'connecting', tools: [] }
273
+ servers.set(name, entry)
274
+ try {
275
+ if (config.disabled) { entry.state = 'disabled'; return entry }
276
+ const http = config.type === 'http' || config.type === 'sse' || (!config.command && config.url)
277
+ entry.client = http ? new HttpClient(config) : new StdioClient(config)
278
+ const init = await entry.client.request('initialize', { protocolVersion: PROTOCOL, capabilities: {},
279
+ clientInfo: { name: 'aipictury', version: '1.6' } })
280
+ entry.info = init?.serverInfo
281
+ entry.resources = !!init?.capabilities?.resources
282
+ entry.client.notify('notifications/initialized', {})
283
+ let cursor
284
+ do {
285
+ const page = await entry.client.request('tools/list', cursor ? { cursor } : {})
286
+ entry.tools.push(...(page?.tools || []))
287
+ cursor = page?.nextCursor
288
+ } while (cursor)
289
+ entry.state = 'connected'
290
+ } catch (error) {
291
+ entry.state = error.name === 'AuthNeeded' ? 'needs-auth' : 'failed'
292
+ entry.error = error.message
293
+ entry.authHeader = error.header
294
+ entry.client?.close()
295
+ }
296
+ return entry
297
+ }
298
+
299
+ /** Signs in to a remote server in the browser, then connects it. */
300
+ async function signIn(name) {
301
+ const found = detailed().get(name)
302
+ if (!found?.config.url) throw new Error(`${name} is not a remote (http) server`)
303
+ if (!auth) throw new Error('signing in is not available here')
304
+ await mcpAuth.signIn(found.config.url, { header: servers.get(name)?.authHeader, store: auth.store, open: auth.open,
305
+ redirectUri: await auth.redirectUri(), fetchImpl: auth.fetch || fetch })
306
+ return reconnect(name)
307
+ }
308
+
309
+ async function signOut(name) {
310
+ const found = detailed().get(name)
311
+ if (found?.config.url && auth) await mcpAuth.signOut(found.config.url, { store: auth.store })
312
+ return reconnect(name)
313
+ }
314
+
315
+ /** Connects every configured server not yet connected (or whose configuration changed). A
316
+ * project's own servers connect only when `trust(servers, fingerprint)` says yes. */
317
+ async function ensure({ trust } = {}) {
318
+ const all = [...detailed().values()].filter((s) => !s.disabled)
319
+ const fromProject = all.filter((s) => s.scope === 'project' && s.editable)
320
+ const trusted = !fromProject.length || (trust ? await trust(fromProject, fingerprint(fromProject)) : false)
321
+ untrusted = trusted ? [] : fromProject.map((s) => s.name)
322
+ const wanted = Object.fromEntries(all.filter((s) => trusted || !fromProject.includes(s)).map((s) => [s.name, s.config]))
323
+ for (const [name, entry] of servers) {
324
+ if (!wanted[name] || JSON.stringify(wanted[name]) !== JSON.stringify(entry.config)) {
325
+ entry.client?.close(); servers.delete(name)
326
+ }
327
+ }
328
+ await Promise.all(Object.entries(wanted).filter(([name]) => !servers.has(name)).map(([n, c]) => connect(n, c)))
329
+ return status()
330
+ }
331
+
332
+ function status() {
333
+ return [...servers.values()].map((s) => ({ name: s.name, state: s.state, error: s.error,
334
+ tools: s.tools.map((t) => t.name), server: s.info?.name })).concat(untrusted.filter((n) => !servers.has(n))
335
+ .map((name) => ({ name, state: 'untrusted', error: 'a project server, not trusted yet', tools: [] })))
336
+ }
337
+
338
+ /** Closes a server and connects it again. */
339
+ async function reconnect(name) {
340
+ const entry = servers.get(name)
341
+ entry?.client?.close()
342
+ servers.delete(name)
343
+ const found = detailed().get(name)
344
+ if (!found || found.disabled) return null
345
+ return connect(name, found.config)
346
+ }
347
+
348
+ // Past this many MCP tools, the model gets their names only and loads what it needs with
349
+ // tool_search — their definitions would otherwise fill a local model's context.
350
+ const DEFER_AT = 20
351
+
352
+ const shaped = (s, t) => ({ type: 'function', function: { name: toolName(s.name, t.name),
353
+ description: `[${s.name}] ${t.description || t.title || t.name}`.slice(0, 1024),
354
+ parameters: t.inputSchema || { type: 'object', properties: {} } } })
355
+
356
+ function allTools({ readOnly = false } = {}) {
357
+ const out = []
358
+ for (const s of servers.values()) {
359
+ if (s.state !== 'connected') continue
360
+ for (const t of s.tools) if (!readOnly || t.annotations?.readOnlyHint) out.push({ server: s, tool: t })
361
+ }
362
+ return out
363
+ }
364
+
365
+ /** The tools every connected server offers, shaped for the model. With many of them, only
366
+ * those `loaded` (by tool_search) are given whole, and tool_search lists the rest by name. */
367
+ function schema({ readOnly = false, loaded = null } = {}) {
368
+ const all = allTools({ readOnly })
369
+ if (!loaded || all.length <= DEFER_AT) return all.map(({ server, tool }) => shaped(server, tool))
370
+ const whole = all.filter(({ server, tool }) => loaded.has(toolName(server.name, tool.name)))
371
+ const rest = all.filter((x) => !whole.includes(x))
372
+ const list = rest.map(({ server, tool }) => `- ${toolName(server.name, tool.name)}: ${String(tool.description || tool.title || '').split('\n')[0].slice(0, 70)}`)
373
+ return [...whole.map(({ server, tool }) => shaped(server, tool)), ...(rest.length ? [{ type: 'function', function: {
374
+ name: 'tool_search',
375
+ description: `Load MCP tools before using them: their full definitions are not given until loaded. Ask by name ("select:a,b") or by words. Not loaded yet:\n${list.join('\n')}`.slice(0, 8000),
376
+ parameters: { type: 'object', properties: {
377
+ query: { type: 'string', description: '"select:mcp__server__tool,…" for exact tools, or a few words to search their names and descriptions' },
378
+ max_results: { type: 'integer', description: 'optional: at most this many (default 5)' } }, required: ['query'] } } }] : [])]
379
+ }
380
+
381
+ /** tool_search: the tools that match, loaded into `loaded`; their definitions for the model. */
382
+ function search({ query, max_results: max = 5 }, loaded) {
383
+ const all = allTools()
384
+ const q = String(query || '').trim()
385
+ let hits
386
+ if (q.startsWith('select:')) {
387
+ const want = new Set(q.slice(7).split(',').map((x) => x.trim()).filter(Boolean))
388
+ hits = all.filter(({ server, tool }) => want.has(toolName(server.name, tool.name)))
389
+ } else {
390
+ const words = q.toLowerCase().split(/\s+/).filter(Boolean)
391
+ hits = all.map((x) => ({ x, score: words.filter((w) => `${toolName(x.server.name, x.tool.name)} ${x.tool.description || ''}`.toLowerCase().includes(w)).length }))
392
+ .filter((h) => h.score).sort((a, b) => b.score - a.score).map((h) => h.x)
393
+ }
394
+ hits = hits.slice(0, Math.max(1, Math.min(20, Number(max) || 5)))
395
+ for (const { server, tool } of hits) loaded.add(toolName(server.name, tool.name))
396
+ if (!hits.length) return `No MCP tool matches "${q}".`
397
+ return `Loaded — you can call these now:\n${hits.map(({ server, tool }) => JSON.stringify(shaped(server, tool).function)).join('\n')}`
398
+ }
399
+
400
+ // ---- resources ----
401
+
402
+ /** The resource tools, when a connected server has resources. */
403
+ function resourceSchema() {
404
+ const any = [...servers.values()].some((s) => s.state === 'connected' && s.resources)
405
+ if (!any) return []
406
+ return [
407
+ { type: 'function', function: { name: 'list_mcp_resources', description: 'List the resources (documents, data) MCP servers offer.',
408
+ parameters: { type: 'object', properties: { server: { type: 'string', description: 'optional: only this server' } } } } },
409
+ { type: 'function', function: { name: 'read_mcp_resource', description: 'Read one MCP resource by its URI.',
410
+ parameters: { type: 'object', properties: { server: { type: 'string', description: 'the server it is on' },
411
+ uri: { type: 'string', description: 'its URI, as list_mcp_resources gave it' } }, required: ['server', 'uri'] } } },
412
+ ]
413
+ }
414
+
415
+ async function listResources({ server } = {}) {
416
+ const lines = []
417
+ for (const s of servers.values()) {
418
+ if (s.state !== 'connected' || !s.resources || (server && s.name !== server)) continue
419
+ let cursor
420
+ do {
421
+ const page = await s.client.request('resources/list', cursor ? { cursor } : {})
422
+ for (const r of page?.resources || []) lines.push(`- ${s.name}: ${r.uri}${r.name ? ` — ${r.name}` : ''}${r.description ? `: ${r.description}` : ''}${r.mimeType ? ` (${r.mimeType})` : ''}`)
423
+ cursor = page?.nextCursor
424
+ } while (cursor && lines.length < 500)
425
+ }
426
+ return lines.length ? lines.join('\n') : server ? `${server} offers no resources (or is not connected).` : 'No MCP server offers resources.'
427
+ }
428
+
429
+ async function readResource({ server, uri }) {
430
+ const s = servers.get(server)
431
+ if (!s || s.state !== 'connected') throw new Error(`${server} is not connected (see /mcp)`)
432
+ const result = await s.client.request('resources/read', { uri })
433
+ return (result?.contents || []).map((c) => (c.text !== undefined ? c.text
434
+ : `[${c.mimeType || 'binary'} content, ${Math.round((c.blob || '').length * 0.75)} bytes — not shown]`)).join('\n\n') || '(empty)'
435
+ }
436
+
437
+ function find(name) {
438
+ for (const s of servers.values()) {
439
+ for (const t of s.tools) if (toolName(s.name, t.name) === name) return { server: s, tool: t }
440
+ }
441
+ return null
442
+ }
443
+
444
+ const isMcp = (name) => String(name).startsWith('mcp__')
445
+ const readOnly = (name) => !!find(name)?.tool.annotations?.readOnlyHint
446
+
447
+ /** Calls an MCP tool. Returns { text, images } for the model. */
448
+ async function call(name, args) {
449
+ const found = find(name)
450
+ if (!found) throw new Error(`${name} is not available (its server may have disconnected — see /mcp)`)
451
+ const result = await found.server.client.request('tools/call', { name: found.tool.name, arguments: args || {} }, CALL_TIMEOUT)
452
+ const texts = []
453
+ const images = []
454
+ for (const part of result?.content || []) {
455
+ if (part.type === 'text') texts.push(part.text)
456
+ else if (part.type === 'image') images.push(part.data)
457
+ else if (part.type === 'resource') texts.push(part.resource?.text || `[resource ${part.resource?.uri}]`)
458
+ else if (part.type === 'resource_link') texts.push(`[${part.name || 'resource'}: ${part.uri}]`)
459
+ }
460
+ if (result?.structuredContent && !texts.length) texts.push(JSON.stringify(result.structuredContent, null, 1))
461
+ const text = texts.join('\n\n') || '(no content)'
462
+ if (result?.isError) throw new Error(text)
463
+ return { text, images }
464
+ }
465
+
466
+ function closeAll() { for (const s of servers.values()) s.client?.close(); servers.clear() }
467
+
468
+ module.exports = { addConfigs, configureAuth, signIn, signOut, ensure, status, schema, search, resourceSchema, listResources, readResource, DEFER_AT, call, isMcp, readOnly, closeAll, configured, detailed, sources, save, remove,
469
+ setEnabled, reconnect, fingerprint, toolName }
package/ext/mcpAuth.js ADDED
@@ -0,0 +1,128 @@
1
+ 'use strict'
2
+ // OAuth for remote MCP servers, as the MCP authorization spec has it: the server answers 401
3
+ // and points to its protected-resource metadata (RFC 9728); its authorization server's
4
+ // metadata (RFC 8414) gives the endpoints; AIPictury registers itself there (dynamic client
5
+ // registration, RFC 7591) and signs in with the authorization code and PKCE (S256) in the
6
+ // browser; the browser comes back through vscode://aipictury.aipictury/oauth. Tokens are kept
7
+ // in VS Code's secret storage and refreshed when they expire. No VS Code here: the storage,
8
+ // the browser and fetch are given, so the tests run the whole flow against a local server.
9
+ const crypto = require('crypto')
10
+
11
+ const b64url = (buf) => Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
12
+ const KEY = (serverUrl) => `aipictury.mcp.oauth:${new URL(serverUrl).origin}${new URL(serverUrl).pathname}`
13
+ const CLIENT_KEY = (issuer) => `aipictury.mcp.oauth.client:${issuer}`
14
+ const pending = new Map() // state → { resolve, reject, timer }
15
+
16
+ class AuthNeeded extends Error {
17
+ constructor(serverUrl, header) {
18
+ super('this server needs you to sign in — /mcp, Sign in')
19
+ this.name = 'AuthNeeded'
20
+ this.serverUrl = serverUrl
21
+ this.header = header || ''
22
+ }
23
+ }
24
+
25
+ /** The parameters of a WWW-Authenticate: Bearer header. */
26
+ function challenge(header) {
27
+ const out = {}
28
+ for (const m of String(header || '').matchAll(/(\w+)="([^"]*)"/g)) out[m[1]] = m[2]
29
+ return out
30
+ }
31
+
32
+ async function json(fetchImpl, url, init) {
33
+ const r = await fetchImpl(url, { ...init, headers: { Accept: 'application/json', ...(init?.headers || {}) } })
34
+ if (!r.ok) throw new Error(`${new URL(url).pathname}: ${r.status} ${(await r.text()).slice(0, 200)}`)
35
+ return r.json()
36
+ }
37
+
38
+ /** Where to sign in for this server: its resource, its authorization server and that server's endpoints. */
39
+ async function discover(serverUrl, header, fetchImpl = fetch) {
40
+ const given = challenge(header)
41
+ const origin = new URL(serverUrl).origin
42
+ let resource
43
+ for (const url of [given.resource_metadata, `${origin}/.well-known/oauth-protected-resource${new URL(serverUrl).pathname}`, `${origin}/.well-known/oauth-protected-resource`].filter(Boolean)) {
44
+ try { resource = await json(fetchImpl, url); break } catch { /* the next place */ }
45
+ }
46
+ const issuer = resource?.authorization_servers?.[0] || origin
47
+ let meta
48
+ for (const url of [`${issuer.replace(/\/$/, '')}/.well-known/oauth-authorization-server`, `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`]) {
49
+ try { meta = await json(fetchImpl, url); break } catch { /* the next place */ }
50
+ }
51
+ if (!meta?.authorization_endpoint || !meta?.token_endpoint) throw new Error(`no OAuth metadata at ${issuer}`)
52
+ if (meta.code_challenge_methods_supported && !meta.code_challenge_methods_supported.includes('S256')) throw new Error(`${issuer} does not offer PKCE (S256)`)
53
+ return { resource: resource?.resource || serverUrl, issuer: meta.issuer || issuer, meta, scope: given.scope || (resource?.scopes_supported || []).join(' ') }
54
+ }
55
+
56
+ /** This client at that authorization server: registered once, kept. */
57
+ async function client(found, { store, redirectUri, fetchImpl = fetch }) {
58
+ const saved = await store.get(CLIENT_KEY(found.issuer))
59
+ if (saved) { const c = JSON.parse(saved); if (c.redirect_uris?.includes(redirectUri)) return c }
60
+ if (!found.meta.registration_endpoint) throw new Error(`${found.issuer} does not let clients register themselves — add a client id to the server's config`)
61
+ const registered = await json(fetchImpl, found.meta.registration_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' },
62
+ body: JSON.stringify({ client_name: 'AIPictury', redirect_uris: [redirectUri], grant_types: ['authorization_code', 'refresh_token'],
63
+ response_types: ['code'], token_endpoint_auth_method: 'none' }) })
64
+ await store.set(CLIENT_KEY(found.issuer), JSON.stringify(registered))
65
+ return registered
66
+ }
67
+
68
+ async function tokenRequest(found, body, fetchImpl) {
69
+ const r = await fetchImpl(found.meta.token_endpoint, { method: 'POST',
70
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, body: new URLSearchParams(body).toString() })
71
+ const data = await r.json().catch(() => ({}))
72
+ if (!r.ok || !data.access_token) throw new Error(`token: ${data.error_description || data.error || r.status}`)
73
+ return { ...data, expires_at: data.expires_in ? Date.now() + data.expires_in * 1000 - 30_000 : null }
74
+ }
75
+
76
+ /**
77
+ * Signs in to a server in the browser. `open(url)` opens it; the flow ends when the browser
78
+ * comes back (handleCallback). Resolves to the tokens, kept in `store`.
79
+ */
80
+ async function signIn(serverUrl, { header, store, open, redirectUri, fetchImpl = fetch, timeoutMs = 5 * 60_000 }) {
81
+ const found = await discover(serverUrl, header, fetchImpl)
82
+ const c = await client(found, { store, redirectUri, fetchImpl })
83
+ const verifier = b64url(crypto.randomBytes(48))
84
+ const state = b64url(crypto.randomBytes(24))
85
+ const url = new URL(found.meta.authorization_endpoint)
86
+ for (const [k, v] of Object.entries({ response_type: 'code', client_id: c.client_id, redirect_uri: redirectUri, state,
87
+ code_challenge: b64url(crypto.createHash('sha256').update(verifier).digest()), code_challenge_method: 'S256',
88
+ resource: found.resource, ...(found.scope ? { scope: found.scope } : {}) })) url.searchParams.set(k, v)
89
+ const code = await new Promise((resolve, reject) => {
90
+ const timer = setTimeout(() => { pending.delete(state); reject(new Error('the sign-in was not finished in time')) }, timeoutMs)
91
+ pending.set(state, { resolve, reject, timer })
92
+ Promise.resolve(open(url.toString())).catch((error) => { clearTimeout(timer); pending.delete(state); reject(error) })
93
+ })
94
+ const tokens = await tokenRequest(found, { grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: c.client_id,
95
+ code_verifier: verifier, resource: found.resource }, fetchImpl)
96
+ await store.set(KEY(serverUrl), JSON.stringify({ ...tokens, found: { issuer: found.issuer, meta: found.meta, resource: found.resource }, client_id: c.client_id }))
97
+ return tokens
98
+ }
99
+
100
+ /** The browser came back: ?code=…&state=… (or ?error=…). True when it was one of ours. */
101
+ function handleCallback(query) {
102
+ const params = query instanceof URLSearchParams ? query : new URLSearchParams(query)
103
+ const flow = pending.get(params.get('state'))
104
+ if (!flow) return false
105
+ pending.delete(params.get('state'))
106
+ clearTimeout(flow.timer)
107
+ if (params.get('error')) flow.reject(new Error(`sign-in refused: ${params.get('error_description') || params.get('error')}`))
108
+ else flow.resolve(params.get('code'))
109
+ return true
110
+ }
111
+
112
+ /** The access token for a server: kept, refreshed when expired, or null when one must sign in. */
113
+ async function token(serverUrl, { store, fetchImpl = fetch, force = false } = {}) {
114
+ const saved = await store.get(KEY(serverUrl))
115
+ if (!saved) return null
116
+ const t = JSON.parse(saved)
117
+ if (!force && (!t.expires_at || t.expires_at > Date.now())) return t.access_token
118
+ if (!t.refresh_token) return null
119
+ try {
120
+ const fresh = await tokenRequest(t.found, { grant_type: 'refresh_token', refresh_token: t.refresh_token, client_id: t.client_id, resource: t.found.resource }, fetchImpl)
121
+ await store.set(KEY(serverUrl), JSON.stringify({ ...t, ...fresh, refresh_token: fresh.refresh_token || t.refresh_token }))
122
+ return fresh.access_token
123
+ } catch { return null }
124
+ }
125
+
126
+ async function signOut(serverUrl, { store }) { await store.delete(KEY(serverUrl)) }
127
+
128
+ module.exports = { AuthNeeded, challenge, discover, signIn, handleCallback, token, signOut }
@@ -0,0 +1,9 @@
1
+ {
2
+ "apiKey": "AIzaSyBsvK8wmSONFUQaHeSzST-4UMbcH5ZB3Ps",
3
+ "authDomain": "aipictury.firebaseapp.com",
4
+ "projectId": "aipictury",
5
+ "storageBucket": "aipictury.firebasestorage.app",
6
+ "messagingSenderId": "662869441939",
7
+ "appId": "1:662869441939:web:b3c8d0fe77266e0d84fc66",
8
+ "measurementId": "G-VC48099T1J"
9
+ }