@link-assistant/agent 0.0.8
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/EXAMPLES.md +383 -0
- package/LICENSE +24 -0
- package/MODELS.md +95 -0
- package/README.md +388 -0
- package/TOOLS.md +134 -0
- package/package.json +89 -0
- package/src/agent/agent.ts +150 -0
- package/src/agent/generate.txt +75 -0
- package/src/auth/index.ts +64 -0
- package/src/bun/index.ts +96 -0
- package/src/bus/global.ts +10 -0
- package/src/bus/index.ts +119 -0
- package/src/cli/bootstrap.js +41 -0
- package/src/cli/bootstrap.ts +17 -0
- package/src/cli/cmd/agent.ts +165 -0
- package/src/cli/cmd/cmd.ts +5 -0
- package/src/cli/cmd/export.ts +88 -0
- package/src/cli/cmd/mcp.ts +80 -0
- package/src/cli/cmd/models.ts +58 -0
- package/src/cli/cmd/run.ts +359 -0
- package/src/cli/cmd/stats.ts +276 -0
- package/src/cli/error.ts +27 -0
- package/src/command/index.ts +73 -0
- package/src/command/template/initialize.txt +10 -0
- package/src/config/config.ts +705 -0
- package/src/config/markdown.ts +41 -0
- package/src/file/ripgrep.ts +391 -0
- package/src/file/time.ts +38 -0
- package/src/file/watcher.ts +75 -0
- package/src/file.ts +6 -0
- package/src/flag/flag.ts +19 -0
- package/src/format/formatter.ts +248 -0
- package/src/format/index.ts +137 -0
- package/src/global/index.ts +52 -0
- package/src/id/id.ts +72 -0
- package/src/index.js +371 -0
- package/src/mcp/index.ts +289 -0
- package/src/patch/index.ts +622 -0
- package/src/project/bootstrap.ts +22 -0
- package/src/project/instance.ts +67 -0
- package/src/project/project.ts +105 -0
- package/src/project/state.ts +65 -0
- package/src/provider/models-macro.ts +11 -0
- package/src/provider/models.ts +98 -0
- package/src/provider/opencode.js +47 -0
- package/src/provider/provider.ts +636 -0
- package/src/provider/transform.ts +241 -0
- package/src/server/project.ts +48 -0
- package/src/server/server.ts +249 -0
- package/src/session/agent.js +204 -0
- package/src/session/compaction.ts +249 -0
- package/src/session/index.ts +380 -0
- package/src/session/message-v2.ts +758 -0
- package/src/session/message.ts +189 -0
- package/src/session/processor.ts +356 -0
- package/src/session/prompt/anthropic-20250930.txt +166 -0
- package/src/session/prompt/anthropic.txt +105 -0
- package/src/session/prompt/anthropic_spoof.txt +1 -0
- package/src/session/prompt/beast.txt +147 -0
- package/src/session/prompt/build-switch.txt +5 -0
- package/src/session/prompt/codex.txt +318 -0
- package/src/session/prompt/copilot-gpt-5.txt +143 -0
- package/src/session/prompt/gemini.txt +155 -0
- package/src/session/prompt/grok-code.txt +1 -0
- package/src/session/prompt/plan.txt +8 -0
- package/src/session/prompt/polaris.txt +107 -0
- package/src/session/prompt/qwen.txt +109 -0
- package/src/session/prompt/summarize-turn.txt +5 -0
- package/src/session/prompt/summarize.txt +10 -0
- package/src/session/prompt/title.txt +25 -0
- package/src/session/prompt.ts +1390 -0
- package/src/session/retry.ts +53 -0
- package/src/session/revert.ts +108 -0
- package/src/session/status.ts +75 -0
- package/src/session/summary.ts +179 -0
- package/src/session/system.ts +138 -0
- package/src/session/todo.ts +36 -0
- package/src/snapshot/index.ts +197 -0
- package/src/storage/storage.ts +226 -0
- package/src/tool/bash.ts +193 -0
- package/src/tool/bash.txt +121 -0
- package/src/tool/batch.ts +173 -0
- package/src/tool/batch.txt +28 -0
- package/src/tool/codesearch.ts +123 -0
- package/src/tool/codesearch.txt +12 -0
- package/src/tool/edit.ts +604 -0
- package/src/tool/edit.txt +10 -0
- package/src/tool/glob.ts +65 -0
- package/src/tool/glob.txt +6 -0
- package/src/tool/grep.ts +116 -0
- package/src/tool/grep.txt +8 -0
- package/src/tool/invalid.ts +17 -0
- package/src/tool/ls.ts +110 -0
- package/src/tool/ls.txt +1 -0
- package/src/tool/multiedit.ts +46 -0
- package/src/tool/multiedit.txt +41 -0
- package/src/tool/patch.ts +188 -0
- package/src/tool/patch.txt +1 -0
- package/src/tool/read.ts +201 -0
- package/src/tool/read.txt +12 -0
- package/src/tool/registry.ts +87 -0
- package/src/tool/task.ts +126 -0
- package/src/tool/task.txt +60 -0
- package/src/tool/todo.ts +39 -0
- package/src/tool/todoread.txt +14 -0
- package/src/tool/todowrite.txt +167 -0
- package/src/tool/tool.ts +66 -0
- package/src/tool/webfetch.ts +171 -0
- package/src/tool/webfetch.txt +14 -0
- package/src/tool/websearch.ts +133 -0
- package/src/tool/websearch.txt +11 -0
- package/src/tool/write.ts +33 -0
- package/src/tool/write.txt +8 -0
- package/src/util/binary.ts +41 -0
- package/src/util/context.ts +25 -0
- package/src/util/defer.ts +12 -0
- package/src/util/error.ts +54 -0
- package/src/util/eventloop.ts +20 -0
- package/src/util/filesystem.ts +69 -0
- package/src/util/fn.ts +11 -0
- package/src/util/iife.ts +3 -0
- package/src/util/keybind.ts +79 -0
- package/src/util/lazy.ts +11 -0
- package/src/util/locale.ts +39 -0
- package/src/util/lock.ts +98 -0
- package/src/util/log.ts +177 -0
- package/src/util/queue.ts +19 -0
- package/src/util/rpc.ts +42 -0
- package/src/util/scrap.ts +10 -0
- package/src/util/signal.ts +12 -0
- package/src/util/timeout.ts +14 -0
- package/src/util/token.ts +7 -0
- package/src/util/wildcard.ts +54 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { Server } from './server/server.ts'
|
|
4
|
+
import { Instance } from './project/instance.ts'
|
|
5
|
+
import { Log } from './util/log.ts'
|
|
6
|
+
import { Bus } from './bus/index.ts'
|
|
7
|
+
import { Session } from './session/index.ts'
|
|
8
|
+
import { SessionPrompt } from './session/prompt.ts'
|
|
9
|
+
import { EOL } from 'os'
|
|
10
|
+
import yargs from 'yargs'
|
|
11
|
+
import { hideBin } from 'yargs/helpers'
|
|
12
|
+
|
|
13
|
+
async function readStdin() {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
let data = ''
|
|
16
|
+
const onData = chunk => {
|
|
17
|
+
data += chunk
|
|
18
|
+
}
|
|
19
|
+
const onEnd = () => {
|
|
20
|
+
cleanup()
|
|
21
|
+
resolve(data)
|
|
22
|
+
}
|
|
23
|
+
const onError = err => {
|
|
24
|
+
cleanup()
|
|
25
|
+
reject(err)
|
|
26
|
+
}
|
|
27
|
+
const cleanup = () => {
|
|
28
|
+
process.stdin.removeListener('data', onData)
|
|
29
|
+
process.stdin.removeListener('end', onEnd)
|
|
30
|
+
process.stdin.removeListener('error', onError)
|
|
31
|
+
}
|
|
32
|
+
process.stdin.on('data', onData)
|
|
33
|
+
process.stdin.on('end', onEnd)
|
|
34
|
+
process.stdin.on('error', onError)
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function main() {
|
|
39
|
+
try {
|
|
40
|
+
// Parse command line arguments
|
|
41
|
+
const argv = await yargs(hideBin(process.argv))
|
|
42
|
+
.option('model', {
|
|
43
|
+
type: 'string',
|
|
44
|
+
description: 'Model to use in format providerID/modelID',
|
|
45
|
+
default: 'opencode/grok-code'
|
|
46
|
+
})
|
|
47
|
+
.option('system-message', {
|
|
48
|
+
type: 'string',
|
|
49
|
+
description: 'Full override of the system message'
|
|
50
|
+
})
|
|
51
|
+
.option('system-message-file', {
|
|
52
|
+
type: 'string',
|
|
53
|
+
description: 'Full override of the system message from file'
|
|
54
|
+
})
|
|
55
|
+
.option('append-system-message', {
|
|
56
|
+
type: 'string',
|
|
57
|
+
description: 'Append to the default system message'
|
|
58
|
+
})
|
|
59
|
+
.option('append-system-message-file', {
|
|
60
|
+
type: 'string',
|
|
61
|
+
description: 'Append to the default system message from file'
|
|
62
|
+
})
|
|
63
|
+
.option('server', {
|
|
64
|
+
type: 'boolean',
|
|
65
|
+
description: 'Run in server mode (default)',
|
|
66
|
+
default: true
|
|
67
|
+
})
|
|
68
|
+
.help()
|
|
69
|
+
.argv
|
|
70
|
+
|
|
71
|
+
// Parse model argument
|
|
72
|
+
const modelParts = argv.model.split('/')
|
|
73
|
+
const providerID = modelParts[0] || 'opencode'
|
|
74
|
+
const modelID = modelParts[1] || 'grok-code'
|
|
75
|
+
|
|
76
|
+
// Read system message files
|
|
77
|
+
let systemMessage = argv['system-message']
|
|
78
|
+
let appendSystemMessage = argv['append-system-message']
|
|
79
|
+
|
|
80
|
+
if (argv['system-message-file']) {
|
|
81
|
+
const resolvedPath = require('path').resolve(process.cwd(), argv['system-message-file'])
|
|
82
|
+
const file = Bun.file(resolvedPath)
|
|
83
|
+
if (!(await file.exists())) {
|
|
84
|
+
console.error(`System message file not found: ${argv['system-message-file']}`)
|
|
85
|
+
process.exit(1)
|
|
86
|
+
}
|
|
87
|
+
systemMessage = await file.text()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (argv['append-system-message-file']) {
|
|
91
|
+
const resolvedPath = require('path').resolve(process.cwd(), argv['append-system-message-file'])
|
|
92
|
+
const file = Bun.file(resolvedPath)
|
|
93
|
+
if (!(await file.exists())) {
|
|
94
|
+
console.error(`Append system message file not found: ${argv['append-system-message-file']}`)
|
|
95
|
+
process.exit(1)
|
|
96
|
+
}
|
|
97
|
+
appendSystemMessage = await file.text()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Initialize logging to redirect to log file instead of stderr
|
|
101
|
+
// This prevents log messages from mixing with JSON output
|
|
102
|
+
await Log.init({
|
|
103
|
+
print: false, // Don't print to stderr
|
|
104
|
+
level: 'INFO'
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// Read input from stdin
|
|
108
|
+
const input = await readStdin()
|
|
109
|
+
const trimmedInput = input.trim()
|
|
110
|
+
|
|
111
|
+
// Try to parse as JSON, if it fails treat it as plain text message
|
|
112
|
+
let request
|
|
113
|
+
try {
|
|
114
|
+
request = JSON.parse(trimmedInput)
|
|
115
|
+
} catch (e) {
|
|
116
|
+
// Not JSON, treat as plain text message
|
|
117
|
+
request = {
|
|
118
|
+
message: trimmedInput
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Wrap in Instance.provide for OpenCode infrastructure
|
|
123
|
+
await Instance.provide({
|
|
124
|
+
directory: process.cwd(),
|
|
125
|
+
fn: async () => {
|
|
126
|
+
if (argv.server) {
|
|
127
|
+
// SERVER MODE: Start server and communicate via HTTP
|
|
128
|
+
await runServerMode()
|
|
129
|
+
} else {
|
|
130
|
+
// DIRECT MODE: Run everything in single process
|
|
131
|
+
await runDirectMode()
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
async function runServerMode() {
|
|
137
|
+
// Start server like OpenCode does
|
|
138
|
+
const server = Server.listen({ port: 0, hostname: "127.0.0.1" })
|
|
139
|
+
let unsub = null
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
// Create a session
|
|
143
|
+
const createRes = await fetch(`http://${server.hostname}:${server.port}/session`, {
|
|
144
|
+
method: 'POST',
|
|
145
|
+
headers: { 'Content-Type': 'application/json' },
|
|
146
|
+
body: JSON.stringify({})
|
|
147
|
+
})
|
|
148
|
+
const session = await createRes.json()
|
|
149
|
+
const sessionID = session.id
|
|
150
|
+
|
|
151
|
+
if (!sessionID) {
|
|
152
|
+
throw new Error("Failed to create session")
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Subscribe to all bus events to output them in OpenCode format
|
|
156
|
+
const eventPromise = new Promise((resolve) => {
|
|
157
|
+
unsub = Bus.subscribeAll((event) => {
|
|
158
|
+
// Output events in OpenCode JSON format
|
|
159
|
+
if (event.type === 'message.part.updated') {
|
|
160
|
+
const part = event.properties.part
|
|
161
|
+
if (part.sessionID !== sessionID) return
|
|
162
|
+
|
|
163
|
+
// Output different event types (pretty-printed for readability)
|
|
164
|
+
if (part.type === 'step-start') {
|
|
165
|
+
process.stdout.write(JSON.stringify({
|
|
166
|
+
type: 'step_start',
|
|
167
|
+
timestamp: Date.now(),
|
|
168
|
+
sessionID,
|
|
169
|
+
part
|
|
170
|
+
}, null, 2) + EOL)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (part.type === 'step-finish') {
|
|
174
|
+
process.stdout.write(JSON.stringify({
|
|
175
|
+
type: 'step_finish',
|
|
176
|
+
timestamp: Date.now(),
|
|
177
|
+
sessionID,
|
|
178
|
+
part
|
|
179
|
+
}, null, 2) + EOL)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (part.type === 'text' && part.time?.end) {
|
|
183
|
+
process.stdout.write(JSON.stringify({
|
|
184
|
+
type: 'text',
|
|
185
|
+
timestamp: Date.now(),
|
|
186
|
+
sessionID,
|
|
187
|
+
part
|
|
188
|
+
}, null, 2) + EOL)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (part.type === 'tool' && part.state.status === 'completed') {
|
|
192
|
+
process.stdout.write(JSON.stringify({
|
|
193
|
+
type: 'tool_use',
|
|
194
|
+
timestamp: Date.now(),
|
|
195
|
+
sessionID,
|
|
196
|
+
part
|
|
197
|
+
}, null, 2) + EOL)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Handle session idle to know when to stop
|
|
202
|
+
if (event.type === 'session.idle' && event.properties.sessionID === sessionID) {
|
|
203
|
+
resolve()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Handle errors
|
|
207
|
+
if (event.type === 'session.error') {
|
|
208
|
+
const props = event.properties
|
|
209
|
+
if (props.sessionID !== sessionID || !props.error) return
|
|
210
|
+
process.stdout.write(JSON.stringify({
|
|
211
|
+
type: 'error',
|
|
212
|
+
timestamp: Date.now(),
|
|
213
|
+
sessionID,
|
|
214
|
+
error: props.error
|
|
215
|
+
}, null, 2) + EOL)
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
// Send message to session with specified model (default: opencode/grok-code)
|
|
221
|
+
const message = request.message || "hi"
|
|
222
|
+
const parts = [{ type: "text", text: message }]
|
|
223
|
+
|
|
224
|
+
// Start the prompt (don't wait for response, events come via Bus)
|
|
225
|
+
fetch(`http://${server.hostname}:${server.port}/session/${sessionID}/message`, {
|
|
226
|
+
method: 'POST',
|
|
227
|
+
headers: { 'Content-Type': 'application/json' },
|
|
228
|
+
body: JSON.stringify({
|
|
229
|
+
parts,
|
|
230
|
+
model: {
|
|
231
|
+
providerID,
|
|
232
|
+
modelID
|
|
233
|
+
},
|
|
234
|
+
system: systemMessage,
|
|
235
|
+
appendSystem: appendSystemMessage
|
|
236
|
+
})
|
|
237
|
+
}).catch(() => {
|
|
238
|
+
// Ignore errors, we're listening to events
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
// Wait for session to become idle
|
|
242
|
+
await eventPromise
|
|
243
|
+
} finally {
|
|
244
|
+
// Always clean up resources
|
|
245
|
+
if (unsub) unsub()
|
|
246
|
+
server.stop()
|
|
247
|
+
await Instance.dispose()
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function runDirectMode() {
|
|
252
|
+
// DIRECT MODE: Run in single process without server
|
|
253
|
+
let unsub = null
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
// Create a session directly
|
|
257
|
+
const session = await Session.createNext({
|
|
258
|
+
directory: process.cwd()
|
|
259
|
+
})
|
|
260
|
+
const sessionID = session.id
|
|
261
|
+
|
|
262
|
+
// Subscribe to all bus events to output them in OpenCode format
|
|
263
|
+
const eventPromise = new Promise((resolve) => {
|
|
264
|
+
unsub = Bus.subscribeAll((event) => {
|
|
265
|
+
// Output events in OpenCode JSON format
|
|
266
|
+
if (event.type === 'message.part.updated') {
|
|
267
|
+
const part = event.properties.part
|
|
268
|
+
if (part.sessionID !== sessionID) return
|
|
269
|
+
|
|
270
|
+
// Output different event types (pretty-printed for readability)
|
|
271
|
+
if (part.type === 'step-start') {
|
|
272
|
+
process.stdout.write(JSON.stringify({
|
|
273
|
+
type: 'step_start',
|
|
274
|
+
timestamp: Date.now(),
|
|
275
|
+
sessionID,
|
|
276
|
+
part
|
|
277
|
+
}, null, 2) + EOL)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (part.type === 'step-finish') {
|
|
281
|
+
process.stdout.write(JSON.stringify({
|
|
282
|
+
type: 'step_finish',
|
|
283
|
+
timestamp: Date.now(),
|
|
284
|
+
sessionID,
|
|
285
|
+
part
|
|
286
|
+
}, null, 2) + EOL)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (part.type === 'text' && part.time?.end) {
|
|
290
|
+
process.stdout.write(JSON.stringify({
|
|
291
|
+
type: 'text',
|
|
292
|
+
timestamp: Date.now(),
|
|
293
|
+
sessionID,
|
|
294
|
+
part
|
|
295
|
+
}, null, 2) + EOL)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (part.type === 'tool' && part.state.status === 'completed') {
|
|
299
|
+
process.stdout.write(JSON.stringify({
|
|
300
|
+
type: 'tool_use',
|
|
301
|
+
timestamp: Date.now(),
|
|
302
|
+
sessionID,
|
|
303
|
+
part
|
|
304
|
+
}, null, 2) + EOL)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Handle session idle to know when to stop
|
|
309
|
+
if (event.type === 'session.idle' && event.properties.sessionID === sessionID) {
|
|
310
|
+
resolve()
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Handle errors
|
|
314
|
+
if (event.type === 'session.error') {
|
|
315
|
+
const props = event.properties
|
|
316
|
+
if (props.sessionID !== sessionID || !props.error) return
|
|
317
|
+
process.stdout.write(JSON.stringify({
|
|
318
|
+
type: 'error',
|
|
319
|
+
timestamp: Date.now(),
|
|
320
|
+
sessionID,
|
|
321
|
+
error: props.error
|
|
322
|
+
}, null, 2) + EOL)
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
// Send message to session directly
|
|
328
|
+
const message = request.message || "hi"
|
|
329
|
+
const parts = [{ type: "text", text: message }]
|
|
330
|
+
|
|
331
|
+
// Start the prompt directly without HTTP
|
|
332
|
+
SessionPrompt.prompt({
|
|
333
|
+
sessionID,
|
|
334
|
+
parts,
|
|
335
|
+
model: {
|
|
336
|
+
providerID,
|
|
337
|
+
modelID
|
|
338
|
+
},
|
|
339
|
+
system: systemMessage,
|
|
340
|
+
appendSystem: appendSystemMessage
|
|
341
|
+
}).catch((error) => {
|
|
342
|
+
process.stdout.write(JSON.stringify({
|
|
343
|
+
type: 'error',
|
|
344
|
+
timestamp: Date.now(),
|
|
345
|
+
sessionID,
|
|
346
|
+
error: error instanceof Error ? error.message : String(error)
|
|
347
|
+
}, null, 2) + EOL)
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
// Wait for session to become idle
|
|
351
|
+
await eventPromise
|
|
352
|
+
} finally {
|
|
353
|
+
// Always clean up resources
|
|
354
|
+
if (unsub) unsub()
|
|
355
|
+
await Instance.dispose()
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Explicitly exit to ensure process terminates
|
|
360
|
+
process.exit(0)
|
|
361
|
+
} catch (error) {
|
|
362
|
+
console.error(JSON.stringify({
|
|
363
|
+
type: 'error',
|
|
364
|
+
timestamp: Date.now(),
|
|
365
|
+
error: error instanceof Error ? error.message : String(error)
|
|
366
|
+
}))
|
|
367
|
+
process.exit(1)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
main()
|
package/src/mcp/index.ts
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { experimental_createMCPClient } from "@ai-sdk/mcp"
|
|
2
|
+
import { type Tool } from "ai"
|
|
3
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
|
4
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
|
|
5
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|
6
|
+
import { Config } from "../config/config"
|
|
7
|
+
import { Log } from "../util/log"
|
|
8
|
+
import { NamedError } from "../util/error"
|
|
9
|
+
import z from "zod/v4"
|
|
10
|
+
import { Instance } from "../project/instance"
|
|
11
|
+
import { withTimeout } from "../util/timeout"
|
|
12
|
+
|
|
13
|
+
export namespace MCP {
|
|
14
|
+
const log = Log.create({ service: "mcp" })
|
|
15
|
+
|
|
16
|
+
export const Failed = NamedError.create(
|
|
17
|
+
"MCPFailed",
|
|
18
|
+
z.object({
|
|
19
|
+
name: z.string(),
|
|
20
|
+
}),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
type Client = Awaited<ReturnType<typeof experimental_createMCPClient>>
|
|
24
|
+
|
|
25
|
+
export const Status = z
|
|
26
|
+
.discriminatedUnion("status", [
|
|
27
|
+
z
|
|
28
|
+
.object({
|
|
29
|
+
status: z.literal("connected"),
|
|
30
|
+
})
|
|
31
|
+
.meta({
|
|
32
|
+
ref: "MCPStatusConnected",
|
|
33
|
+
}),
|
|
34
|
+
z
|
|
35
|
+
.object({
|
|
36
|
+
status: z.literal("disabled"),
|
|
37
|
+
})
|
|
38
|
+
.meta({
|
|
39
|
+
ref: "MCPStatusDisabled",
|
|
40
|
+
}),
|
|
41
|
+
z
|
|
42
|
+
.object({
|
|
43
|
+
status: z.literal("failed"),
|
|
44
|
+
error: z.string(),
|
|
45
|
+
})
|
|
46
|
+
.meta({
|
|
47
|
+
ref: "MCPStatusFailed",
|
|
48
|
+
}),
|
|
49
|
+
])
|
|
50
|
+
.meta({
|
|
51
|
+
ref: "MCPStatus",
|
|
52
|
+
})
|
|
53
|
+
export type Status = z.infer<typeof Status>
|
|
54
|
+
type MCPClient = Awaited<ReturnType<typeof experimental_createMCPClient>>
|
|
55
|
+
|
|
56
|
+
const state = Instance.state(
|
|
57
|
+
async () => {
|
|
58
|
+
const cfg = await Config.get()
|
|
59
|
+
const config = cfg.mcp ?? {}
|
|
60
|
+
const clients: Record<string, Client> = {}
|
|
61
|
+
const status: Record<string, Status> = {}
|
|
62
|
+
|
|
63
|
+
await Promise.all(
|
|
64
|
+
Object.entries(config).map(async ([key, mcp]) => {
|
|
65
|
+
const result = await create(key, mcp).catch(() => undefined)
|
|
66
|
+
if (!result) return
|
|
67
|
+
|
|
68
|
+
status[key] = result.status
|
|
69
|
+
|
|
70
|
+
if (result.mcpClient) {
|
|
71
|
+
clients[key] = result.mcpClient
|
|
72
|
+
}
|
|
73
|
+
}),
|
|
74
|
+
)
|
|
75
|
+
return {
|
|
76
|
+
status,
|
|
77
|
+
clients,
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
async (state) => {
|
|
81
|
+
await Promise.all(
|
|
82
|
+
Object.values(state.clients).map((client) =>
|
|
83
|
+
client.close().catch((error) => {
|
|
84
|
+
log.error("Failed to close MCP client", {
|
|
85
|
+
error,
|
|
86
|
+
})
|
|
87
|
+
}),
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
export async function add(name: string, mcp: Config.Mcp) {
|
|
94
|
+
const s = await state()
|
|
95
|
+
const result = await create(name, mcp)
|
|
96
|
+
if (!result) {
|
|
97
|
+
const status = {
|
|
98
|
+
status: "failed" as const,
|
|
99
|
+
error: "unknown error",
|
|
100
|
+
}
|
|
101
|
+
s.status[name] = status
|
|
102
|
+
return {
|
|
103
|
+
status,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (!result.mcpClient) {
|
|
107
|
+
s.status[name] = result.status
|
|
108
|
+
return {
|
|
109
|
+
status: s.status,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
s.clients[name] = result.mcpClient
|
|
113
|
+
s.status[name] = result.status
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
status: s.status,
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function create(key: string, mcp: Config.Mcp) {
|
|
121
|
+
if (mcp.enabled === false) {
|
|
122
|
+
log.info("mcp server disabled", { key })
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
log.info("found", { key, type: mcp.type })
|
|
126
|
+
let mcpClient: MCPClient | undefined
|
|
127
|
+
let status: Status | undefined = undefined
|
|
128
|
+
|
|
129
|
+
if (mcp.type === "remote") {
|
|
130
|
+
const transports = [
|
|
131
|
+
{
|
|
132
|
+
name: "StreamableHTTP",
|
|
133
|
+
transport: new StreamableHTTPClientTransport(new URL(mcp.url), {
|
|
134
|
+
requestInit: {
|
|
135
|
+
headers: mcp.headers,
|
|
136
|
+
},
|
|
137
|
+
}),
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: "SSE",
|
|
141
|
+
transport: new SSEClientTransport(new URL(mcp.url), {
|
|
142
|
+
requestInit: {
|
|
143
|
+
headers: mcp.headers,
|
|
144
|
+
},
|
|
145
|
+
}),
|
|
146
|
+
},
|
|
147
|
+
]
|
|
148
|
+
let lastError: Error | undefined
|
|
149
|
+
for (const { name, transport } of transports) {
|
|
150
|
+
const result = await experimental_createMCPClient({
|
|
151
|
+
name: "opencode",
|
|
152
|
+
transport,
|
|
153
|
+
})
|
|
154
|
+
.then((client) => {
|
|
155
|
+
log.info("connected", { key, transport: name })
|
|
156
|
+
mcpClient = client
|
|
157
|
+
status = { status: "connected" }
|
|
158
|
+
return true
|
|
159
|
+
})
|
|
160
|
+
.catch((error) => {
|
|
161
|
+
lastError = error instanceof Error ? error : new Error(String(error))
|
|
162
|
+
log.debug("transport connection failed", {
|
|
163
|
+
key,
|
|
164
|
+
transport: name,
|
|
165
|
+
url: mcp.url,
|
|
166
|
+
error: lastError.message,
|
|
167
|
+
})
|
|
168
|
+
status = {
|
|
169
|
+
status: "failed" as const,
|
|
170
|
+
error: lastError.message,
|
|
171
|
+
}
|
|
172
|
+
return false
|
|
173
|
+
})
|
|
174
|
+
if (result) break
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (mcp.type === "local") {
|
|
179
|
+
const [cmd, ...args] = mcp.command
|
|
180
|
+
await experimental_createMCPClient({
|
|
181
|
+
name: "opencode",
|
|
182
|
+
transport: new StdioClientTransport({
|
|
183
|
+
stderr: "ignore",
|
|
184
|
+
command: cmd,
|
|
185
|
+
args,
|
|
186
|
+
env: {
|
|
187
|
+
...process.env,
|
|
188
|
+
...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}),
|
|
189
|
+
...mcp.environment,
|
|
190
|
+
},
|
|
191
|
+
}),
|
|
192
|
+
})
|
|
193
|
+
.then((client) => {
|
|
194
|
+
mcpClient = client
|
|
195
|
+
status = {
|
|
196
|
+
status: "connected",
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
.catch((error) => {
|
|
200
|
+
log.error("local mcp startup failed", {
|
|
201
|
+
key,
|
|
202
|
+
command: mcp.command,
|
|
203
|
+
error: error instanceof Error ? error.message : String(error),
|
|
204
|
+
})
|
|
205
|
+
status = {
|
|
206
|
+
status: "failed" as const,
|
|
207
|
+
error: error instanceof Error ? error.message : String(error),
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!status) {
|
|
213
|
+
status = {
|
|
214
|
+
status: "failed" as const,
|
|
215
|
+
error: "Unknown error",
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (!mcpClient) {
|
|
220
|
+
return {
|
|
221
|
+
mcpClient: undefined,
|
|
222
|
+
status,
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const result = await withTimeout(mcpClient.tools(), mcp.timeout ?? 5000).catch((err) => {
|
|
227
|
+
log.error("failed to get tools from client", { key, error: err })
|
|
228
|
+
return undefined
|
|
229
|
+
})
|
|
230
|
+
if (!result) {
|
|
231
|
+
await mcpClient.close().catch((error) => {
|
|
232
|
+
log.error("Failed to close MCP client", {
|
|
233
|
+
error,
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
status = {
|
|
237
|
+
status: "failed",
|
|
238
|
+
error: "Failed to get tools",
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
mcpClient: undefined,
|
|
242
|
+
status: {
|
|
243
|
+
status: "failed" as const,
|
|
244
|
+
error: "Failed to get tools",
|
|
245
|
+
},
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
log.info("create() successfully created client", { key, toolCount: Object.keys(result).length })
|
|
250
|
+
return {
|
|
251
|
+
mcpClient,
|
|
252
|
+
status,
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function status() {
|
|
257
|
+
return state().then((state) => state.status)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export async function clients() {
|
|
261
|
+
return state().then((state) => state.clients)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export async function tools() {
|
|
265
|
+
const result: Record<string, Tool> = {}
|
|
266
|
+
const s = await state()
|
|
267
|
+
const clientsSnapshot = await clients()
|
|
268
|
+
for (const [clientName, client] of Object.entries(clientsSnapshot)) {
|
|
269
|
+
const tools = await client.tools().catch((e) => {
|
|
270
|
+
log.error("failed to get tools", { clientName, error: e.message })
|
|
271
|
+
const failedStatus = {
|
|
272
|
+
status: "failed" as const,
|
|
273
|
+
error: e instanceof Error ? e.message : String(e),
|
|
274
|
+
}
|
|
275
|
+
s.status[clientName] = failedStatus
|
|
276
|
+
delete s.clients[clientName]
|
|
277
|
+
})
|
|
278
|
+
if (!tools) {
|
|
279
|
+
continue
|
|
280
|
+
}
|
|
281
|
+
for (const [toolName, tool] of Object.entries(tools)) {
|
|
282
|
+
const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
|
|
283
|
+
const sanitizedToolName = toolName.replace(/[^a-zA-Z0-9_-]/g, "_")
|
|
284
|
+
result[sanitizedClientName + "_" + sanitizedToolName] = tool
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return result
|
|
288
|
+
}
|
|
289
|
+
}
|