opencode-raven 2.1.4 → 2.1.6
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/CHANGELOG.md +214 -0
- package/README.md +212 -313
- package/index.ts +684 -413
- package/package.json +22 -4
package/index.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
|
|
4
4
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
|
5
5
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
|
|
6
6
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|
7
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync,
|
|
7
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"
|
|
8
8
|
import { join } from "node:path"
|
|
9
9
|
import { homedir, tmpdir } from "node:os"
|
|
10
10
|
|
|
@@ -62,7 +62,9 @@ const DEFAULT_ON_DEMAND_MCP_METADATA: Record<string, OnDemandMcpMetadata> = {
|
|
|
62
62
|
},
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
const NEVER_ROUTE_TOOLS = new Set(["raven_seek", "raven_mcp", "task", "subtask"])
|
|
65
|
+
const NEVER_ROUTE_TOOLS = new Set(["raven_seek", "raven_mcp", "task", "subtask"])
|
|
66
|
+
const MAX_TIMER_MS = 2_147_483_647
|
|
67
|
+
const MAX_TIMEOUT_SECONDS = Math.floor(MAX_TIMER_MS / 1000)
|
|
66
68
|
|
|
67
69
|
// ── Bash commands that look like search workarounds ──
|
|
68
70
|
const SEARCH_BASH_RE = /\b(?:rg|ripgrep|grep|egrep|fgrep|git\s+grep|ack|ag\b|findstr|Select-String)\b|\b(?:Get-ChildItem|gci)\b(?=[^|;&\n]*(?:-Recurse|-Filter|-Include|\s-[A-Za-z]*r[A-Za-z]*\b))|\bdir\b(?=[^|;&\n]*(?:[/-][sS]\b|-Recurse|-Filter|-Include))|\bls\b(?=[^|;&\n]*(?:\s-[A-Za-z]*R[A-Za-z]*\b|--recursive\b))|\bfind\b\s+.*(?:-name|-type)\b/i
|
|
@@ -79,11 +81,49 @@ function stripShellComments(cmd: string): string {
|
|
|
79
81
|
.join("\n")
|
|
80
82
|
}
|
|
81
83
|
|
|
82
|
-
function stripQuotedContent(cmd: string): string {
|
|
83
|
-
return stripShellComments(stripHeredocs(cmd)
|
|
84
|
-
.replace(/'[^']*'/g, "''")
|
|
85
|
-
.replace(/"[^"]*"/g, '""'))
|
|
86
|
-
}
|
|
84
|
+
function stripQuotedContent(cmd: string): string {
|
|
85
|
+
return stripShellComments(stripHeredocs(cmd)
|
|
86
|
+
.replace(/'[^']*'/g, "''")
|
|
87
|
+
.replace(/"[^"]*"/g, '""'))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function unquoteShellPayload(value: string): string {
|
|
91
|
+
const payload = value.trim()
|
|
92
|
+
const quote = payload[0]
|
|
93
|
+
return (quote === '"' || quote === "'") && payload.endsWith(quote)
|
|
94
|
+
? payload.slice(1, -1)
|
|
95
|
+
: payload
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function wrappedShellPayload(cmd: string): string | undefined {
|
|
99
|
+
const command = stripShellComments(stripHeredocs(cmd)).trim()
|
|
100
|
+
const match = command.match(/^(?:"([^"]+)"|'([^']+)'|(\S+))(?:\s+([\s\S]+))?$/)
|
|
101
|
+
if (!match) return undefined
|
|
102
|
+
|
|
103
|
+
const executable = (match[1] ?? match[2] ?? match[3]).replace(/\\/g, "/").split("/").pop()?.toLowerCase()
|
|
104
|
+
const rest = match[4] ?? ""
|
|
105
|
+
if (executable === "cmd" || executable === "cmd.exe") {
|
|
106
|
+
const payload = rest.match(/^(?:\/[dqs]\s+)*\/c\s+([\s\S]+)$/i)?.[1]
|
|
107
|
+
return payload === undefined ? undefined : unquoteShellPayload(payload)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (["sh", "sh.exe", "bash", "bash.exe", "zsh", "zsh.exe", "dash", "dash.exe"].includes(executable ?? "")) {
|
|
111
|
+
const payload = rest.match(/^-[a-z]*c\s+([\s\S]+)$/i)?.[1]
|
|
112
|
+
return payload === undefined ? undefined : unquoteShellPayload(payload)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (["powershell", "powershell.exe", "pwsh", "pwsh.exe"].includes(executable ?? "")) {
|
|
116
|
+
const payload = rest.match(/(?:^|\s)-(?:command|c)\s+([\s\S]+)$/i)?.[1]
|
|
117
|
+
if (payload === undefined) return undefined
|
|
118
|
+
const unquoted = unquoteShellPayload(payload)
|
|
119
|
+
return unquoted.replace(
|
|
120
|
+
/(^|(?:(?:&&|\|\||[;|\n{(])\s*))&\s+(?:"([^"]+)"|'([^']+)'|(\S+))/g,
|
|
121
|
+
(_match, prefix, doubleQuoted, singleQuoted, bare) => `${prefix}${doubleQuoted ?? singleQuoted ?? bare}`,
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return undefined
|
|
126
|
+
}
|
|
87
127
|
|
|
88
128
|
function splitPipelineSegments(cmd: string): string[] {
|
|
89
129
|
const segments: string[] = []
|
|
@@ -112,12 +152,16 @@ function hasSearchAfterCommandSeparator(segment: string): boolean {
|
|
|
112
152
|
return separator !== -1 && SEARCH_BASH_RE.test(segment.slice(separator))
|
|
113
153
|
}
|
|
114
154
|
|
|
115
|
-
function commandLooksLikeSearch(cmd: string): boolean {
|
|
116
|
-
const
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
155
|
+
function commandLooksLikeSearch(cmd: string): boolean {
|
|
156
|
+
const wrapped = wrappedShellPayload(cmd)
|
|
157
|
+
if (wrapped !== undefined) return commandLooksLikeSearch(wrapped)
|
|
158
|
+
|
|
159
|
+
const stripped = stripQuotedContent(cmd)
|
|
160
|
+
const lower = stripped.toLowerCase().trim()
|
|
161
|
+
if (/^cmd\s+\/c\s+"?(?:findstr|find|tree)\b/.test(lower)) return true
|
|
162
|
+
if (/^cmd\s+\/c\s+"?dir\b[^\n]*\s\/s\b/.test(lower)) return true
|
|
163
|
+
|
|
164
|
+
return splitPipelineSegments(stripped).some((segment, index) => {
|
|
121
165
|
// Allow bounded filters over output already produced by the previous command:
|
|
122
166
|
// pacman -Qi libarchive | head -15
|
|
123
167
|
// 7z i | grep -i udf
|
|
@@ -127,12 +171,10 @@ function commandLooksLikeSearch(cmd: string): boolean {
|
|
|
127
171
|
})
|
|
128
172
|
}
|
|
129
173
|
|
|
130
|
-
function isSearchBash(tool: string, args: any): boolean {
|
|
131
|
-
if (tool !== "bash") return false
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
return commandLooksLikeSearch(cmd)
|
|
135
|
-
}
|
|
174
|
+
function isSearchBash(tool: string, args: any): boolean {
|
|
175
|
+
if (tool !== "bash") return false
|
|
176
|
+
return commandLooksLikeSearch(String(args?.command ?? ""))
|
|
177
|
+
}
|
|
136
178
|
|
|
137
179
|
// ── Config file shape ──
|
|
138
180
|
interface OnDemandMcpToolInfo {
|
|
@@ -155,12 +197,11 @@ interface OnDemandMcpMetadata {
|
|
|
155
197
|
lastCheckedAt?: string
|
|
156
198
|
}
|
|
157
199
|
|
|
158
|
-
interface RemoteOnDemandMcpServer extends BaseOnDemandMcpServer {
|
|
159
|
-
type: "remote"
|
|
160
|
-
url: string
|
|
161
|
-
headers?: Record<string, string>
|
|
162
|
-
|
|
163
|
-
}
|
|
200
|
+
interface RemoteOnDemandMcpServer extends BaseOnDemandMcpServer {
|
|
201
|
+
type: "remote"
|
|
202
|
+
url: string
|
|
203
|
+
headers?: Record<string, string>
|
|
204
|
+
}
|
|
164
205
|
|
|
165
206
|
interface LocalOnDemandMcpServer extends BaseOnDemandMcpServer {
|
|
166
207
|
type: "local"
|
|
@@ -316,17 +357,19 @@ function normalizeStringRecord(value: unknown): Record<string, string> | undefin
|
|
|
316
357
|
|
|
317
358
|
function normalizeOnDemandMcpServer(value: unknown): OnDemandMcpServer | undefined {
|
|
318
359
|
if (!value || typeof value !== "object") return undefined
|
|
319
|
-
const source = value as Record<string, any>
|
|
320
|
-
const base = {
|
|
321
|
-
enabled: source.enabled !== false,
|
|
322
|
-
...(typeof source.timeout === "number" && source.timeout
|
|
323
|
-
|
|
324
|
-
|
|
360
|
+
const source = value as Record<string, any>
|
|
361
|
+
const base = {
|
|
362
|
+
enabled: source.enabled !== false,
|
|
363
|
+
...(typeof source.timeout === "number" && Number.isFinite(source.timeout) && source.timeout >= 1 && source.timeout <= MAX_TIMER_MS
|
|
364
|
+
? { timeout: Math.floor(source.timeout) }
|
|
365
|
+
: {}),
|
|
366
|
+
...(typeof source.description === "string" && source.description.trim() ? { description: source.description.trim() } : {}),
|
|
367
|
+
}
|
|
325
368
|
|
|
326
369
|
if (source.type === "local") {
|
|
327
|
-
const commandParts = Array.isArray(source.command)
|
|
328
|
-
? source.command.filter((arg: unknown): arg is string => typeof arg === "string" && arg.trim())
|
|
329
|
-
: []
|
|
370
|
+
const commandParts = Array.isArray(source.command)
|
|
371
|
+
? source.command.filter((arg: unknown): arg is string => typeof arg === "string" && !!arg.trim())
|
|
372
|
+
: []
|
|
330
373
|
if (!commandParts.length) return undefined
|
|
331
374
|
const environment = normalizeStringRecord(source.environment)
|
|
332
375
|
return {
|
|
@@ -343,11 +386,10 @@ function normalizeOnDemandMcpServer(value: unknown): OnDemandMcpServer | undefin
|
|
|
343
386
|
const headers = normalizeStringRecord(source.headers)
|
|
344
387
|
return {
|
|
345
388
|
...base,
|
|
346
|
-
type: "remote",
|
|
347
|
-
url: source.url.trim(),
|
|
348
|
-
...(headers ? { headers } : {}),
|
|
349
|
-
|
|
350
|
-
}
|
|
389
|
+
type: "remote",
|
|
390
|
+
url: source.url.trim(),
|
|
391
|
+
...(headers ? { headers } : {}),
|
|
392
|
+
}
|
|
351
393
|
}
|
|
352
394
|
|
|
353
395
|
return undefined
|
|
@@ -376,11 +418,7 @@ function normalizeOnDemandMcpMetadata(value: unknown, fallback: Record<string, O
|
|
|
376
418
|
return result
|
|
377
419
|
}
|
|
378
420
|
|
|
379
|
-
function
|
|
380
|
-
return normalizeOnDemandMcpMetadata(value)
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
function normalizeOnDemandMcpServers(value: unknown, fallback: Record<string, OnDemandMcpServer> = {}): Record<string, OnDemandMcpServer> {
|
|
421
|
+
function normalizeOnDemandMcpServers(value: unknown, fallback: Record<string, OnDemandMcpServer> = {}): Record<string, OnDemandMcpServer> {
|
|
384
422
|
const source = value && typeof value === "object" ? value as Record<string, unknown> : fallback
|
|
385
423
|
const result: Record<string, OnDemandMcpServer> = {}
|
|
386
424
|
for (const [key, raw] of Object.entries(source)) {
|
|
@@ -392,18 +430,15 @@ function normalizeOnDemandMcpServers(value: unknown, fallback: Record<string, On
|
|
|
392
430
|
}
|
|
393
431
|
|
|
394
432
|
// ── Plugin ──
|
|
395
|
-
export default ((input: PluginInput) => {
|
|
433
|
+
export default (async (input: PluginInput) => {
|
|
396
434
|
const client = input.client
|
|
397
435
|
|
|
398
|
-
// Raven state lives under the global opencode config directory.
|
|
399
|
-
const opencodeConfigDir = join(homedir(), ".config", "opencode")
|
|
400
|
-
const ravenConfigDir = join(opencodeConfigDir, "opencode-raven")
|
|
401
|
-
const
|
|
402
|
-
const
|
|
403
|
-
const
|
|
404
|
-
const dynamicGuidanceFile = join(ravenConfigDir, "autogenerated-on-demand-guidance.md")
|
|
405
|
-
let migratedFromOldConfig = false
|
|
406
|
-
let initialMcpMetadata: Record<string, OnDemandMcpMetadata> = {}
|
|
436
|
+
// Raven state lives under the global opencode config directory.
|
|
437
|
+
const opencodeConfigDir = join(homedir(), ".config", "opencode")
|
|
438
|
+
const ravenConfigDir = join(opencodeConfigDir, "opencode-raven")
|
|
439
|
+
const configFile = join(ravenConfigDir, "raven-config.json")
|
|
440
|
+
const mcpMetadataFile = join(ravenConfigDir, "autogenerated-on-demand-mcp-metadata.json")
|
|
441
|
+
const dynamicGuidanceFile = join(ravenConfigDir, "autogenerated-on-demand-guidance.md")
|
|
407
442
|
|
|
408
443
|
function normalizeConfig(raw: any): RavenConfig {
|
|
409
444
|
const source = raw && typeof raw === "object" ? raw : {}
|
|
@@ -416,33 +451,33 @@ export default ((input: PluginInput) => {
|
|
|
416
451
|
normalized.routeTools = uniqueStrings(source.routeTools, DEFAULT_ROUTE_TOOLS)
|
|
417
452
|
normalized.routeToolKeywords = uniqueStrings(source.routeToolKeywords, DEFAULT_ROUTE_TOOL_KEYWORDS)
|
|
418
453
|
normalized.onDemandMcpDescriptionDetail = source.onDemandMcpDescriptionDetail === "minimized" ? "minimized" : "full"
|
|
419
|
-
normalized.onDemandMcpServers = source.onDemandMcpServers === undefined
|
|
420
|
-
? cloneOnDemandMcpServers(DEFAULT_ON_DEMAND_MCP_SERVERS)
|
|
421
|
-
: normalizeOnDemandMcpServers(source.onDemandMcpServers)
|
|
422
|
-
normalized.excludeAgents = uniqueStrings(source.excludeAgents)
|
|
423
|
-
normalized.excludeTools = uniqueStrings(source.excludeTools)
|
|
424
|
-
normalized.timeout = typeof source.timeout === "number"
|
|
425
|
-
|
|
454
|
+
normalized.onDemandMcpServers = source.onDemandMcpServers === undefined
|
|
455
|
+
? cloneOnDemandMcpServers(DEFAULT_ON_DEMAND_MCP_SERVERS)
|
|
456
|
+
: normalizeOnDemandMcpServers(source.onDemandMcpServers)
|
|
457
|
+
normalized.excludeAgents = uniqueStrings(source.excludeAgents)
|
|
458
|
+
normalized.excludeTools = uniqueStrings(source.excludeTools)
|
|
459
|
+
normalized.timeout = typeof source.timeout === "number" && Number.isFinite(source.timeout)
|
|
460
|
+
&& source.timeout >= 10 && source.timeout <= MAX_TIMEOUT_SECONDS
|
|
461
|
+
? Math.floor(source.timeout)
|
|
462
|
+
: DEFAULT_CONFIG.timeout
|
|
463
|
+
normalized.stats = source.stats && typeof source.stats === "object"
|
|
464
|
+
&& typeof source.stats.bytes === "number" && Number.isFinite(source.stats.bytes) && source.stats.bytes >= 0
|
|
465
|
+
? { bytes: Math.round(source.stats.bytes) }
|
|
466
|
+
: undefined
|
|
426
467
|
|
|
427
468
|
return normalized
|
|
428
469
|
}
|
|
429
470
|
|
|
430
|
-
function loadConfig(): RavenConfig {
|
|
431
|
-
try {
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
}
|
|
441
|
-
if (migratedFromOldConfig) {
|
|
442
|
-
try { rmSync(oldConfigFile, { force: true }) } catch { /* best-effort */ }
|
|
443
|
-
}
|
|
444
|
-
return normalized
|
|
445
|
-
}
|
|
471
|
+
function loadConfig(): RavenConfig {
|
|
472
|
+
try {
|
|
473
|
+
if (existsSync(configFile)) {
|
|
474
|
+
const raw = JSON.parse(readFileSync(configFile, "utf-8"))
|
|
475
|
+
const normalized = normalizeConfig(raw)
|
|
476
|
+
if (JSON.stringify(raw) !== JSON.stringify(normalized)) {
|
|
477
|
+
saveConfig(normalized)
|
|
478
|
+
}
|
|
479
|
+
return normalized
|
|
480
|
+
}
|
|
446
481
|
} catch { /* ignore corruption, use defaults */ }
|
|
447
482
|
// Auto-create config file with defaults on first run
|
|
448
483
|
saveConfig(DEFAULT_CONFIG)
|
|
@@ -456,16 +491,15 @@ export default ((input: PluginInput) => {
|
|
|
456
491
|
} catch { /* non-fatal: config won't persist but toggle still works in-session */ }
|
|
457
492
|
}
|
|
458
493
|
|
|
459
|
-
function loadMcpMetadata(
|
|
460
|
-
const defaults = cloneOnDemandMcpMetadata(DEFAULT_ON_DEMAND_MCP_METADATA)
|
|
494
|
+
function loadMcpMetadata(): Record<string, OnDemandMcpMetadata> {
|
|
495
|
+
const defaults = cloneOnDemandMcpMetadata(DEFAULT_ON_DEMAND_MCP_METADATA)
|
|
461
496
|
try {
|
|
462
497
|
if (existsSync(mcpMetadataFile)) {
|
|
463
498
|
return { ...defaults, ...normalizeOnDemandMcpMetadata(JSON.parse(readFileSync(mcpMetadataFile, "utf-8"))) }
|
|
464
499
|
}
|
|
465
|
-
} catch { /* ignore corruption, use defaults
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
return metadata
|
|
500
|
+
} catch { /* ignore corruption, use defaults */ }
|
|
501
|
+
saveMcpMetadata(defaults)
|
|
502
|
+
return defaults
|
|
469
503
|
}
|
|
470
504
|
|
|
471
505
|
function saveMcpMetadata(metadata: Record<string, OnDemandMcpMetadata>) {
|
|
@@ -475,21 +509,15 @@ export default ((input: PluginInput) => {
|
|
|
475
509
|
} catch { /* non-fatal: generated metadata can be refreshed */ }
|
|
476
510
|
}
|
|
477
511
|
|
|
478
|
-
function saveDynamicGuidance() {
|
|
479
|
-
try {
|
|
480
|
-
mkdirSync(ravenConfigDir, { recursive: true })
|
|
481
|
-
writeFileSync(dynamicGuidanceFile, `## Raven on-demand MCP capabilities\n\n${onDemandMcpGuidance("delegate")}\n`, "utf-8")
|
|
482
|
-
} catch { /* non-fatal: guidance still exists in tool/subagent paths */ }
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
function cleanupOldRootFiles() {
|
|
486
|
-
try {
|
|
487
|
-
if (existsSync(configFile)) rmSync(oldConfigFile, { force: true })
|
|
488
|
-
} catch { /* best-effort migration cleanup */ }
|
|
489
|
-
}
|
|
512
|
+
function saveDynamicGuidance() {
|
|
513
|
+
try {
|
|
514
|
+
mkdirSync(ravenConfigDir, { recursive: true })
|
|
515
|
+
writeFileSync(dynamicGuidanceFile, `## Raven on-demand MCP capabilities\n\n${onDemandMcpGuidance("delegate", dynamicGuidanceMcpOrder())}\n`, "utf-8")
|
|
516
|
+
} catch { /* non-fatal: guidance still exists in tool/subagent paths */ }
|
|
517
|
+
}
|
|
490
518
|
|
|
491
|
-
let config = loadConfig()
|
|
492
|
-
let mcpMetadata = loadMcpMetadata(
|
|
519
|
+
let config = loadConfig()
|
|
520
|
+
let mcpMetadata = loadMcpMetadata()
|
|
493
521
|
const ravenSessions = new Set<string>()
|
|
494
522
|
const ravenTaskCalls = new Set<string>()
|
|
495
523
|
const sessionAgents = new Map<string, string>()
|
|
@@ -502,9 +530,11 @@ export default ((input: PluginInput) => {
|
|
|
502
530
|
let updateCheckPromise: Promise<{ current: string; latest?: string; available: boolean }> | undefined
|
|
503
531
|
let updateToastPending = false
|
|
504
532
|
let mcpFailureToastPending = false
|
|
505
|
-
let duplicateMcpToastPending = false
|
|
506
|
-
let mcpRefreshScheduled = false
|
|
507
|
-
let mcpRefreshPromise: Promise<{ refreshed: string[]; failed: string[] }> | undefined
|
|
533
|
+
let duplicateMcpToastPending = false
|
|
534
|
+
let mcpRefreshScheduled = false
|
|
535
|
+
let mcpRefreshPromise: Promise<{ refreshed: string[]; failed: string[] }> | undefined
|
|
536
|
+
let mcpRefreshQueue: Promise<unknown> = Promise.resolve()
|
|
537
|
+
const mcpRefreshControllers = new Set<AbortController>()
|
|
508
538
|
let lastNotifiedMcpFailureKey = ""
|
|
509
539
|
let ravenProviderIssue: string | undefined
|
|
510
540
|
|
|
@@ -615,32 +645,71 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
615
645
|
return extra ? `${prompt}\n\nAdditional user instructions:\n${extra}` : prompt
|
|
616
646
|
}
|
|
617
647
|
|
|
618
|
-
function
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
648
|
+
function compactText(value: unknown): string {
|
|
649
|
+
return String(value ?? "").replace(/\s+/g, " ").trim()
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function truncateText(value: unknown, max = 240): string {
|
|
653
|
+
const text = compactText(value)
|
|
654
|
+
return text.length > max ? `${text.slice(0, Math.max(0, max - 3))}...` : text
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function dynamicGuidanceMcpOrder(): string[] | undefined {
|
|
658
|
+
try {
|
|
659
|
+
if (!existsSync(dynamicGuidanceFile)) return undefined
|
|
660
|
+
const names: string[] = []
|
|
661
|
+
for (const line of readFileSync(dynamicGuidanceFile, "utf-8").split("\n")) {
|
|
662
|
+
const name = line.match(/^-\s+([^:]+):/)?.[1]?.trim()
|
|
663
|
+
if (name && !names.includes(name)) names.push(name)
|
|
664
|
+
}
|
|
665
|
+
return names
|
|
666
|
+
} catch {
|
|
667
|
+
return undefined
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function orderedOnDemandMcpEntries(entries: [string, OnDemandMcpServer][], preferredOrder?: string[]): [string, OnDemandMcpServer][] {
|
|
672
|
+
if (!preferredOrder?.length) return entries
|
|
673
|
+
const order = new Map(preferredOrder.map((name, index) => [name.toLowerCase(), index]))
|
|
674
|
+
const existing = entries
|
|
675
|
+
.filter(([name]) => order.has(name.toLowerCase()))
|
|
676
|
+
.sort(([left], [right]) => order.get(left.toLowerCase())! - order.get(right.toLowerCase())!)
|
|
677
|
+
const added = entries.filter(([name]) => !order.has(name.toLowerCase()))
|
|
678
|
+
return [...existing, ...added]
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function firstSentenceText(value: unknown): string {
|
|
682
|
+
const text = compactText(value)
|
|
683
|
+
const period = text.indexOf(".")
|
|
684
|
+
return period === -1 ? text : text.slice(0, period + 1)
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function onDemandMcpGuidance(target: "delegate" | "raven" = "delegate", preferredOrder?: string[]): string {
|
|
688
|
+
const servers = config.onDemandMcpServers ?? {}
|
|
689
|
+
const activeEntries = activeOnDemandMcpEntries()
|
|
690
|
+
const entries = orderedOnDemandMcpEntries(
|
|
691
|
+
target === "raven"
|
|
692
|
+
? activeEntries
|
|
693
|
+
: activeEntries.filter(([name]) => onDemandMcpRuntimeStatus(name) === "ready"),
|
|
694
|
+
preferredOrder,
|
|
695
|
+
)
|
|
696
|
+
if (!Object.keys(servers).length) return "No on-demand MCPs are configured."
|
|
697
|
+
if (!activeEntries.length) return "No enabled on-demand MCPs are configured."
|
|
698
|
+
if (!entries.length) return "No loaded on-demand MCPs are available."
|
|
699
|
+
|
|
700
|
+
const detail = config.onDemandMcpDescriptionDetail ?? "full"
|
|
701
|
+
const lines = entries.map(([name, server]) => {
|
|
702
|
+
const metadata = mcpMetadata[name] ?? {}
|
|
703
|
+
const userDescription = firstSentenceText(server.description)
|
|
704
|
+
const generated = firstSentenceText(metadata.autoGeneratedDescription)
|
|
705
|
+
const parts = [userDescription, generated ? `Discovered capabilities: ${generated}` : ""].filter(Boolean)
|
|
706
|
+
const summary = parts.length ? parts.join(" ") : "Description discovery pending."
|
|
707
|
+
if (detail === "minimized" || !metadata.autoGeneratedTools?.length) return `- ${name}: ${summary}`
|
|
708
|
+
const tools = metadata.autoGeneratedTools
|
|
709
|
+
.map((item) => `${item.name}${item.description ? ` - ${firstSentenceText(item.description)}` : ""}`)
|
|
710
|
+
.join("; ")
|
|
711
|
+
return `- ${name}: ${summary} Tools: ${tools}`
|
|
712
|
+
})
|
|
644
713
|
if (target === "raven") {
|
|
645
714
|
return `Raven can handle these on-demand MCP capabilities through raven_mcp:\n${lines.join("\n")}`
|
|
646
715
|
}
|
|
@@ -672,20 +741,6 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
672
741
|
return result
|
|
673
742
|
}
|
|
674
743
|
|
|
675
|
-
async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
|
676
|
-
let timer: ReturnType<typeof setTimeout> | undefined
|
|
677
|
-
try {
|
|
678
|
-
return await Promise.race([
|
|
679
|
-
promise,
|
|
680
|
-
new Promise<never>((_, reject) => {
|
|
681
|
-
timer = setTimeout(() => reject(new Error(`${label} timed out after ${Math.round(ms / 1000)}s`)), ms)
|
|
682
|
-
}),
|
|
683
|
-
])
|
|
684
|
-
} finally {
|
|
685
|
-
if (timer) clearTimeout(timer)
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
|
|
689
744
|
function providerErrorMessage(error: any): string {
|
|
690
745
|
const name = String(error?.name ?? "")
|
|
691
746
|
const data = error?.data && typeof error.data === "object" ? error.data : {}
|
|
@@ -729,41 +784,114 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
729
784
|
ravenProgressWaiters.get(sessionId)?.()
|
|
730
785
|
}
|
|
731
786
|
|
|
732
|
-
type McpConnection = {
|
|
733
|
-
client: Client
|
|
734
|
-
transport: any
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
787
|
+
type McpConnection = {
|
|
788
|
+
client: Client
|
|
789
|
+
transport: any
|
|
790
|
+
lastUsed: number
|
|
791
|
+
active: number
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
type McpConnectionAttempt = {
|
|
795
|
+
promise: Promise<McpConnection>
|
|
796
|
+
controller: AbortController
|
|
797
|
+
waiters: number
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const mcpConnections = new Map<string, McpConnection>()
|
|
801
|
+
const mcpConnectionAttempts = new Map<string, McpConnectionAttempt>()
|
|
802
|
+
let mcpIdleTimer: ReturnType<typeof setInterval> | undefined
|
|
803
|
+
|
|
804
|
+
function mcpTimeoutMs(server: OnDemandMcpServer): number {
|
|
805
|
+
return server.timeout ?? (config.timeout ?? 600) * 1000
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
async function closeMcpClient(client: Client, transport: any) {
|
|
809
|
+
try {
|
|
810
|
+
await transport?.close?.()
|
|
811
|
+
} catch { /* best-effort */ }
|
|
812
|
+
try {
|
|
813
|
+
await client.close()
|
|
814
|
+
} catch { /* best-effort */ }
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
async function connectMcpClient(client: Client, transport: any, timeout: number, label: string, signal?: AbortSignal) {
|
|
818
|
+
const controller = new AbortController()
|
|
819
|
+
let timedOut = false
|
|
820
|
+
const abort = () => controller.abort(signal?.reason)
|
|
821
|
+
if (signal?.aborted) abort()
|
|
822
|
+
else signal?.addEventListener("abort", abort, { once: true })
|
|
823
|
+
const timer = setTimeout(() => {
|
|
824
|
+
timedOut = true
|
|
825
|
+
controller.abort()
|
|
826
|
+
}, timeout)
|
|
827
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
828
|
+
const fail = () => reject(new Error(timedOut ? `${label} timed out after ${Math.round(timeout / 1000)}s` : `${label} cancelled`))
|
|
829
|
+
if (controller.signal.aborted) fail()
|
|
830
|
+
else controller.signal.addEventListener("abort", fail, { once: true })
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
try {
|
|
834
|
+
await Promise.race([
|
|
835
|
+
client.connect(transport, { signal: controller.signal, timeout }),
|
|
836
|
+
aborted,
|
|
837
|
+
])
|
|
838
|
+
} catch (error) {
|
|
839
|
+
await closeMcpClient(client, transport)
|
|
840
|
+
throw error
|
|
841
|
+
} finally {
|
|
842
|
+
clearTimeout(timer)
|
|
843
|
+
signal?.removeEventListener("abort", abort)
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
async function closeMcpConnection(connection: McpConnection) {
|
|
848
|
+
await closeMcpClient(connection.client, connection.transport)
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function stopMcpIdleTimerIfUnused() {
|
|
852
|
+
if (mcpConnections.size || mcpConnectionAttempts.size || !mcpIdleTimer) return
|
|
853
|
+
clearInterval(mcpIdleTimer)
|
|
854
|
+
mcpIdleTimer = undefined
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function ensureMcpIdleTimer() {
|
|
858
|
+
if (mcpIdleTimer) return
|
|
859
|
+
mcpIdleTimer = setInterval(() => closeIdleMcpConnections(), 60_000)
|
|
860
|
+
;(mcpIdleTimer as any).unref?.()
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
async function closeMcpConnectionKey(key: string, expected?: McpConnection) {
|
|
864
|
+
const existing = mcpConnections.get(key)
|
|
865
|
+
if (!existing || (expected && existing !== expected)) return
|
|
866
|
+
mcpConnections.delete(key)
|
|
867
|
+
await closeMcpConnection(existing)
|
|
868
|
+
stopMcpIdleTimerIfUnused()
|
|
869
|
+
}
|
|
758
870
|
|
|
759
871
|
function closeIdleMcpConnections(maxAgeMs = 10 * 60 * 1000) {
|
|
760
872
|
const now = Date.now()
|
|
761
|
-
for (const [key, connection] of mcpConnections) {
|
|
762
|
-
if (now - connection.lastUsed > maxAgeMs) {
|
|
763
|
-
void closeMcpConnectionKey(key)
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
}
|
|
873
|
+
for (const [key, connection] of mcpConnections) {
|
|
874
|
+
if (!connection.active && now - connection.lastUsed > maxAgeMs) {
|
|
875
|
+
void closeMcpConnectionKey(key, connection)
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
async function closeMcpConnectionsForSession(sessionId: string) {
|
|
881
|
+
const prefix = `${sessionId}:`
|
|
882
|
+
const pending: Promise<McpConnection>[] = []
|
|
883
|
+
for (const [key, attempt] of mcpConnectionAttempts) {
|
|
884
|
+
if (!key.startsWith(prefix)) continue
|
|
885
|
+
attempt.controller.abort()
|
|
886
|
+
pending.push(attempt.promise)
|
|
887
|
+
}
|
|
888
|
+
await Promise.allSettled(pending)
|
|
889
|
+
await Promise.all(
|
|
890
|
+
[...mcpConnections.keys()]
|
|
891
|
+
.filter((key) => key.startsWith(prefix))
|
|
892
|
+
.map((key) => closeMcpConnectionKey(key)),
|
|
893
|
+
)
|
|
894
|
+
}
|
|
767
895
|
|
|
768
896
|
function mcpConnectionKey(sessionId: string, serverName: string): string {
|
|
769
897
|
return `${sessionId}:${serverName.toLowerCase()}`
|
|
@@ -774,15 +902,44 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
774
902
|
return entries.find(([name]) => name.toLowerCase() === serverName.toLowerCase())
|
|
775
903
|
}
|
|
776
904
|
|
|
777
|
-
function activeOnDemandMcpEntries(): [string, OnDemandMcpServer][] {
|
|
778
|
-
return Object.entries(config.onDemandMcpServers ?? {}).filter(([, server]) => server.enabled !== false)
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
905
|
+
function activeOnDemandMcpEntries(): [string, OnDemandMcpServer][] {
|
|
906
|
+
return Object.entries(config.onDemandMcpServers ?? {}).filter(([, server]) => server.enabled !== false)
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function waitForMcpConnection(attempt: McpConnectionAttempt, signal?: AbortSignal): Promise<McpConnection> {
|
|
910
|
+
if (!signal) {
|
|
911
|
+
attempt.waiters++
|
|
912
|
+
return attempt.promise.finally(() => attempt.waiters--)
|
|
913
|
+
}
|
|
914
|
+
if (signal.aborted) return Promise.reject(new Error("MCP request cancelled"))
|
|
915
|
+
attempt.waiters++
|
|
916
|
+
return new Promise((resolve, reject) => {
|
|
917
|
+
let settled = false
|
|
918
|
+
const finish = () => {
|
|
919
|
+
if (settled) return false
|
|
920
|
+
settled = true
|
|
921
|
+
attempt.waiters--
|
|
922
|
+
signal.removeEventListener("abort", abort)
|
|
923
|
+
return true
|
|
924
|
+
}
|
|
925
|
+
const abort = () => {
|
|
926
|
+
if (!finish()) return
|
|
927
|
+
if (!attempt.waiters) attempt.controller.abort(signal.reason)
|
|
928
|
+
reject(new Error("MCP request cancelled"))
|
|
929
|
+
}
|
|
930
|
+
signal.addEventListener("abort", abort, { once: true })
|
|
931
|
+
attempt.promise.then(
|
|
932
|
+
(connection) => { if (finish()) resolve(connection) },
|
|
933
|
+
(error) => { if (finish()) reject(error) },
|
|
934
|
+
)
|
|
935
|
+
})
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
async function connectOnDemandMcp(serverName: string, server: OnDemandMcpServer, signal?: AbortSignal): Promise<McpConnection> {
|
|
939
|
+
const timeout = mcpTimeoutMs(server)
|
|
940
|
+
const client = new Client({ name: PACKAGE_NAME, version: PACKAGE_VERSION })
|
|
941
|
+
|
|
942
|
+
if (server.type === "local") {
|
|
786
943
|
const env = { ...cleanProcessEnv(), ...resolveStringRecord(server.environment) }
|
|
787
944
|
const transport = new StdioClientTransport({
|
|
788
945
|
command: resolveEnvPlaceholders(server.command[0]),
|
|
@@ -790,84 +947,114 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
790
947
|
env,
|
|
791
948
|
stderr: "pipe",
|
|
792
949
|
cwd: server.cwd ? resolveEnvPlaceholders(server.cwd) : undefined,
|
|
793
|
-
})
|
|
794
|
-
transport.stderr?.on("data", () => {})
|
|
795
|
-
transport.stderr?.on("error", () => {})
|
|
796
|
-
await
|
|
797
|
-
return { client, transport,
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
const url = new URL(resolveEnvPlaceholders(server.url))
|
|
801
|
-
const headers = resolveStringRecord(server.headers)
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
await
|
|
805
|
-
return { client, transport,
|
|
806
|
-
} catch (err) {
|
|
807
|
-
|
|
808
|
-
const fallbackClient = new Client({ name: PACKAGE_NAME, version: PACKAGE_VERSION })
|
|
809
|
-
const fallback = new SSEClientTransport(url, headers ? { requestInit: { headers } } : undefined)
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
950
|
+
})
|
|
951
|
+
transport.stderr?.on("data", () => {})
|
|
952
|
+
transport.stderr?.on("error", () => {})
|
|
953
|
+
await connectMcpClient(client, transport, timeout, `Connecting to ${serverName}`, signal)
|
|
954
|
+
return { client, transport, lastUsed: Date.now(), active: 0 }
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const url = new URL(resolveEnvPlaceholders(server.url))
|
|
958
|
+
const headers = resolveStringRecord(server.headers)
|
|
959
|
+
const transport = new StreamableHTTPClientTransport(url, headers ? { requestInit: { headers } } : undefined)
|
|
960
|
+
try {
|
|
961
|
+
await connectMcpClient(client, transport, timeout, `Connecting to ${serverName}`, signal)
|
|
962
|
+
return { client, transport, lastUsed: Date.now(), active: 0 }
|
|
963
|
+
} catch (err) {
|
|
964
|
+
if (signal?.aborted) throw err
|
|
965
|
+
const fallbackClient = new Client({ name: PACKAGE_NAME, version: PACKAGE_VERSION })
|
|
966
|
+
const fallback = new SSEClientTransport(url, headers ? { requestInit: { headers } } : undefined)
|
|
967
|
+
try {
|
|
968
|
+
await connectMcpClient(fallbackClient, fallback, timeout, `Connecting to ${serverName} via SSE`, signal)
|
|
969
|
+
return { client: fallbackClient, transport: fallback, lastUsed: Date.now(), active: 0 }
|
|
970
|
+
} catch (fallbackError) {
|
|
971
|
+
throw fallbackError
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
async function getMcpConnection(sessionId: string, serverName: string, signal?: AbortSignal): Promise<McpConnection> {
|
|
977
|
+
if (signal?.aborted) throw new Error("MCP request cancelled")
|
|
978
|
+
closeIdleMcpConnections()
|
|
979
|
+
const match = getOnDemandMcpServer(serverName)
|
|
980
|
+
if (!match) throw new Error(`Unknown on-demand MCP server: ${serverName}`)
|
|
819
981
|
const [canonicalName, server] = match
|
|
820
982
|
const key = mcpConnectionKey(sessionId, canonicalName)
|
|
821
983
|
const existing = mcpConnections.get(key)
|
|
822
984
|
if (existing) {
|
|
823
|
-
existing.lastUsed = Date.now()
|
|
824
|
-
return existing
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
return
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
const
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
985
|
+
existing.lastUsed = Date.now()
|
|
986
|
+
return existing
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const pending = mcpConnectionAttempts.get(key)
|
|
990
|
+
if (pending && !pending.controller.signal.aborted) return waitForMcpConnection(pending, signal)
|
|
991
|
+
if (pending) mcpConnectionAttempts.delete(key)
|
|
992
|
+
|
|
993
|
+
const controller = new AbortController()
|
|
994
|
+
const attempt = { controller, waiters: 0 } as McpConnectionAttempt
|
|
995
|
+
attempt.promise = connectOnDemandMcp(canonicalName, server, controller.signal)
|
|
996
|
+
.then(async (connection) => {
|
|
997
|
+
if (controller.signal.aborted || mcpConnectionAttempts.get(key) !== attempt) {
|
|
998
|
+
await closeMcpConnection(connection)
|
|
999
|
+
throw new Error("MCP connection attempt cancelled")
|
|
1000
|
+
}
|
|
1001
|
+
mcpConnections.set(key, connection)
|
|
1002
|
+
connection.client.onclose = () => {
|
|
1003
|
+
if (mcpConnections.get(key) === connection) mcpConnections.delete(key)
|
|
1004
|
+
stopMcpIdleTimerIfUnused()
|
|
1005
|
+
}
|
|
1006
|
+
ensureMcpIdleTimer()
|
|
1007
|
+
return connection
|
|
1008
|
+
})
|
|
1009
|
+
.finally(() => {
|
|
1010
|
+
if (mcpConnectionAttempts.get(key) === attempt) mcpConnectionAttempts.delete(key)
|
|
1011
|
+
stopMcpIdleTimerIfUnused()
|
|
1012
|
+
})
|
|
1013
|
+
mcpConnectionAttempts.set(key, attempt)
|
|
1014
|
+
ensureMcpIdleTimer()
|
|
1015
|
+
return waitForMcpConnection(attempt, signal)
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
async function listAllMcpTools(client: Client, options?: { signal?: AbortSignal; timeout?: number }): Promise<any[]> {
|
|
1019
|
+
const tools: any[] = []
|
|
1020
|
+
let cursor: string | undefined
|
|
1021
|
+
do {
|
|
1022
|
+
const result: any = await client.listTools(cursor ? { cursor } : undefined as any, options)
|
|
1023
|
+
tools.push(...(result.tools ?? []))
|
|
1024
|
+
cursor = result.nextCursor
|
|
1025
|
+
} while (cursor)
|
|
1026
|
+
return tools
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
async function listAllMcpResources(client: Client, options?: { signal?: AbortSignal; timeout?: number }): Promise<any[]> {
|
|
1030
|
+
const resources: any[] = []
|
|
1031
|
+
let cursor: string | undefined
|
|
1032
|
+
do {
|
|
1033
|
+
const result: any = await client.listResources(cursor ? { cursor } : undefined as any, options)
|
|
1034
|
+
resources.push(...(result.resources ?? []))
|
|
1035
|
+
cursor = result.nextCursor
|
|
1036
|
+
} while (cursor)
|
|
1037
|
+
return resources
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
async function listAllMcpPrompts(client: Client, options?: { signal?: AbortSignal; timeout?: number }): Promise<any[]> {
|
|
1041
|
+
const prompts: any[] = []
|
|
1042
|
+
let cursor: string | undefined
|
|
1043
|
+
do {
|
|
1044
|
+
const result: any = await client.listPrompts(cursor ? { cursor } : undefined as any, options)
|
|
1045
|
+
prompts.push(...(result.prompts ?? []))
|
|
1046
|
+
cursor = result.nextCursor
|
|
860
1047
|
} while (cursor)
|
|
861
1048
|
return prompts
|
|
862
1049
|
}
|
|
863
1050
|
|
|
864
1051
|
function generatedToolsFromMcpTools(tools: any[]): OnDemandMcpToolInfo[] {
|
|
865
1052
|
return tools
|
|
866
|
-
.filter((item) => item?.name)
|
|
867
|
-
.map((item) => ({
|
|
868
|
-
name: String(item.name),
|
|
869
|
-
...(item.description ? { description:
|
|
870
|
-
}))
|
|
1053
|
+
.filter((item) => item?.name)
|
|
1054
|
+
.map((item) => ({
|
|
1055
|
+
name: String(item.name),
|
|
1056
|
+
...(item.description ? { description: compactText(item.description) } : {}),
|
|
1057
|
+
}))
|
|
871
1058
|
}
|
|
872
1059
|
|
|
873
1060
|
function estimateAvoidedMcpSchemaBytes(tools: any[]): number {
|
|
@@ -876,14 +1063,15 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
876
1063
|
}
|
|
877
1064
|
|
|
878
1065
|
function synthesizeMcpDescription(serverName: string, tools: OnDemandMcpToolInfo[]): string {
|
|
879
|
-
if (!tools.length) return `${serverName} exposes on-demand MCP capabilities. Use list_tools for available operations.`
|
|
880
|
-
const descriptions = tools.map((item) => item.description || item.name).filter(Boolean)
|
|
881
|
-
const joined = descriptions.slice(0, 6).join("; ")
|
|
882
|
-
return
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
async function refreshOnDemandMcpDescriptions(force = false, serverName?: string): Promise<{ refreshed: string[]; failed: string[] }> {
|
|
886
|
-
|
|
1066
|
+
if (!tools.length) return `${serverName} exposes on-demand MCP capabilities. Use list_tools for available operations.`
|
|
1067
|
+
const descriptions = tools.map((item) => item.description || item.name).filter(Boolean)
|
|
1068
|
+
const joined = descriptions.slice(0, 6).join("; ")
|
|
1069
|
+
return compactText(`${serverName} provides on-demand MCP tools for ${joined}.`)
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
async function refreshOnDemandMcpDescriptions(force = false, serverName?: string, signal?: AbortSignal): Promise<{ refreshed: string[]; failed: string[] }> {
|
|
1073
|
+
if (signal?.aborted) return { refreshed: [], failed: [] }
|
|
1074
|
+
const entries = activeOnDemandMcpEntries()
|
|
887
1075
|
.filter(([name]) => !serverName || name.toLowerCase() === serverName.toLowerCase())
|
|
888
1076
|
.filter(([name]) => {
|
|
889
1077
|
const metadata = mcpMetadata[name]
|
|
@@ -892,13 +1080,14 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
892
1080
|
const refreshed: string[] = []
|
|
893
1081
|
const failed: string[] = []
|
|
894
1082
|
if (!entries.length) return { refreshed, failed }
|
|
895
|
-
let changed = false
|
|
896
|
-
for (const [name, server] of entries) {
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
1083
|
+
let changed = false
|
|
1084
|
+
for (const [name, server] of entries) {
|
|
1085
|
+
if (signal?.aborted) break
|
|
1086
|
+
let connection: McpConnection | undefined
|
|
1087
|
+
const checkedAt = new Date().toISOString()
|
|
1088
|
+
try {
|
|
1089
|
+
connection = await connectOnDemandMcp(name, server, signal)
|
|
1090
|
+
const rawTools = await listAllMcpTools(connection.client, { signal, timeout: mcpTimeoutMs(server) })
|
|
902
1091
|
const tools = generatedToolsFromMcpTools(rawTools)
|
|
903
1092
|
mcpMetadata[name] = {
|
|
904
1093
|
autoGeneratedTools: tools,
|
|
@@ -907,10 +1096,11 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
907
1096
|
status: "ready",
|
|
908
1097
|
lastCheckedAt: checkedAt,
|
|
909
1098
|
}
|
|
910
|
-
changed = true
|
|
911
|
-
refreshed.push(name)
|
|
912
|
-
} catch (err: any) {
|
|
913
|
-
|
|
1099
|
+
changed = true
|
|
1100
|
+
refreshed.push(name)
|
|
1101
|
+
} catch (err: any) {
|
|
1102
|
+
if (signal?.aborted) break
|
|
1103
|
+
mcpMetadata[name] = {
|
|
914
1104
|
...(mcpMetadata[name] ?? {}),
|
|
915
1105
|
status: "failed",
|
|
916
1106
|
lastError: truncateText(err?.message ?? err, 500),
|
|
@@ -930,15 +1120,38 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
930
1120
|
return { refreshed, failed }
|
|
931
1121
|
}
|
|
932
1122
|
|
|
933
|
-
function
|
|
934
|
-
if (
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
1123
|
+
function waitForRefreshDelay(ms: number, signal: AbortSignal): Promise<void> {
|
|
1124
|
+
if (signal.aborted) return Promise.resolve()
|
|
1125
|
+
return new Promise((resolve) => {
|
|
1126
|
+
const timer = setTimeout(done, ms)
|
|
1127
|
+
function done() {
|
|
1128
|
+
clearTimeout(timer)
|
|
1129
|
+
signal.removeEventListener("abort", done)
|
|
1130
|
+
resolve()
|
|
1131
|
+
}
|
|
1132
|
+
signal.addEventListener("abort", done, { once: true })
|
|
1133
|
+
})
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function queueOnDemandMcpRefresh(force = false, serverName?: string, delay = 0): Promise<{ refreshed: string[]; failed: string[] }> {
|
|
1137
|
+
const controller = new AbortController()
|
|
1138
|
+
mcpRefreshControllers.add(controller)
|
|
1139
|
+
const run = mcpRefreshQueue
|
|
1140
|
+
.catch(() => undefined)
|
|
1141
|
+
.then(async () => {
|
|
1142
|
+
if (delay) await waitForRefreshDelay(delay, controller.signal)
|
|
1143
|
+
return refreshOnDemandMcpDescriptions(force, serverName, controller.signal)
|
|
1144
|
+
})
|
|
1145
|
+
.finally(() => mcpRefreshControllers.delete(controller))
|
|
1146
|
+
mcpRefreshQueue = run
|
|
1147
|
+
return run
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function scheduleOnDemandMcpRefresh(): Promise<{ refreshed: string[]; failed: string[] }> {
|
|
1151
|
+
if (mcpRefreshScheduled) return mcpRefreshPromise!
|
|
1152
|
+
mcpRefreshScheduled = true
|
|
1153
|
+
mcpRefreshPromise = queueOnDemandMcpRefresh(false, undefined, 1000)
|
|
1154
|
+
return mcpRefreshPromise
|
|
942
1155
|
}
|
|
943
1156
|
|
|
944
1157
|
function onDemandMcpRuntimeStatus(name: string): "pending" | "ready" | "failed" {
|
|
@@ -1052,15 +1265,15 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
1052
1265
|
return typeof result === "string" ? result : JSON.stringify(result, null, 2)
|
|
1053
1266
|
}
|
|
1054
1267
|
|
|
1055
|
-
function parseMcpArguments(raw: string | undefined): Record<string, any> {
|
|
1268
|
+
function parseMcpArguments(raw: string | undefined): Record<string, any> {
|
|
1056
1269
|
if (!raw?.trim()) return {}
|
|
1057
1270
|
const parsed = JSON.parse(raw)
|
|
1058
1271
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1059
1272
|
throw new Error("argumentsJson must be a JSON object")
|
|
1060
1273
|
}
|
|
1061
|
-
return parsed
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1274
|
+
return parsed
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1064
1277
|
// ── Context saved by Raven delegation ──
|
|
1065
1278
|
let sessionBytes = 0
|
|
1066
1279
|
let totalBytes = config.stats?.bytes ?? 0
|
|
@@ -1202,10 +1415,13 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
1202
1415
|
} catch { /* update checks are best-effort */ }
|
|
1203
1416
|
}
|
|
1204
1417
|
|
|
1205
|
-
async function notifyIfMcpFailures(names?: string[]) {
|
|
1206
|
-
try {
|
|
1207
|
-
const failed = failedOnDemandMcpNames(names)
|
|
1208
|
-
if (!failed.length)
|
|
1418
|
+
async function notifyIfMcpFailures(names?: string[]) {
|
|
1419
|
+
try {
|
|
1420
|
+
const failed = failedOnDemandMcpNames(names)
|
|
1421
|
+
if (!failed.length) {
|
|
1422
|
+
lastNotifiedMcpFailureKey = ""
|
|
1423
|
+
return
|
|
1424
|
+
}
|
|
1209
1425
|
const key = failed.sort().join("|")
|
|
1210
1426
|
if (key === lastNotifiedMcpFailureKey) return
|
|
1211
1427
|
lastNotifiedMcpFailureKey = key
|
|
@@ -1236,18 +1452,31 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
1236
1452
|
} catch { /* duplicate MCP notifications are best-effort */ }
|
|
1237
1453
|
}
|
|
1238
1454
|
|
|
1239
|
-
return {
|
|
1240
|
-
|
|
1455
|
+
return {
|
|
1456
|
+
async dispose() {
|
|
1457
|
+
for (const controller of mcpRefreshControllers) controller.abort()
|
|
1458
|
+
for (const attempt of mcpConnectionAttempts.values()) attempt.controller.abort()
|
|
1459
|
+
await Promise.allSettled([
|
|
1460
|
+
...[...mcpConnectionAttempts.values()].map((attempt) => attempt.promise),
|
|
1461
|
+
mcpRefreshQueue,
|
|
1462
|
+
])
|
|
1463
|
+
await Promise.all([...mcpConnections.keys()].map((key) => closeMcpConnectionKey(key)))
|
|
1464
|
+
if (mcpIdleTimer) clearInterval(mcpIdleTimer)
|
|
1465
|
+
mcpIdleTimer = undefined
|
|
1466
|
+
for (const sessionId of ravenProgressWaiters.keys()) clearRavenWaiters(sessionId)
|
|
1467
|
+
ravenErrorWaiters.clear()
|
|
1468
|
+
},
|
|
1469
|
+
|
|
1470
|
+
async config(configInput: any) {
|
|
1241
1471
|
globalMcpServers = uniqueStrings(Object.entries(configInput.mcp ?? {})
|
|
1242
1472
|
.filter(([, value]) => !value || typeof value !== "object" || (value as any).enabled !== false)
|
|
1243
1473
|
.map(([name]) => name))
|
|
1244
1474
|
const onDemandNames = new Set(activeOnDemandMcpEntries().map(([name]) => name.toLowerCase()))
|
|
1245
1475
|
duplicateMcpServers = globalMcpServers.filter((name) => onDemandNames.has(name.toLowerCase()))
|
|
1246
1476
|
|
|
1247
|
-
// Inject MCP guidance as a startup instruction file (absolute path for npm compat).
|
|
1248
|
-
// The file is regenerated after MCP health checks, not during the startup pending reset.
|
|
1249
|
-
|
|
1250
|
-
configInput.instructions = configInput.instructions || []
|
|
1477
|
+
// Inject MCP guidance as a startup instruction file (absolute path for npm compat).
|
|
1478
|
+
// The file is regenerated after MCP health checks, not during the startup pending reset.
|
|
1479
|
+
configInput.instructions = configInput.instructions || []
|
|
1251
1480
|
if (!configInput.instructions.includes(MCP_GUIDANCE_MD)) {
|
|
1252
1481
|
configInput.instructions.push(MCP_GUIDANCE_MD)
|
|
1253
1482
|
}
|
|
@@ -1297,12 +1526,13 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
1297
1526
|
const timeout = (config.timeout ?? 600) * 1000
|
|
1298
1527
|
try {
|
|
1299
1528
|
// Create a Raven session
|
|
1300
|
-
const session = await client.session.create({
|
|
1301
|
-
body: {
|
|
1302
|
-
parentID: context.sessionID,
|
|
1303
|
-
title: `raven_seek: ${args.query.slice(0, 80)}`,
|
|
1304
|
-
},
|
|
1305
|
-
|
|
1529
|
+
const session = await client.session.create({
|
|
1530
|
+
body: {
|
|
1531
|
+
parentID: context.sessionID,
|
|
1532
|
+
title: `raven_seek: ${args.query.slice(0, 80)}`,
|
|
1533
|
+
},
|
|
1534
|
+
signal: context.abort,
|
|
1535
|
+
})
|
|
1306
1536
|
|
|
1307
1537
|
const sessionId = (session as any)?.data?.id ?? (session as any)?.id
|
|
1308
1538
|
if (!sessionId) {
|
|
@@ -1311,40 +1541,49 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
1311
1541
|
|
|
1312
1542
|
ravenSessions.add(sessionId)
|
|
1313
1543
|
|
|
1314
|
-
//
|
|
1315
|
-
context.metadata({ metadata: { sessionId } })
|
|
1544
|
+
// Preserve the child session link for TUI clients that render it.
|
|
1545
|
+
context.metadata({ metadata: { sessionId } })
|
|
1316
1546
|
|
|
1317
1547
|
// Log session for debugging
|
|
1318
1548
|
try {
|
|
1319
|
-
const logFile = join(tmpdir(), "raven-sessions.log")
|
|
1320
|
-
const ts = new Date().toISOString()
|
|
1321
|
-
|
|
1322
|
-
appendFileSync(logFile, `${ts} ${sessionId} "${q}"\n`)
|
|
1549
|
+
const logFile = join(tmpdir(), "raven-sessions.log")
|
|
1550
|
+
const ts = new Date().toISOString()
|
|
1551
|
+
writeFileSync(logFile, `${ts} ${sessionId}\n`)
|
|
1323
1552
|
} catch { /* non-fatal */ }
|
|
1324
1553
|
|
|
1325
1554
|
// Send the query to Raven with timeout. Provider/API errors are also
|
|
1326
1555
|
// surfaced through message.updated events; fail as soon as one lands
|
|
1327
|
-
// so the caller does not wait for the full timeout on quota/auth issues.
|
|
1328
|
-
let timer: ReturnType<typeof setTimeout> | undefined
|
|
1329
|
-
|
|
1330
|
-
const
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1556
|
+
// so the caller does not wait for the full timeout on quota/auth issues.
|
|
1557
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
1558
|
+
let promptCompleted = false
|
|
1559
|
+
const promptController = new AbortController()
|
|
1560
|
+
const abortPrompt = () => promptController.abort(context.abort.reason)
|
|
1561
|
+
if (context.abort.aborted) abortPrompt()
|
|
1562
|
+
else context.abort.addEventListener("abort", abortPrompt, { once: true })
|
|
1563
|
+
const providerError = waitForRavenProviderError(sessionId)
|
|
1564
|
+
const firstProgress = waitForRavenFirstProgress(sessionId, Math.min(timeout, 30_000))
|
|
1565
|
+
try {
|
|
1566
|
+
const result = await Promise.race([
|
|
1567
|
+
client.session.prompt({
|
|
1334
1568
|
path: { id: sessionId },
|
|
1335
1569
|
body: {
|
|
1336
|
-
agent: "raven",
|
|
1337
|
-
parts: [{ type: "text", text: args.query }],
|
|
1338
|
-
},
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1570
|
+
agent: "raven",
|
|
1571
|
+
parts: [{ type: "text", text: args.query }],
|
|
1572
|
+
},
|
|
1573
|
+
signal: promptController.signal,
|
|
1574
|
+
}),
|
|
1575
|
+
providerError,
|
|
1576
|
+
firstProgress,
|
|
1577
|
+
new Promise<never>((_, reject) => {
|
|
1578
|
+
timer = setTimeout(() => {
|
|
1579
|
+
promptController.abort()
|
|
1580
|
+
reject(new Error(`Raven timed out after ${timeout / 1000}s — child session retained: ${sessionId}`))
|
|
1581
|
+
}, timeout)
|
|
1582
|
+
}),
|
|
1583
|
+
])
|
|
1584
|
+
promptCompleted = true
|
|
1585
|
+
if (timer) clearTimeout(timer)
|
|
1586
|
+
clearRavenWaiters(sessionId)
|
|
1348
1587
|
|
|
1349
1588
|
const elapsed = ((Date.now() - started) / 1000).toFixed(1)
|
|
1350
1589
|
|
|
@@ -1371,15 +1610,18 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
1371
1610
|
ravenProviderIssue = undefined
|
|
1372
1611
|
|
|
1373
1612
|
return { title: "Raven Seek", metadata: { sessionId }, output: `${output}\n\n*Raven searched for ${elapsed}s — ${formatBytes(savedCandidate)} handled, ${formatTokens(savedCandidate)} tokens*` }
|
|
1374
|
-
} finally {
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1613
|
+
} finally {
|
|
1614
|
+
context.abort.removeEventListener("abort", abortPrompt)
|
|
1615
|
+
if (!promptCompleted) promptController.abort()
|
|
1616
|
+
if (timer) clearTimeout(timer)
|
|
1617
|
+
clearRavenWaiters(sessionId)
|
|
1618
|
+
}
|
|
1378
1619
|
} catch (err: any) {
|
|
1379
1620
|
const elapsed = ((Date.now() - started) / 1000).toFixed(1)
|
|
1380
|
-
const msg = String(err?.message ?? err ?? "").toLowerCase()
|
|
1381
|
-
const hint =
|
|
1382
|
-
|
|
1621
|
+
const msg = String(err?.message ?? err ?? "").toLowerCase()
|
|
1622
|
+
const hint =
|
|
1623
|
+
context.abort.aborted ? "Raven search cancelled."
|
|
1624
|
+
: /rate.?limit|too many requests|429/i.test(msg) ? "Raven rate limited — wait 30s then retry with a narrower query."
|
|
1383
1625
|
: /quota|usage.?limit|usage exceeded|free usage|subscribe to go|subscription|billing|insufficient.*(?:credit|balance|quota)/i.test(msg) ? "Raven API quota/subscription exhausted — proceed without Raven, tell the user Raven could not run, and mention what information may be missing."
|
|
1384
1626
|
: /auth|unauthorized|forbidden|401|403|api key|login/i.test(msg) ? "Raven provider authentication failed — proceed without Raven and tell the user to check the Raven model/provider login or API key."
|
|
1385
1627
|
: /token|context.?length|too large|too long/i.test(msg) ? "Raven query too large — shorten your query and retry."
|
|
@@ -1403,81 +1645,95 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
1403
1645
|
name: tool.schema.string().optional().describe("Tool name for call_tool, resource URI for read_resource, or prompt name for get_prompt."),
|
|
1404
1646
|
argumentsJson: tool.schema.string().optional().describe("JSON object string with arguments for call_tool or get_prompt, e.g. {\"query\":\"latest AI news\"}. Omit for no arguments."),
|
|
1405
1647
|
},
|
|
1406
|
-
async execute(args, context) {
|
|
1407
|
-
if (!ravenSessions.has(context.sessionID) && sessionAgents.get(context.sessionID) !== "raven") {
|
|
1408
|
-
return { title: "Raven MCP", output: "raven_mcp is only available inside Raven. Use raven_seek for MCP requests." }
|
|
1409
|
-
}
|
|
1410
|
-
|
|
1411
|
-
const operation = args.operation as RavenMcpOperation
|
|
1412
|
-
const
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
connection
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1648
|
+
async execute(args, context) {
|
|
1649
|
+
if (!ravenSessions.has(context.sessionID) && sessionAgents.get(context.sessionID) !== "raven") {
|
|
1650
|
+
return { title: "Raven MCP", output: "raven_mcp is only available inside Raven. Use raven_seek for MCP requests." }
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
const operation = args.operation as RavenMcpOperation
|
|
1654
|
+
const server = getOnDemandMcpServer(args.server)?.[1]
|
|
1655
|
+
const timeout = server ? mcpTimeoutMs(server) : (config.timeout ?? 600) * 1000
|
|
1656
|
+
const requestOptions = { signal: context.abort, timeout }
|
|
1657
|
+
let connected = false
|
|
1658
|
+
let connection: McpConnection | undefined
|
|
1659
|
+
try {
|
|
1660
|
+
connection = await getMcpConnection(context.sessionID, args.server, context.abort)
|
|
1661
|
+
connected = true
|
|
1662
|
+
connection.active++
|
|
1663
|
+
connection.lastUsed = Date.now()
|
|
1664
|
+
const client = connection.client
|
|
1665
|
+
|
|
1666
|
+
if (operation === "list_tools") {
|
|
1667
|
+
const tools = await listAllMcpTools(client, requestOptions)
|
|
1668
|
+
const match = getOnDemandMcpServer(args.server)
|
|
1669
|
+
if (match) {
|
|
1670
|
+
const [name] = match
|
|
1426
1671
|
const generatedTools = generatedToolsFromMcpTools(tools)
|
|
1427
1672
|
mcpMetadata[name] = {
|
|
1428
1673
|
autoGeneratedTools: generatedTools,
|
|
1429
1674
|
autoGeneratedDescription: mcpMetadata[name]?.autoGeneratedDescription || synthesizeMcpDescription(name, generatedTools),
|
|
1430
1675
|
avoidedSchemaBytes: estimateAvoidedMcpSchemaBytes(tools),
|
|
1431
1676
|
status: "ready",
|
|
1432
|
-
lastCheckedAt: new Date().toISOString(),
|
|
1433
|
-
}
|
|
1434
|
-
saveMcpMetadata(mcpMetadata)
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
return { title: `Raven MCP: ${args.server}`, output: `
|
|
1474
|
-
}
|
|
1677
|
+
lastCheckedAt: new Date().toISOString(),
|
|
1678
|
+
}
|
|
1679
|
+
saveMcpMetadata(mcpMetadata)
|
|
1680
|
+
saveDynamicGuidance()
|
|
1681
|
+
}
|
|
1682
|
+
return { title: `Raven MCP: ${args.server}`, output: formatMcpToolsForRaven(args.server, tools) }
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
if (operation === "call_tool") {
|
|
1686
|
+
if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "call_tool requires name." }
|
|
1687
|
+
const result = await client.callTool({ name: args.name, arguments: parseMcpArguments(args.argumentsJson) }, undefined, requestOptions)
|
|
1688
|
+
setOnDemandMcpHealth(args.server, "ready")
|
|
1689
|
+
return { title: `Raven MCP: ${args.server}.${args.name}`, output: formatMcpResult(result) }
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
if (operation === "list_resources") {
|
|
1693
|
+
const resources = await listAllMcpResources(client, requestOptions)
|
|
1694
|
+
setOnDemandMcpHealth(args.server, "ready")
|
|
1695
|
+
return { title: `Raven MCP: ${args.server}`, output: formatMcpList(`Resources for ${args.server}`, resources, ["uri", "name", "description"]) }
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
if (operation === "read_resource") {
|
|
1699
|
+
if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "read_resource requires name as the resource URI." }
|
|
1700
|
+
const result = await client.readResource({ uri: args.name }, requestOptions)
|
|
1701
|
+
setOnDemandMcpHealth(args.server, "ready")
|
|
1702
|
+
return { title: `Raven MCP: ${args.server}`, output: formatMcpResult(result) }
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
if (operation === "list_prompts") {
|
|
1706
|
+
const prompts = await listAllMcpPrompts(client, requestOptions)
|
|
1707
|
+
setOnDemandMcpHealth(args.server, "ready")
|
|
1708
|
+
return { title: `Raven MCP: ${args.server}`, output: formatMcpList(`Prompts for ${args.server}`, prompts) }
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
if (operation === "get_prompt") {
|
|
1712
|
+
if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "get_prompt requires name." }
|
|
1713
|
+
const result = await client.getPrompt({ name: args.name, arguments: parseMcpArguments(args.argumentsJson) }, requestOptions)
|
|
1714
|
+
setOnDemandMcpHealth(args.server, "ready")
|
|
1715
|
+
return { title: `Raven MCP: ${args.server}.${args.name}`, output: formatMcpResult(result) }
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
return { title: `Raven MCP: ${args.server}`, output: `Unsupported operation: ${operation}` }
|
|
1719
|
+
} catch (err: any) {
|
|
1720
|
+
if (!connected && !context.abort.aborted) {
|
|
1721
|
+
const name = setOnDemandMcpHealth(args.server, "failed", err?.message ?? err)
|
|
1722
|
+
if (name) await notifyIfMcpFailures([name])
|
|
1723
|
+
}
|
|
1724
|
+
return { title: `Raven MCP: ${args.server}`, output: `MCP call failed: ${err?.message ?? err}` }
|
|
1725
|
+
} finally {
|
|
1726
|
+
if (connection) {
|
|
1727
|
+
connection.active = Math.max(0, connection.active - 1)
|
|
1728
|
+
connection.lastUsed = Date.now()
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1475
1731
|
},
|
|
1476
1732
|
}),
|
|
1477
1733
|
},
|
|
1478
1734
|
|
|
1479
1735
|
// Track agent ↔ session mapping for allowlist + Raven exclusion
|
|
1480
|
-
"chat.message"(input: any, _output: any) {
|
|
1736
|
+
async "chat.message"(input: any, _output: any) {
|
|
1481
1737
|
if (input.agent) {
|
|
1482
1738
|
sessionAgents.set(input.sessionID, input.agent)
|
|
1483
1739
|
if (input.agent === "raven") {
|
|
@@ -1486,12 +1742,25 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
1486
1742
|
}
|
|
1487
1743
|
},
|
|
1488
1744
|
|
|
1489
|
-
event(input: { event: any }) {
|
|
1490
|
-
// Track subagent session → parent mapping for accurate context counting
|
|
1491
|
-
const evt = input.event
|
|
1492
|
-
if (evt?.type === "session.created"
|
|
1493
|
-
|
|
1494
|
-
|
|
1745
|
+
async event(input: { event: any }) {
|
|
1746
|
+
// Track subagent session → parent mapping for accurate context counting
|
|
1747
|
+
const evt = input.event
|
|
1748
|
+
if (evt?.type === "session.created") {
|
|
1749
|
+
const info = evt.properties?.info
|
|
1750
|
+
if (info?.parentID && info?.id) ravenSessionParents.set(info.parentID, info.id)
|
|
1751
|
+
}
|
|
1752
|
+
if (evt?.type === "session.deleted") {
|
|
1753
|
+
const sessionId = evt.properties?.info?.id
|
|
1754
|
+
if (sessionId) {
|
|
1755
|
+
ravenSessions.delete(sessionId)
|
|
1756
|
+
sessionAgents.delete(sessionId)
|
|
1757
|
+
clearRavenWaiters(sessionId)
|
|
1758
|
+
for (const [parentId, childId] of ravenSessionParents) {
|
|
1759
|
+
if (parentId === sessionId || childId === sessionId) ravenSessionParents.delete(parentId)
|
|
1760
|
+
}
|
|
1761
|
+
await closeMcpConnectionsForSession(sessionId)
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1495
1764
|
if (evt?.type === "message.updated") {
|
|
1496
1765
|
const info = evt.properties?.info
|
|
1497
1766
|
if (info?.role === "assistant" && info?.sessionID && info.error && ravenSessions.has(info.sessionID)) {
|
|
@@ -1581,10 +1850,10 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
1581
1850
|
output.parts.push({ type: "text", text: `${onDemandMcpStatus()}\n\nUsage:\n /raven mcp refresh [server_name]\n /raven mcp detail full\n /raven mcp detail minimized` })
|
|
1582
1851
|
} else if (arg.startsWith("mcp ")) {
|
|
1583
1852
|
const parts = raw.split(/\s+/)
|
|
1584
|
-
const action = parts[1]?.toLowerCase()
|
|
1585
|
-
if (action === "refresh") {
|
|
1586
|
-
const serverName = parts.slice(2).join(" ").trim() || undefined
|
|
1587
|
-
const result = await
|
|
1853
|
+
const action = parts[1]?.toLowerCase()
|
|
1854
|
+
if (action === "refresh") {
|
|
1855
|
+
const serverName = parts.slice(2).join(" ").trim() || undefined
|
|
1856
|
+
const result = await queueOnDemandMcpRefresh(true, serverName)
|
|
1588
1857
|
await notifyIfMcpFailures(result.failed)
|
|
1589
1858
|
output.parts.push({
|
|
1590
1859
|
type: "text",
|
|
@@ -1635,10 +1904,10 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
1635
1904
|
saveConfig(config)
|
|
1636
1905
|
output.parts.push({ type: "text", text: `Raven reasoning effort set to: ${effort}\nRestart opencode for the change to take effect.` })
|
|
1637
1906
|
}
|
|
1638
|
-
} else if (arg.startsWith("timeout ")) {
|
|
1639
|
-
const secs =
|
|
1640
|
-
if (!secs || secs < 10) {
|
|
1641
|
-
output.parts.push({ type: "text", text: `Usage: /raven timeout <seconds>\nMust be
|
|
1907
|
+
} else if (arg.startsWith("timeout ")) {
|
|
1908
|
+
const secs = Number(raw.slice(8).trim())
|
|
1909
|
+
if (!Number.isInteger(secs) || secs < 10 || secs > MAX_TIMEOUT_SECONDS) {
|
|
1910
|
+
output.parts.push({ type: "text", text: `Usage: /raven timeout <seconds>\nMust be between 10 and ${MAX_TIMEOUT_SECONDS}. Current: ${config.timeout ?? 600}s` })
|
|
1642
1911
|
} else {
|
|
1643
1912
|
config.timeout = secs
|
|
1644
1913
|
saveConfig(config)
|
|
@@ -1652,16 +1921,18 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
1652
1921
|
const providerIssue = ravenProviderIssue ? `\nProvider issue: ${ravenProviderIssue}` : ""
|
|
1653
1922
|
let update = "Update: unable to check npm."
|
|
1654
1923
|
try {
|
|
1655
|
-
const info = await getUpdateInfo()
|
|
1656
|
-
update = info.available && info.latest
|
|
1657
|
-
? `Update: ${info.latest} available. Run /raven update, then restart opencode.`
|
|
1658
|
-
:
|
|
1924
|
+
const info = await getUpdateInfo()
|
|
1925
|
+
update = info.available && info.latest
|
|
1926
|
+
? `Update: ${info.latest} available. Run /raven update, then restart opencode.`
|
|
1927
|
+
: info.latest
|
|
1928
|
+
? `Update: up to date (latest ${info.latest}).`
|
|
1929
|
+
: "Update: unable to check npm."
|
|
1659
1930
|
} catch { /* keep fallback */ }
|
|
1660
1931
|
output.parts.push({ type: "text", text: `Raven is ${enabled}. Version: ${PACKAGE_VERSION}. Model: ${model}. Reasoning: ${effort}. Timeout: ${timeout}s${providerIssue}\n${update}\n\n${routeSummary()}\n\n${mcpSummary()}\n\n${statsText()}\n\nRun /raven help for commands.` })
|
|
1661
1932
|
}
|
|
1662
1933
|
},
|
|
1663
1934
|
|
|
1664
|
-
"tool.execute.before"(input: any, output: any) {
|
|
1935
|
+
async "tool.execute.before"(input: any, output: any) {
|
|
1665
1936
|
if (input.tool === "raven_seek" && (ravenSessions.has(input.sessionID) || sessionAgents.get(input.sessionID) === "raven")) {
|
|
1666
1937
|
throw new Error("raven_seek is disabled inside Raven. Use Raven's direct search/fetch tools instead.")
|
|
1667
1938
|
}
|
|
@@ -1690,7 +1961,7 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
1690
1961
|
}
|
|
1691
1962
|
},
|
|
1692
1963
|
|
|
1693
|
-
"tool.execute.after"(input: any, output: any) {
|
|
1964
|
+
async "tool.execute.after"(input: any, output: any) {
|
|
1694
1965
|
if (ravenTaskCalls.has(input.callID)) {
|
|
1695
1966
|
ravenTaskCalls.delete(input.callID)
|
|
1696
1967
|
// Try task metadata first (built-in tools preserve metadata)
|