@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.
- package/LICENSE.md +77 -0
- package/README.md +39 -0
- package/ext/about.js +209 -0
- package/ext/account.js +242 -0
- package/ext/accountUi.js +287 -0
- package/ext/agent.js +1929 -0
- package/ext/browser.js +263 -0
- package/ext/cloud.js +385 -0
- package/ext/context.js +241 -0
- package/ext/deps.js +142 -0
- package/ext/dialogs.js +803 -0
- package/ext/diff.js +150 -0
- package/ext/extension.js +2483 -0
- package/ext/feedback.js +55 -0
- package/ext/files.js +565 -0
- package/ext/firestore.js +131 -0
- package/ext/gpuRun.js +77 -0
- package/ext/gpuServer.js +76 -0
- package/ext/hooks.js +153 -0
- package/ext/log.js +29 -0
- package/ext/lsp.js +122 -0
- package/ext/mcp.js +469 -0
- package/ext/mcpAuth.js +128 -0
- package/ext/media/vendor/firebase-config.json +9 -0
- package/ext/media/vendor/signin.js +1 -0
- package/ext/mediagen.js +174 -0
- package/ext/memory.js +117 -0
- package/ext/notebook.js +82 -0
- package/ext/package.json +1234 -0
- package/ext/paths.js +50 -0
- package/ext/permissions.js +229 -0
- package/ext/plans.js +110 -0
- package/ext/plugins.js +243 -0
- package/ext/prefs.js +157 -0
- package/ext/remote.js +170 -0
- package/ext/review.js +126 -0
- package/ext/sandbox.js +79 -0
- package/ext/scheduler.js +87 -0
- package/ext/sessions.js +228 -0
- package/ext/sessionsView.js +449 -0
- package/ext/shell.js +357 -0
- package/ext/storage.js +80 -0
- package/ext/tasklist.js +112 -0
- package/ext/text.js +31 -0
- package/ext/tools.js +781 -0
- package/ext/usage.js +77 -0
- package/ext/voice.js +135 -0
- package/ext/workbench.js +162 -0
- package/ext/workflow.js +51 -0
- package/ext/worktree.js +83 -0
- package/main.js +758 -0
- package/package.json +43 -0
- package/terminal.js +124 -0
- 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
|
+
}
|