opencode-raven 2.0.1 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import type { Plugin, PluginInput } from "@opencode-ai/plugin"
2
2
  import { tool } from "@opencode-ai/plugin"
3
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js"
4
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
5
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
6
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
3
7
  import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync, readdirSync, rmSync } from "node:fs"
4
8
  import { join } from "node:path"
5
9
  import { homedir, tmpdir } from "node:os"
@@ -19,29 +23,43 @@ const DEFAULT_ROUTE_TOOLS = [
19
23
  "glob",
20
24
  "webfetch",
21
25
  "fetch",
26
+ "websearch",
22
27
  "bash",
23
28
  ]
24
29
 
25
- const DEFAULT_ROUTE_MCP_SERVERS = [
26
- "context7",
27
- "exa",
28
- "grep_app",
29
- ]
30
-
31
- const DEFAULT_ROUTE_TOOL_KEYWORDS = [
32
- "search",
33
- "context7",
34
- "exa",
35
- "grep_app",
36
- ]
37
-
38
- const DEFAULT_MCP_SERVERS: Record<string, string> = {
39
- context7: "https://mcp.context7.com/mcp",
40
- exa: "https://mcp.exa.ai/mcp",
41
- grep_app: "https://mcp.grep.app",
30
+ const DEFAULT_ROUTE_TOOL_KEYWORDS: string[] = []
31
+
32
+ const DEFAULT_ON_DEMAND_MCP_SERVERS: Record<string, OnDemandMcpServer> = {
33
+ context7: {
34
+ type: "remote",
35
+ url: "https://mcp.context7.com/mcp",
36
+ description: "Library/framework documentation lookup.",
37
+ },
38
+ exa: {
39
+ type: "remote",
40
+ url: "https://mcp.exa.ai/mcp",
41
+ description: "Live web search and webpage fetching.",
42
+ },
43
+ grep_app: {
44
+ type: "remote",
45
+ url: "https://mcp.grep.app",
46
+ description: "Public GitHub code search and real-world examples.",
47
+ },
42
48
  }
43
49
 
44
- const NEVER_ROUTE_TOOLS = new Set(["raven_seek", "task", "subtask"])
50
+ const DEFAULT_ON_DEMAND_MCP_METADATA: Record<string, OnDemandMcpMetadata> = {
51
+ context7: {
52
+ autoGeneratedDescription: "Finds current library, framework, SDK, package, and API documentation; useful for implementation, configuration, and debugging with version-specific docs.",
53
+ },
54
+ exa: {
55
+ autoGeneratedDescription: "Searches the live web, fetches webpages, researches companies/products, and gathers current information from online sources.",
56
+ },
57
+ grep_app: {
58
+ autoGeneratedDescription: "Searches public GitHub repositories for real-world code examples, usage patterns, configuration snippets, and implementation references.",
59
+ },
60
+ }
61
+
62
+ const NEVER_ROUTE_TOOLS = new Set(["raven_seek", "raven_mcp", "task", "subtask"])
45
63
 
46
64
  // ── Bash commands that look like search workarounds ──
47
65
  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
@@ -114,15 +132,51 @@ function isSearchBash(tool: string, args: any): boolean {
114
132
  }
115
133
 
116
134
  // ── Config file shape ──
135
+ interface OnDemandMcpToolInfo {
136
+ name: string
137
+ description?: string
138
+ }
139
+
140
+ interface BaseOnDemandMcpServer {
141
+ description?: string
142
+ }
143
+
144
+ interface OnDemandMcpMetadata {
145
+ autoGeneratedDescription?: string
146
+ autoGeneratedTools?: OnDemandMcpToolInfo[]
147
+ avoidedSchemaBytes?: number
148
+ status?: "pending" | "ready" | "failed"
149
+ lastError?: string
150
+ lastCheckedAt?: string
151
+ }
152
+
153
+ interface RemoteOnDemandMcpServer extends BaseOnDemandMcpServer {
154
+ type: "remote"
155
+ url: string
156
+ headers?: Record<string, string>
157
+ }
158
+
159
+ interface StdioOnDemandMcpServer extends BaseOnDemandMcpServer {
160
+ type: "stdio"
161
+ command: string
162
+ args?: string[]
163
+ env?: Record<string, string>
164
+ cwd?: string
165
+ }
166
+
167
+ type OnDemandMcpServer = RemoteOnDemandMcpServer | StdioOnDemandMcpServer
168
+ type OnDemandMcpDescriptionDetail = "full" | "minimized"
169
+ type RavenMcpOperation = "list_tools" | "call_tool" | "list_resources" | "read_resource" | "list_prompts" | "get_prompt"
170
+
117
171
  interface RavenConfig {
118
172
  enabled: boolean
119
173
  model?: string
120
174
  reasoning_effort?: string
121
- ravenInstructions?: string
122
- routeTools?: string[]
123
- routeMcpServers?: string[]
124
- routeToolKeywords?: string[]
125
- allowBundledMCPServers?: boolean
175
+ ravenInstructions?: string
176
+ routeTools?: string[]
177
+ routeToolKeywords?: string[]
178
+ onDemandMcpDescriptionDetail?: OnDemandMcpDescriptionDetail
179
+ onDemandMcpServers?: Record<string, OnDemandMcpServer>
126
180
  excludeAgents?: string[]
127
181
  excludeTools?: string[]
128
182
  timeout?: number
@@ -137,11 +191,11 @@ const DEFAULT_CONFIG: RavenConfig = {
137
191
  enabled: true,
138
192
  model: fm.model,
139
193
  reasoning_effort: fm.reasoning_effort,
140
- ravenInstructions: "",
141
- routeTools: DEFAULT_ROUTE_TOOLS,
142
- routeMcpServers: DEFAULT_ROUTE_MCP_SERVERS,
143
- routeToolKeywords: DEFAULT_ROUTE_TOOL_KEYWORDS,
144
- allowBundledMCPServers: true,
194
+ ravenInstructions: "",
195
+ routeTools: DEFAULT_ROUTE_TOOLS,
196
+ routeToolKeywords: DEFAULT_ROUTE_TOOL_KEYWORDS,
197
+ onDemandMcpDescriptionDetail: "full",
198
+ onDemandMcpServers: DEFAULT_ON_DEMAND_MCP_SERVERS,
145
199
  excludeAgents: [],
146
200
  excludeTools: [],
147
201
  timeout: 180,
@@ -230,25 +284,123 @@ function uniqueStrings(value: unknown, fallback: string[] = []): string[] {
230
284
  return [...new Set(source.filter((item) => typeof item === "string" && item.trim()).map((item) => item.trim()))]
231
285
  }
232
286
 
287
+ function cloneOnDemandMcpServers(value: Record<string, OnDemandMcpServer>): Record<string, OnDemandMcpServer> {
288
+ return JSON.parse(JSON.stringify(value))
289
+ }
290
+
291
+ function cloneOnDemandMcpMetadata(value: Record<string, OnDemandMcpMetadata>): Record<string, OnDemandMcpMetadata> {
292
+ return JSON.parse(JSON.stringify(value))
293
+ }
294
+
295
+ function normalizeToolInfo(value: unknown): OnDemandMcpToolInfo[] | undefined {
296
+ if (!Array.isArray(value)) return undefined
297
+ const tools = value
298
+ .filter((item) => item && typeof item === "object" && typeof item.name === "string" && item.name.trim())
299
+ .map((item) => ({
300
+ name: item.name.trim(),
301
+ ...(typeof item.description === "string" && item.description.trim() ? { description: item.description.trim() } : {}),
302
+ }))
303
+ return tools.length ? tools : undefined
304
+ }
305
+
306
+ function normalizeOnDemandMcpServer(value: unknown): OnDemandMcpServer | undefined {
307
+ if (!value || typeof value !== "object") return undefined
308
+ const source = value as Record<string, any>
309
+ const base = {
310
+ ...(typeof source.description === "string" && source.description.trim() ? { description: source.description.trim() } : {}),
311
+ }
312
+
313
+ if (source.type === "stdio") {
314
+ if (typeof source.command !== "string" || !source.command.trim()) return undefined
315
+ return {
316
+ ...base,
317
+ type: "stdio",
318
+ command: source.command.trim(),
319
+ ...(Array.isArray(source.args) ? { args: source.args.filter((arg: unknown) => typeof arg === "string") } : {}),
320
+ ...(source.env && typeof source.env === "object" ? { env: Object.fromEntries(Object.entries(source.env).filter((entry): entry is [string, string] => typeof entry[1] === "string")) } : {}),
321
+ ...(typeof source.cwd === "string" && source.cwd.trim() ? { cwd: source.cwd.trim() } : {}),
322
+ }
323
+ }
324
+
325
+ if (source.type === "remote" || typeof source.url === "string") {
326
+ if (typeof source.url !== "string" || !source.url.trim()) return undefined
327
+ return {
328
+ ...base,
329
+ type: "remote",
330
+ url: source.url.trim(),
331
+ ...(source.headers && typeof source.headers === "object" ? { headers: Object.fromEntries(Object.entries(source.headers).filter((entry): entry is [string, string] => typeof entry[1] === "string")) } : {}),
332
+ }
333
+ }
334
+
335
+ return undefined
336
+ }
337
+
338
+ function normalizeOnDemandMcpMetadata(value: unknown, fallback: Record<string, OnDemandMcpMetadata> = {}): Record<string, OnDemandMcpMetadata> {
339
+ const source = value && typeof value === "object" ? value as Record<string, any> : fallback
340
+ const result: Record<string, OnDemandMcpMetadata> = {}
341
+ for (const [key, raw] of Object.entries(source)) {
342
+ if (!key.trim() || !raw || typeof raw !== "object") continue
343
+ const item = raw as Record<string, unknown>
344
+ const metadata: OnDemandMcpMetadata = {}
345
+ if (typeof item.autoGeneratedDescription === "string" && item.autoGeneratedDescription.trim()) {
346
+ metadata.autoGeneratedDescription = item.autoGeneratedDescription.trim()
347
+ }
348
+ const tools = normalizeToolInfo(item.autoGeneratedTools)
349
+ if (tools) metadata.autoGeneratedTools = tools
350
+ if (typeof item.avoidedSchemaBytes === "number" && Number.isFinite(item.avoidedSchemaBytes) && item.avoidedSchemaBytes >= 0) {
351
+ metadata.avoidedSchemaBytes = Math.round(item.avoidedSchemaBytes)
352
+ }
353
+ if (item.status === "pending" || item.status === "ready" || item.status === "failed") metadata.status = item.status
354
+ if (typeof item.lastError === "string" && item.lastError.trim()) metadata.lastError = item.lastError.trim()
355
+ if (typeof item.lastCheckedAt === "string" && item.lastCheckedAt.trim()) metadata.lastCheckedAt = item.lastCheckedAt.trim()
356
+ if (metadata.autoGeneratedDescription || metadata.autoGeneratedTools?.length || metadata.avoidedSchemaBytes || metadata.status || metadata.lastError || metadata.lastCheckedAt) result[key.trim()] = metadata
357
+ }
358
+ return result
359
+ }
360
+
361
+ function extractOnDemandMcpMetadata(value: unknown): Record<string, OnDemandMcpMetadata> {
362
+ return normalizeOnDemandMcpMetadata(value)
363
+ }
364
+
365
+ function normalizeOnDemandMcpServers(value: unknown, fallback: Record<string, OnDemandMcpServer> = {}): Record<string, OnDemandMcpServer> {
366
+ const source = value && typeof value === "object" ? value as Record<string, unknown> : fallback
367
+ const result: Record<string, OnDemandMcpServer> = {}
368
+ for (const [key, raw] of Object.entries(source)) {
369
+ if (!key.trim()) continue
370
+ const server = normalizeOnDemandMcpServer(raw)
371
+ if (server) result[key.trim()] = server
372
+ }
373
+ return result
374
+ }
375
+
233
376
  // ── Plugin ──
234
377
  export default ((input: PluginInput) => {
235
378
  const client = input.client
236
379
 
237
- // Config file lives in the global opencode config directory
238
- const configFile = join(homedir(), ".config", "opencode", "raven-config.json")
380
+ // Raven state lives under the global opencode config directory.
381
+ const opencodeConfigDir = join(homedir(), ".config", "opencode")
382
+ const ravenConfigDir = join(opencodeConfigDir, "opencode-raven")
383
+ const oldConfigFile = join(opencodeConfigDir, "raven-config.json")
384
+ const configFile = join(ravenConfigDir, "raven-config.json")
385
+ const mcpMetadataFile = join(ravenConfigDir, "autogenerated-on-demand-mcp-metadata.json")
386
+ const dynamicGuidanceFile = join(ravenConfigDir, "autogenerated-on-demand-guidance.md")
387
+ let migratedFromOldConfig = false
388
+ let initialMcpMetadata: Record<string, OnDemandMcpMetadata> = {}
239
389
 
240
390
  function normalizeConfig(raw: any): RavenConfig {
241
391
  const source = raw && typeof raw === "object" ? raw : {}
242
- const normalized: RavenConfig = { ...DEFAULT_CONFIG, ...source }
392
+ const normalized: RavenConfig = { ...DEFAULT_CONFIG }
243
393
 
244
394
  normalized.enabled = source.enabled !== false
245
395
  normalized.model = typeof source.model === "string" ? source.model : DEFAULT_CONFIG.model
246
396
  normalized.reasoning_effort = typeof source.reasoning_effort === "string" ? source.reasoning_effort : DEFAULT_CONFIG.reasoning_effort
247
- normalized.ravenInstructions = typeof source.ravenInstructions === "string" ? source.ravenInstructions : DEFAULT_CONFIG.ravenInstructions
248
- normalized.routeTools = uniqueStrings(source.routeTools, DEFAULT_ROUTE_TOOLS)
249
- normalized.routeMcpServers = uniqueStrings(source.routeMcpServers, DEFAULT_ROUTE_MCP_SERVERS)
250
- normalized.routeToolKeywords = uniqueStrings(source.routeToolKeywords, DEFAULT_ROUTE_TOOL_KEYWORDS)
251
- normalized.allowBundledMCPServers = source.allowBundledMCPServers !== false
397
+ normalized.ravenInstructions = typeof source.ravenInstructions === "string" ? source.ravenInstructions : DEFAULT_CONFIG.ravenInstructions
398
+ normalized.routeTools = uniqueStrings(source.routeTools, DEFAULT_ROUTE_TOOLS)
399
+ normalized.routeToolKeywords = uniqueStrings(source.routeToolKeywords, DEFAULT_ROUTE_TOOL_KEYWORDS)
400
+ normalized.onDemandMcpDescriptionDetail = source.onDemandMcpDescriptionDetail === "minimized" ? "minimized" : "full"
401
+ normalized.onDemandMcpServers = source.onDemandMcpServers === undefined
402
+ ? cloneOnDemandMcpServers(DEFAULT_ON_DEMAND_MCP_SERVERS)
403
+ : normalizeOnDemandMcpServers(source.onDemandMcpServers)
252
404
  normalized.excludeAgents = uniqueStrings(source.excludeAgents)
253
405
  normalized.excludeTools = uniqueStrings(source.excludeTools)
254
406
  normalized.timeout = typeof source.timeout === "number" ? source.timeout : DEFAULT_CONFIG.timeout
@@ -259,12 +411,18 @@ export default ((input: PluginInput) => {
259
411
 
260
412
  function loadConfig(): RavenConfig {
261
413
  try {
262
- if (existsSync(configFile)) {
263
- const raw = JSON.parse(readFileSync(configFile, "utf-8"))
414
+ const sourceFile = existsSync(configFile) ? configFile : existsSync(oldConfigFile) ? oldConfigFile : undefined
415
+ if (sourceFile) {
416
+ const raw = JSON.parse(readFileSync(sourceFile, "utf-8"))
417
+ migratedFromOldConfig = sourceFile === oldConfigFile
418
+ initialMcpMetadata = extractOnDemandMcpMetadata(raw.onDemandMcpServers)
264
419
  const normalized = normalizeConfig(raw)
265
- if (JSON.stringify(raw) !== JSON.stringify(normalized)) {
420
+ if (migratedFromOldConfig || JSON.stringify(raw) !== JSON.stringify(normalized)) {
266
421
  saveConfig(normalized)
267
422
  }
423
+ if (migratedFromOldConfig) {
424
+ try { rmSync(oldConfigFile, { force: true }) } catch { /* best-effort */ }
425
+ }
268
426
  return normalized
269
427
  }
270
428
  } catch { /* ignore corruption, use defaults */ }
@@ -275,19 +433,75 @@ export default ((input: PluginInput) => {
275
433
 
276
434
  function saveConfig(config: RavenConfig) {
277
435
  try {
278
- mkdirSync(join(homedir(), ".config", "opencode"), { recursive: true })
436
+ mkdirSync(ravenConfigDir, { recursive: true })
279
437
  writeFileSync(configFile, JSON.stringify(config, null, 2) + "\n")
280
438
  } catch { /* non-fatal: config won't persist but toggle still works in-session */ }
281
439
  }
282
440
 
441
+ function loadMcpMetadata(seed: Record<string, OnDemandMcpMetadata>): Record<string, OnDemandMcpMetadata> {
442
+ const defaults = cloneOnDemandMcpMetadata(DEFAULT_ON_DEMAND_MCP_METADATA)
443
+ try {
444
+ if (existsSync(mcpMetadataFile)) {
445
+ return { ...defaults, ...normalizeOnDemandMcpMetadata(JSON.parse(readFileSync(mcpMetadataFile, "utf-8"))) }
446
+ }
447
+ } catch { /* ignore corruption, use defaults + seed */ }
448
+ const metadata = { ...defaults, ...seed }
449
+ saveMcpMetadata(metadata)
450
+ return metadata
451
+ }
452
+
453
+ function saveMcpMetadata(metadata: Record<string, OnDemandMcpMetadata>) {
454
+ try {
455
+ mkdirSync(ravenConfigDir, { recursive: true })
456
+ writeFileSync(mcpMetadataFile, JSON.stringify(metadata, null, 2) + "\n")
457
+ } catch { /* non-fatal: generated metadata can be refreshed */ }
458
+ }
459
+
460
+ function saveDynamicGuidance() {
461
+ try {
462
+ mkdirSync(ravenConfigDir, { recursive: true })
463
+ writeFileSync(dynamicGuidanceFile, `## Raven on-demand MCP capabilities\n\n${onDemandMcpGuidance("delegate")}\n`, "utf-8")
464
+ } catch { /* non-fatal: guidance still exists in tool/subagent paths */ }
465
+ }
466
+
467
+ function cleanupOldRootFiles() {
468
+ try {
469
+ if (existsSync(configFile)) rmSync(oldConfigFile, { force: true })
470
+ } catch { /* best-effort migration cleanup */ }
471
+ }
472
+
283
473
  let config = loadConfig()
474
+ let mcpMetadata = loadMcpMetadata(initialMcpMetadata)
284
475
  const ravenSessions = new Set<string>()
285
476
  const ravenTaskCalls = new Set<string>()
286
477
  const sessionAgents = new Map<string, string>()
287
478
  const ravenSessionParents = new Map<string, string>()
479
+ let globalMcpServers: string[] = []
480
+ let duplicateMcpServers: string[] = []
288
481
  let updateInfo: { current: string; latest?: string; available: boolean } | undefined
289
482
  let updateCheckPromise: Promise<{ current: string; latest?: string; available: boolean }> | undefined
290
483
  let updateToastPending = false
484
+ let mcpFailureToastPending = false
485
+ let duplicateMcpToastPending = false
486
+ let mcpRefreshScheduled = false
487
+ let mcpRefreshPromise: Promise<{ refreshed: string[]; failed: string[] }> | undefined
488
+ let lastNotifiedMcpFailureKey = ""
489
+
490
+ resetOnDemandMcpRuntimeStatuses()
491
+
492
+ function resetOnDemandMcpRuntimeStatuses() {
493
+ let changed = false
494
+ for (const name of Object.keys(config.onDemandMcpServers ?? {})) {
495
+ const metadata = mcpMetadata[name] ?? {}
496
+ if (metadata.status !== "pending" || metadata.lastError || metadata.lastCheckedAt) {
497
+ mcpMetadata[name] = { ...metadata, status: "pending" }
498
+ delete mcpMetadata[name].lastError
499
+ delete mcpMetadata[name].lastCheckedAt
500
+ changed = true
501
+ }
502
+ }
503
+ if (changed) saveMcpMetadata(mcpMetadata)
504
+ }
291
505
 
292
506
  // ── Check if an agent is excluded from Raven enforcement (case-insensitive) ──
293
507
  function isExcluded(agent: string | undefined): boolean {
@@ -297,19 +511,19 @@ export default ((input: PluginInput) => {
297
511
  }
298
512
 
299
513
  function ravenGuidance(): string {
300
- const tools = config.routeTools?.length ? config.routeTools.join(", ") : "none"
301
- const mcps = config.routeMcpServers?.length ? config.routeMcpServers.map((server) => `${server}_*`).join(", ") : "none"
302
- const keywords = config.routeToolKeywords?.length ? config.routeToolKeywords.join(", ") : "none"
303
- return `Some tools/MCPs are routed through Raven to save context. Routed tools: ${tools}. Routed MCP prefixes: ${mcps}. Routed tool-name keywords: ${keywords}. If one is blocked, your next tool call should be raven_seek(query="<same request>"). Include the original tool/MCP name and relevant arguments.`
304
- }
514
+ const tools = config.routeTools?.length ? config.routeTools.join(", ") : "none"
515
+ const mcps = globalMcpServers.length ? globalMcpServers.map((server) => `${server}_*`).join(", ") : "none"
516
+ const keywords = config.routeToolKeywords?.length ? config.routeToolKeywords.join(", ") : "none"
517
+ return `Some tools/MCPs are routed through Raven to save context. Routed tools: ${tools}. Auto-routed global MCP prefixes: ${mcps}. Routed tool-name keywords: ${keywords}. ${onDemandMcpGuidance("delegate")} If one is blocked, your next tool call should be raven_seek(query="<same request>"). Include the original tool/MCP name and relevant arguments.`
518
+ }
305
519
 
306
520
  function isRouteConfigured(toolName: string): boolean {
307
521
  const tool = toolName.toLowerCase()
308
- if (NEVER_ROUTE_TOOLS.has(tool)) return false
309
- if (config.routeTools?.some((name) => name.toLowerCase() === tool)) return true
310
- if (config.routeToolKeywords?.some((keyword) => tool.includes(keyword.toLowerCase()))) return true
311
- return config.routeMcpServers?.some((server) => tool.startsWith(`${server.toLowerCase()}_`)) ?? false
312
- }
522
+ if (NEVER_ROUTE_TOOLS.has(tool)) return false
523
+ if (config.routeTools?.some((name) => name.toLowerCase() === tool)) return true
524
+ if (config.routeToolKeywords?.some((keyword) => tool.includes(keyword.toLowerCase()))) return true
525
+ return globalMcpServers.some((server) => tool.startsWith(`${server.toLowerCase()}_`))
526
+ }
313
527
 
314
528
  function compactArgs(value: any): any {
315
529
  if (Array.isArray(value)) {
@@ -342,19 +556,425 @@ export default ((input: PluginInput) => {
342
556
  }
343
557
 
344
558
  function routeSummary(): string {
345
- const tools = config.routeTools?.length ? config.routeTools.join(", ") : "(none)"
346
- const mcps = config.routeMcpServers?.length ? config.routeMcpServers.join(", ") : "(none)"
347
- const keywords = config.routeToolKeywords?.length ? config.routeToolKeywords.join(", ") : "(none)"
348
- return `Raven routed tools/MCPs:\n Tools: ${tools}\n MCP servers: ${mcps}\n Tool keywords: ${keywords}`
349
- }
559
+ const tools = config.routeTools?.length ? config.routeTools.join(", ") : "(none)"
560
+ const mcps = globalMcpServers.length ? `${globalMcpServers.join(", ")} (auto-detected)` : "none detected"
561
+ const keywords = config.routeToolKeywords?.length ? config.routeToolKeywords.join(", ") : "(none)"
562
+ const duplicates = duplicateMcpServers.length ? `\n Duplicate MCP config: ${duplicateMcpServers.join(", ")} (configured globally and on-demand)` : ""
563
+ return `Raven routing:\n Routed tools: ${tools}\n Routed global MCPs: ${mcps}\n Routed tool keywords: ${keywords}${duplicates}`
564
+ }
350
565
 
351
566
  function mcpSummary(): string {
352
- return `Raven bundled MCPs: ${config.allowBundledMCPServers === false ? "disabled" : Object.keys(DEFAULT_MCP_SERVERS).join(", ")}`
567
+ const buckets = onDemandMcpStatusBuckets()
568
+ const total = buckets.loaded.length + buckets.failed.length + buckets.pending.length
569
+ if (!total) return `Raven on-demand MCPs (always behind Raven): none configured.\n Guidance detail: ${config.onDemandMcpDescriptionDetail ?? "full"}`
570
+ const lines = [
571
+ "Raven on-demand MCPs (always behind Raven):",
572
+ ` Loaded: ${buckets.loaded.length ? buckets.loaded.join(", ") : "none"}`,
573
+ ` Failed: ${buckets.failed.length ? buckets.failed.join(", ") : "none"}`,
574
+ ` Pending: ${buckets.pending.length ? buckets.pending.join(", ") : "none"}`,
575
+ ` Guidance detail: ${config.onDemandMcpDescriptionDetail ?? "full"}`,
576
+ ]
577
+ return lines.join("\n")
353
578
  }
354
579
 
355
580
  function ravenAgentPrompt(): string {
356
581
  const extra = config.ravenInstructions?.trim()
357
- return extra ? `${ravenPrompt}\n\nAdditional user instructions:\n${extra}` : ravenPrompt
582
+ const onDemand = onDemandMcpGuidance("raven")
583
+ const prompt = `${ravenPrompt}\n\nOn-demand MCP guidance:\n${onDemand}\nUse direct MCP tools when they are available globally. Use raven_mcp for on-demand MCPs listed above. If you do not know the exact tool name for an on-demand MCP, call raven_mcp with operation list_tools first, then call the selected tool with operation call_tool. For call_tool and get_prompt, pass arguments as a JSON object string in argumentsJson.`
584
+ return extra ? `${prompt}\n\nAdditional user instructions:\n${extra}` : prompt
585
+ }
586
+
587
+ function truncateText(value: unknown, max = 240): string {
588
+ const text = String(value ?? "").replace(/\s+/g, " ").trim()
589
+ return text.length > max ? `${text.slice(0, Math.max(0, max - 3))}...` : text
590
+ }
591
+
592
+ function onDemandMcpGuidance(target: "delegate" | "raven" = "delegate"): string {
593
+ const servers = config.onDemandMcpServers ?? {}
594
+ const entries = Object.entries(servers).filter(([name]) => onDemandMcpRuntimeStatus(name) === "ready")
595
+ if (!Object.keys(servers).length) return "No on-demand MCPs are configured."
596
+ if (!entries.length) return "No loaded on-demand MCPs are available."
597
+
598
+ const detail = config.onDemandMcpDescriptionDetail ?? "full"
599
+ const lines = entries.map(([name, server]) => {
600
+ const metadata = mcpMetadata[name] ?? {}
601
+ const userDescription = truncateText(server.description, 220)
602
+ const generated = truncateText(metadata.autoGeneratedDescription, 260)
603
+ const parts = [userDescription, generated ? `Discovered capabilities: ${generated}` : ""].filter(Boolean)
604
+ const summary = parts.length ? parts.join(" ") : "Description discovery pending."
605
+ if (detail === "minimized" || !metadata.autoGeneratedTools?.length) return `- ${name}: ${summary}`
606
+ const tools = metadata.autoGeneratedTools
607
+ .map((item) => `${item.name}${item.description ? ` - ${truncateText(item.description, 140)}` : ""}`)
608
+ .join("; ")
609
+ return `- ${name}: ${summary} Tools: ${tools}`
610
+ })
611
+ if (target === "raven") {
612
+ return `Raven can handle these on-demand MCP capabilities through raven_mcp:\n${lines.join("\n")}`
613
+ }
614
+ return `Raven can handle these on-demand MCP capabilities. Delegate matching requests with raven_seek; include the target MCP server and likely tool name when obvious, but do not call raven_mcp directly:\n${lines.join("\n")}`
615
+ }
616
+
617
+ function resolveEnvPlaceholders(value: string): string {
618
+ return value.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => {
619
+ const envValue = process.env[name]
620
+ if (envValue === undefined) throw new Error(`Missing environment variable ${name}`)
621
+ return envValue
622
+ })
623
+ }
624
+
625
+ function resolveStringRecord(value: Record<string, string> | undefined): Record<string, string> | undefined {
626
+ if (!value) return undefined
627
+ const result: Record<string, string> = {}
628
+ for (const [key, raw] of Object.entries(value)) {
629
+ result[key] = resolveEnvPlaceholders(raw)
630
+ }
631
+ return result
632
+ }
633
+
634
+ function cleanProcessEnv(): Record<string, string> {
635
+ const result: Record<string, string> = {}
636
+ for (const [key, value] of Object.entries(process.env)) {
637
+ if (typeof value === "string") result[key] = value
638
+ }
639
+ return result
640
+ }
641
+
642
+ async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
643
+ let timer: ReturnType<typeof setTimeout> | undefined
644
+ try {
645
+ return await Promise.race([
646
+ promise,
647
+ new Promise<never>((_, reject) => {
648
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${Math.round(ms / 1000)}s`)), ms)
649
+ }),
650
+ ])
651
+ } finally {
652
+ if (timer) clearTimeout(timer)
653
+ }
654
+ }
655
+
656
+ type McpConnection = {
657
+ client: Client
658
+ transport: any
659
+ serverName: string
660
+ lastUsed: number
661
+ }
662
+
663
+ const mcpConnections = new Map<string, McpConnection>()
664
+
665
+ async function closeMcpConnection(connection: McpConnection) {
666
+ try {
667
+ if (typeof connection.transport?.terminateSession === "function") {
668
+ await connection.transport.terminateSession()
669
+ }
670
+ } catch { /* best-effort */ }
671
+ try {
672
+ await connection.client.close()
673
+ } catch { /* best-effort */ }
674
+ }
675
+
676
+ async function closeMcpConnectionKey(key: string) {
677
+ const existing = mcpConnections.get(key)
678
+ if (!existing) return
679
+ mcpConnections.delete(key)
680
+ await closeMcpConnection(existing)
681
+ }
682
+
683
+ function closeIdleMcpConnections(maxAgeMs = 10 * 60 * 1000) {
684
+ const now = Date.now()
685
+ for (const [key, connection] of mcpConnections) {
686
+ if (now - connection.lastUsed > maxAgeMs) {
687
+ void closeMcpConnectionKey(key)
688
+ }
689
+ }
690
+ }
691
+
692
+ function mcpConnectionKey(sessionId: string, serverName: string): string {
693
+ return `${sessionId}:${serverName.toLowerCase()}`
694
+ }
695
+
696
+ function getOnDemandMcpServer(serverName: string): [string, OnDemandMcpServer] | undefined {
697
+ const entries = Object.entries(config.onDemandMcpServers ?? {})
698
+ return entries.find(([name]) => name.toLowerCase() === serverName.toLowerCase())
699
+ }
700
+
701
+ async function connectOnDemandMcp(serverName: string, server: OnDemandMcpServer): Promise<McpConnection> {
702
+ const timeout = (config.timeout ?? 180) * 1000
703
+ const client = new Client({ name: PACKAGE_NAME, version: PACKAGE_VERSION })
704
+
705
+ if (server.type === "stdio") {
706
+ const env = { ...cleanProcessEnv(), ...resolveStringRecord(server.env) }
707
+ const transport = new StdioClientTransport({
708
+ command: resolveEnvPlaceholders(server.command),
709
+ args: server.args?.map((arg) => resolveEnvPlaceholders(arg)),
710
+ env,
711
+ cwd: server.cwd ? resolveEnvPlaceholders(server.cwd) : undefined,
712
+ })
713
+ await withTimeout(client.connect(transport), timeout, `Connecting to ${serverName}`)
714
+ return { client, transport, serverName, lastUsed: Date.now() }
715
+ }
716
+
717
+ const url = new URL(resolveEnvPlaceholders(server.url))
718
+ const headers = resolveStringRecord(server.headers)
719
+ try {
720
+ const transport = new StreamableHTTPClientTransport(url, headers ? { requestInit: { headers } } : undefined)
721
+ await withTimeout(client.connect(transport), timeout, `Connecting to ${serverName}`)
722
+ return { client, transport, serverName, lastUsed: Date.now() }
723
+ } catch (err) {
724
+ try { await client.close() } catch { /* best-effort */ }
725
+ const fallbackClient = new Client({ name: PACKAGE_NAME, version: PACKAGE_VERSION })
726
+ const fallback = new SSEClientTransport(url, headers ? { requestInit: { headers } } : undefined)
727
+ await withTimeout(fallbackClient.connect(fallback), timeout, `Connecting to ${serverName} via SSE`)
728
+ return { client: fallbackClient, transport: fallback, serverName, lastUsed: Date.now() }
729
+ }
730
+ }
731
+
732
+ async function getMcpConnection(sessionId: string, serverName: string): Promise<McpConnection> {
733
+ closeIdleMcpConnections()
734
+ const match = getOnDemandMcpServer(serverName)
735
+ if (!match) throw new Error(`Unknown on-demand MCP server: ${serverName}`)
736
+ const [canonicalName, server] = match
737
+ const key = mcpConnectionKey(sessionId, canonicalName)
738
+ const existing = mcpConnections.get(key)
739
+ if (existing) {
740
+ existing.lastUsed = Date.now()
741
+ return existing
742
+ }
743
+ const connection = await connectOnDemandMcp(canonicalName, server)
744
+ mcpConnections.set(key, connection)
745
+ return connection
746
+ }
747
+
748
+ async function listAllMcpTools(client: Client): Promise<any[]> {
749
+ const tools: any[] = []
750
+ let cursor: string | undefined
751
+ do {
752
+ const result: any = await client.listTools(cursor ? { cursor } : undefined as any)
753
+ tools.push(...(result.tools ?? []))
754
+ cursor = result.nextCursor
755
+ } while (cursor)
756
+ return tools
757
+ }
758
+
759
+ async function listAllMcpResources(client: Client): Promise<any[]> {
760
+ const resources: any[] = []
761
+ let cursor: string | undefined
762
+ do {
763
+ const result: any = await client.listResources(cursor ? { cursor } : undefined as any)
764
+ resources.push(...(result.resources ?? []))
765
+ cursor = result.nextCursor
766
+ } while (cursor)
767
+ return resources
768
+ }
769
+
770
+ async function listAllMcpPrompts(client: Client): Promise<any[]> {
771
+ const prompts: any[] = []
772
+ let cursor: string | undefined
773
+ do {
774
+ const result: any = await client.listPrompts(cursor ? { cursor } : undefined as any)
775
+ prompts.push(...(result.prompts ?? []))
776
+ cursor = result.nextCursor
777
+ } while (cursor)
778
+ return prompts
779
+ }
780
+
781
+ function generatedToolsFromMcpTools(tools: any[]): OnDemandMcpToolInfo[] {
782
+ return tools
783
+ .filter((item) => item?.name)
784
+ .map((item) => ({
785
+ name: String(item.name),
786
+ ...(item.description ? { description: truncateText(item.description, 500) } : {}),
787
+ }))
788
+ }
789
+
790
+ function estimateAvoidedMcpSchemaBytes(tools: any[]): number {
791
+ if (!tools.length) return 0
792
+ return JSON.stringify(tools).length
793
+ }
794
+
795
+ function synthesizeMcpDescription(serverName: string, tools: OnDemandMcpToolInfo[]): string {
796
+ if (!tools.length) return `${serverName} exposes on-demand MCP capabilities. Use list_tools for available operations.`
797
+ const descriptions = tools.map((item) => item.description || item.name).filter(Boolean)
798
+ const joined = descriptions.slice(0, 6).join("; ")
799
+ return truncateText(`${serverName} provides on-demand MCP tools for ${joined}.`, 300)
800
+ }
801
+
802
+ async function refreshOnDemandMcpDescriptions(force = false, serverName?: string): Promise<{ refreshed: string[]; failed: string[] }> {
803
+ const entries = Object.entries(config.onDemandMcpServers ?? {})
804
+ .filter(([name]) => !serverName || name.toLowerCase() === serverName.toLowerCase())
805
+ .filter(([name]) => {
806
+ const metadata = mcpMetadata[name]
807
+ return force || metadata?.status === "pending" || metadata?.status === "failed" || !metadata?.autoGeneratedDescription || !metadata.autoGeneratedTools?.length || metadata.avoidedSchemaBytes === undefined
808
+ })
809
+ const refreshed: string[] = []
810
+ const failed: string[] = []
811
+ if (!entries.length) return { refreshed, failed }
812
+ let changed = false
813
+ for (const [name, server] of entries) {
814
+ let connection: McpConnection | undefined
815
+ const checkedAt = new Date().toISOString()
816
+ try {
817
+ connection = await connectOnDemandMcp(name, server)
818
+ const rawTools = await listAllMcpTools(connection.client)
819
+ const tools = generatedToolsFromMcpTools(rawTools)
820
+ mcpMetadata[name] = {
821
+ autoGeneratedTools: tools,
822
+ autoGeneratedDescription: synthesizeMcpDescription(name, tools),
823
+ avoidedSchemaBytes: estimateAvoidedMcpSchemaBytes(rawTools),
824
+ status: "ready",
825
+ lastCheckedAt: checkedAt,
826
+ }
827
+ changed = true
828
+ refreshed.push(name)
829
+ } catch (err: any) {
830
+ mcpMetadata[name] = {
831
+ ...(mcpMetadata[name] ?? {}),
832
+ status: "failed",
833
+ lastError: truncateText(err?.message ?? err, 500),
834
+ lastCheckedAt: checkedAt,
835
+ }
836
+ changed = true
837
+ failed.push(name)
838
+ // Keep startup/discovery best-effort; /raven mcp exposes failures.
839
+ } finally {
840
+ if (connection) await closeMcpConnection(connection)
841
+ }
842
+ }
843
+ if (changed) {
844
+ saveMcpMetadata(mcpMetadata)
845
+ saveDynamicGuidance()
846
+ }
847
+ return { refreshed, failed }
848
+ }
849
+
850
+ function scheduleOnDemandMcpRefresh(): Promise<{ refreshed: string[]; failed: string[] }> {
851
+ if (mcpRefreshScheduled && mcpRefreshPromise) return mcpRefreshPromise
852
+ mcpRefreshScheduled = true
853
+ mcpRefreshPromise = new Promise((resolve) => {
854
+ setTimeout(() => {
855
+ void refreshOnDemandMcpDescriptions().then(resolve)
856
+ }, 1000)
857
+ })
858
+ return mcpRefreshPromise
859
+ }
860
+
861
+ function onDemandMcpRuntimeStatus(name: string): "pending" | "ready" | "failed" {
862
+ const metadata = mcpMetadata[name]
863
+ if (metadata?.status) return metadata.status
864
+ return "pending"
865
+ }
866
+
867
+ function displayMcpStatus(status: "pending" | "ready" | "failed"): "pending" | "loaded" | "failed" {
868
+ return status === "ready" ? "loaded" : status
869
+ }
870
+
871
+ function onDemandMcpStatusBuckets(): { loaded: string[]; failed: string[]; pending: string[] } {
872
+ const buckets = { loaded: [] as string[], failed: [] as string[], pending: [] as string[] }
873
+ for (const name of Object.keys(config.onDemandMcpServers ?? {})) {
874
+ const status = onDemandMcpRuntimeStatus(name)
875
+ if (status === "ready") buckets.loaded.push(name)
876
+ else if (status === "failed") buckets.failed.push(name)
877
+ else buckets.pending.push(name)
878
+ }
879
+ return buckets
880
+ }
881
+
882
+ function failedOnDemandMcpNames(names?: string[]): string[] {
883
+ const configured = Object.keys(config.onDemandMcpServers ?? {})
884
+ const wanted = names?.length ? names : configured
885
+ return wanted.filter((name) => configured.includes(name) && onDemandMcpRuntimeStatus(name) === "failed")
886
+ }
887
+
888
+ function setOnDemandMcpHealth(serverName: string, status: "ready" | "failed", error?: unknown) {
889
+ const match = getOnDemandMcpServer(serverName)
890
+ if (!match) return undefined
891
+ const [name] = match
892
+ const next: OnDemandMcpMetadata = {
893
+ ...(mcpMetadata[name] ?? {}),
894
+ status,
895
+ lastCheckedAt: new Date().toISOString(),
896
+ }
897
+ if (status === "failed") next.lastError = truncateText(error, 500)
898
+ else delete next.lastError
899
+ mcpMetadata[name] = next
900
+ saveMcpMetadata(mcpMetadata)
901
+ saveDynamicGuidance()
902
+ return name
903
+ }
904
+
905
+ function onDemandMcpStatus(): string {
906
+ const entries = Object.entries(config.onDemandMcpServers ?? {})
907
+ if (!entries.length) return "Raven on-demand MCPs: none configured."
908
+ const buckets = onDemandMcpStatusBuckets()
909
+ const lines = entries.map(([name, server]) => {
910
+ const metadata = mcpMetadata[name]
911
+ const source = server.description ? "user description" : "no user description"
912
+ const generated = metadata?.autoGeneratedDescription ? "generated" : "missing generated description"
913
+ const tools = metadata?.autoGeneratedTools?.length ? `${metadata.autoGeneratedTools.length} tools` : "tools pending"
914
+ const status = onDemandMcpRuntimeStatus(name)
915
+ const avoided = status === "ready" && metadata?.avoidedSchemaBytes ? `, ${formatBytes(metadata.avoidedSchemaBytes)} avoided schema` : ""
916
+ const error = status === "failed" && metadata?.lastError ? `, error: ${metadata.lastError}` : ""
917
+ return `- ${name} (${server.type}): ${displayMcpStatus(status)}, ${source}, ${generated}, ${tools}${avoided}${error}`
918
+ })
919
+ return `Raven on-demand MCPs (${config.onDemandMcpDescriptionDetail ?? "full"} guidance):\n Loaded: ${buckets.loaded.length ? buckets.loaded.join(", ") : "none"}\n Failed: ${buckets.failed.length ? buckets.failed.join(", ") : "none"}\n Pending: ${buckets.pending.length ? buckets.pending.join(", ") : "none"}\n${lines.join("\n")}`
920
+ }
921
+
922
+ function avoidedMcpSchemaBytes(): number {
923
+ return Object.keys(config.onDemandMcpServers ?? {}).reduce((sum, name) => {
924
+ const metadata = mcpMetadata[name]
925
+ if (onDemandMcpRuntimeStatus(name) !== "ready") return sum
926
+ return sum + Math.max(0, metadata?.avoidedSchemaBytes ?? 0)
927
+ }, 0)
928
+ }
929
+
930
+ function statsText(): string {
931
+ const avoided = avoidedMcpSchemaBytes()
932
+ return `Raven context saved:\n Avoided MCP schema load: ${formatBytes(avoided)} (~${formatTokens(avoided)} context)\n This session: ${formatBytes(sessionBytes)} (~${formatTokens(sessionBytes)} context)\n All time: ${formatBytes(totalBytes)} (~${formatTokens(totalBytes)} context)`
933
+ }
934
+
935
+ function helpText(): string {
936
+ return `Raven commands:\n /raven help — show this help\n /raven on — enable tool/MCP routing\n /raven off — disable tool/MCP routing\n /raven route — show or edit routed tools/keywords\n /raven mcp — show or refresh on-demand MCP metadata\n /raven update — check npm, clear plugin cache if newer, then restart opencode\n /raven model <name> — change Raven's model (requires restart)\n /raven effort <value> — change Raven's reasoning effort (requires restart)\n /raven timeout <seconds> — change raven_seek timeout\n /raven stats — show context saved`
937
+ }
938
+
939
+ function formatMcpList(title: string, items: any[], fields: string[] = ["name", "description"]): string {
940
+ if (!items.length) return `${title}: none found.`
941
+ const lines = items.map((item) => {
942
+ const primary = fields.map((field) => item?.[field]).find(Boolean) ?? JSON.stringify(item)
943
+ const description = item?.description && primary !== item.description ? ` - ${truncateText(item.description, 300)}` : ""
944
+ return `- ${truncateText(primary, 180)}${description}`
945
+ })
946
+ return `${title}:\n${lines.join("\n")}`
947
+ }
948
+
949
+ function formatMcpToolsForRaven(serverName: string, tools: any[]): string {
950
+ if (!tools.length) return `Tools for ${serverName}: none found.`
951
+ const lines = tools.map((item) => {
952
+ const description = item?.description ? `\n Description: ${truncateText(item.description, 600)}` : ""
953
+ const schema = item?.inputSchema ? `\n Input schema: ${truncateText(JSON.stringify(item.inputSchema), 3000)}` : ""
954
+ return `- ${item?.name ?? "(unnamed)"}${description}${schema}`
955
+ })
956
+ return `Tools for ${serverName}:\n${lines.join("\n")}`
957
+ }
958
+
959
+ function formatMcpResult(result: any): string {
960
+ if (!result) return "No result."
961
+ if (Array.isArray(result.content)) {
962
+ const parts = result.content.map((part: any) => {
963
+ if (part?.type === "text") return part.text
964
+ return JSON.stringify(part)
965
+ })
966
+ return parts.join("\n") || JSON.stringify(result)
967
+ }
968
+ return typeof result === "string" ? result : JSON.stringify(result, null, 2)
969
+ }
970
+
971
+ function parseMcpArguments(raw: string | undefined): Record<string, any> {
972
+ if (!raw?.trim()) return {}
973
+ const parsed = JSON.parse(raw)
974
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
975
+ throw new Error("argumentsJson must be a JSON object")
976
+ }
977
+ return parsed
358
978
  }
359
979
 
360
980
  // ── Context saved by Raven delegation ──
@@ -376,7 +996,9 @@ export default ((input: PluginInput) => {
376
996
 
377
997
  function formatTokens(bytes: number): string {
378
998
  const tokens = Math.round(bytes / 4)
379
- return tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}K` : `${tokens}`
999
+ return tokens >= 1_000_000 ? `${(tokens / 1_000_000).toFixed(1)}M`
1000
+ : tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}K`
1001
+ : `${tokens}`
380
1002
  }
381
1003
 
382
1004
  function compareVersions(a: string, b: string): number {
@@ -496,34 +1118,58 @@ export default ((input: PluginInput) => {
496
1118
  } catch { /* update checks are best-effort */ }
497
1119
  }
498
1120
 
499
- function ensureRemoteMcp(configInput: any, key: string, url: string) {
500
- const existing = configInput.mcp?.[key] && typeof configInput.mcp[key] === "object"
501
- ? configInput.mcp[key]
502
- : {}
503
- const type = existing.type ?? "remote"
504
- configInput.mcp[key] = {
505
- ...existing,
506
- type,
507
- ...(type === "local" ? {} : { url: existing.url ?? url }),
508
- enabled: existing.enabled ?? true,
509
- }
1121
+ async function notifyIfMcpFailures(names?: string[]) {
1122
+ try {
1123
+ const failed = failedOnDemandMcpNames(names)
1124
+ if (!failed.length) return
1125
+ const key = failed.sort().join("|")
1126
+ if (key === lastNotifiedMcpFailureKey) return
1127
+ lastNotifiedMcpFailureKey = key
1128
+ const label = failed.length === 1 ? failed[0] : `${failed.length} on-demand MCPs`
1129
+ await (client as any).tui?.showToast?.({
1130
+ body: {
1131
+ title: "Raven MCP warning",
1132
+ message: `${label} failed to load. Run /raven mcp for details.`,
1133
+ variant: "warning",
1134
+ duration: 12000,
1135
+ },
1136
+ })
1137
+ } catch { /* MCP failure notifications are best-effort */ }
1138
+ }
1139
+
1140
+ async function notifyIfDuplicateMcps() {
1141
+ try {
1142
+ if (!duplicateMcpServers.length) return
1143
+ const label = duplicateMcpServers.length === 1 ? duplicateMcpServers[0] : `${duplicateMcpServers.length} MCPs`
1144
+ await (client as any).tui?.showToast?.({
1145
+ body: {
1146
+ title: "Raven MCP duplicate config",
1147
+ message: `${label} configured both globally and on-demand. Prefer one location to avoid duplicate schema/connection behavior.`,
1148
+ variant: "warning",
1149
+ duration: 12000,
1150
+ },
1151
+ })
1152
+ } catch { /* duplicate MCP notifications are best-effort */ }
510
1153
  }
511
1154
 
512
1155
  return {
513
1156
  config(configInput: any) {
514
- // Bundled MCP defaults. Existing opencode.jsonc entries are merged, not overwritten.
515
- if (config.allowBundledMCPServers !== false) {
516
- configInput.mcp = configInput.mcp || {}
517
- for (const [key, url] of Object.entries(DEFAULT_MCP_SERVERS)) {
518
- ensureRemoteMcp(configInput, key, url)
519
- }
520
- }
521
-
522
- // Inject MCP guidance as a startup instruction file (absolute path for npm compat)
1157
+ globalMcpServers = uniqueStrings(Object.entries(configInput.mcp ?? {})
1158
+ .filter(([, value]) => !value || typeof value !== "object" || (value as any).enabled !== false)
1159
+ .map(([name]) => name))
1160
+ const onDemandNames = new Set(Object.keys(config.onDemandMcpServers ?? {}).map((name) => name.toLowerCase()))
1161
+ duplicateMcpServers = globalMcpServers.filter((name) => onDemandNames.has(name.toLowerCase()))
1162
+
1163
+ // Inject MCP guidance as a startup instruction file (absolute path for npm compat).
1164
+ // The file is regenerated after MCP health checks, not during the startup pending reset.
1165
+ cleanupOldRootFiles()
523
1166
  configInput.instructions = configInput.instructions || []
524
1167
  if (!configInput.instructions.includes(MCP_GUIDANCE_MD)) {
525
1168
  configInput.instructions.push(MCP_GUIDANCE_MD)
526
1169
  }
1170
+ if (!configInput.instructions.includes(dynamicGuidanceFile)) {
1171
+ configInput.instructions.push(dynamicGuidanceFile)
1172
+ }
527
1173
 
528
1174
  // Register Raven from Raven.md, with config file overrides
529
1175
  configInput.agent = configInput.agent || {}
@@ -544,18 +1190,21 @@ export default ((input: PluginInput) => {
544
1190
  configInput.command = configInput.command || {}
545
1191
  if (!configInput.command.raven) {
546
1192
  configInput.command.raven = {
547
- template: "Manage Raven: /raven on|off|route|update|model <name>|status",
548
- description: "Toggle Raven routing, manage routed tools/MCPs, or change Raven's model",
1193
+ template: "Manage Raven: /raven on|off|route|mcp|update|model <name>|status",
1194
+ description: "Toggle Raven routing, manage routed/on-demand MCPs, or change Raven's model",
549
1195
  }
550
1196
  }
551
1197
 
1198
+ scheduleOnDemandMcpRefresh()
552
1199
  updateToastPending = true
1200
+ mcpFailureToastPending = true
1201
+ duplicateMcpToastPending = duplicateMcpServers.length > 0
553
1202
  },
554
1203
 
555
1204
  // Register raven_seek tool — lets agents with task:false still delegate through Raven
556
1205
  tool: {
557
1206
  "raven_seek": tool({
558
- description: "Unified Raven delegation tool. Use this whenever a tool/MCP is blocked by Raven, or when grep, glob, WebFetch/fetch, websearch, docs lookup, GitHub search, or search-like bash would be used. Handles routed MCP requests, local codebase search, filesystem discovery, specific URL/page reads, web/docs research, GitHub examples, and command-output/system inspection via Raven.",
1207
+ description: "Unified Raven delegation tool. Use this whenever a tool/MCP is blocked by Raven, or when grep, glob, WebFetch/fetch, websearch, docs lookup, GitHub search, on-demand MCP capabilities, or search-like bash would be used. Handles routed MCP requests, on-demand MCPs, local codebase search, filesystem discovery, specific URL/page reads, web/docs research, GitHub examples, and command-output/system inspection via Raven.",
559
1208
  args: {
560
1209
  query: tool.schema.string().describe("What Raven should do. Include the original blocked tool/MCP request and relevant args, exact URLs when replacing WebFetch, and commands/output checks when replacing grep/rg/head over command output."),
561
1210
  },
@@ -641,6 +1290,85 @@ export default ((input: PluginInput) => {
641
1290
  }
642
1291
  },
643
1292
  }),
1293
+ "raven_mcp": tool({
1294
+ description: "Raven-only bridge for on-demand MCP servers. Non-Raven agents must not call this directly; use raven_seek instead. Raven uses this to list and call tools/resources/prompts from MCPs configured in Raven's onDemandMcpServers without exposing their full schemas to the main model.",
1295
+ args: {
1296
+ server: tool.schema.string().describe("On-demand MCP server name from Raven config, e.g. context7, exa, grep_app, unityMCP."),
1297
+ operation: tool.schema.enum(["list_tools", "call_tool", "list_resources", "read_resource", "list_prompts", "get_prompt"]).describe("MCP operation to perform."),
1298
+ name: tool.schema.string().optional().describe("Tool name for call_tool, resource URI for read_resource, or prompt name for get_prompt."),
1299
+ 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."),
1300
+ },
1301
+ async execute(args, context) {
1302
+ if (!ravenSessions.has(context.sessionID) && sessionAgents.get(context.sessionID) !== "raven") {
1303
+ return { title: "Raven MCP", output: "raven_mcp is only available inside Raven. Use raven_seek for MCP requests." }
1304
+ }
1305
+
1306
+ const operation = args.operation as RavenMcpOperation
1307
+ const timeout = (config.timeout ?? 180) * 1000
1308
+ let connected = false
1309
+ try {
1310
+ const connection = await getMcpConnection(context.sessionID, args.server)
1311
+ connected = true
1312
+ setOnDemandMcpHealth(args.server, "ready")
1313
+ connection.lastUsed = Date.now()
1314
+ const client = connection.client
1315
+
1316
+ if (operation === "list_tools") {
1317
+ const tools = await withTimeout(listAllMcpTools(client), timeout, `Listing ${args.server} tools`)
1318
+ const match = getOnDemandMcpServer(args.server)
1319
+ if (match) {
1320
+ const [name] = match
1321
+ const generatedTools = generatedToolsFromMcpTools(tools)
1322
+ mcpMetadata[name] = {
1323
+ autoGeneratedTools: generatedTools,
1324
+ autoGeneratedDescription: mcpMetadata[name]?.autoGeneratedDescription || synthesizeMcpDescription(name, generatedTools),
1325
+ avoidedSchemaBytes: estimateAvoidedMcpSchemaBytes(tools),
1326
+ status: "ready",
1327
+ lastCheckedAt: new Date().toISOString(),
1328
+ }
1329
+ saveMcpMetadata(mcpMetadata)
1330
+ }
1331
+ return { title: `Raven MCP: ${args.server}`, output: formatMcpToolsForRaven(args.server, tools) }
1332
+ }
1333
+
1334
+ if (operation === "call_tool") {
1335
+ if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "call_tool requires name." }
1336
+ const result = await withTimeout(client.callTool({ name: args.name, arguments: parseMcpArguments(args.argumentsJson) }), timeout, `Calling ${args.server}.${args.name}`)
1337
+ return { title: `Raven MCP: ${args.server}.${args.name}`, output: formatMcpResult(result) }
1338
+ }
1339
+
1340
+ if (operation === "list_resources") {
1341
+ const resources = await withTimeout(listAllMcpResources(client), timeout, `Listing ${args.server} resources`)
1342
+ return { title: `Raven MCP: ${args.server}`, output: formatMcpList(`Resources for ${args.server}`, resources, ["uri", "name", "description"]) }
1343
+ }
1344
+
1345
+ if (operation === "read_resource") {
1346
+ if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "read_resource requires name as the resource URI." }
1347
+ const result = await withTimeout(client.readResource({ uri: args.name }), timeout, `Reading ${args.server} resource`)
1348
+ return { title: `Raven MCP: ${args.server}`, output: formatMcpResult(result) }
1349
+ }
1350
+
1351
+ if (operation === "list_prompts") {
1352
+ const prompts = await withTimeout(listAllMcpPrompts(client), timeout, `Listing ${args.server} prompts`)
1353
+ return { title: `Raven MCP: ${args.server}`, output: formatMcpList(`Prompts for ${args.server}`, prompts) }
1354
+ }
1355
+
1356
+ if (operation === "get_prompt") {
1357
+ if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "get_prompt requires name." }
1358
+ const result = await withTimeout(client.getPrompt({ name: args.name, arguments: parseMcpArguments(args.argumentsJson) }), timeout, `Getting ${args.server} prompt`)
1359
+ return { title: `Raven MCP: ${args.server}.${args.name}`, output: formatMcpResult(result) }
1360
+ }
1361
+
1362
+ return { title: `Raven MCP: ${args.server}`, output: `Unsupported operation: ${operation}` }
1363
+ } catch (err: any) {
1364
+ if (!connected) {
1365
+ const name = setOnDemandMcpHealth(args.server, "failed", err?.message ?? err)
1366
+ if (name) await notifyIfMcpFailures([name])
1367
+ }
1368
+ return { title: `Raven MCP: ${args.server}`, output: `MCP call failed: ${err?.message ?? err}` }
1369
+ }
1370
+ },
1371
+ }),
644
1372
  },
645
1373
 
646
1374
  // Track agent ↔ session mapping for allowlist + Raven exclusion
@@ -660,12 +1388,21 @@ export default ((input: PluginInput) => {
660
1388
  ravenSessionParents.set(evt.properties.parentID, evt.properties.id)
661
1389
  }
662
1390
 
663
- if (!updateToastPending) return
664
- updateToastPending = false
665
- setTimeout(() => void notifyIfUpdateAvailable(), 500)
1391
+ if (updateToastPending) {
1392
+ updateToastPending = false
1393
+ setTimeout(() => void notifyIfUpdateAvailable(), 500)
1394
+ }
1395
+ if (mcpFailureToastPending) {
1396
+ mcpFailureToastPending = false
1397
+ void scheduleOnDemandMcpRefresh().then((result) => notifyIfMcpFailures(result.failed))
1398
+ }
1399
+ if (duplicateMcpToastPending) {
1400
+ duplicateMcpToastPending = false
1401
+ setTimeout(() => void notifyIfDuplicateMcps(), 700)
1402
+ }
666
1403
  },
667
1404
 
668
- // /raven on|off|route|model <name>|effort <value>|timeout <seconds>|stats|status
1405
+ // /raven help|on|off|route|mcp|model <name>|effort <value>|timeout <seconds>|stats|status
669
1406
  async "command.execute.before"(input: any, output: any) {
670
1407
  if (input.command !== "raven") return
671
1408
  output.parts.length = 0
@@ -676,23 +1413,25 @@ export default ((input: PluginInput) => {
676
1413
  config.enabled = true
677
1414
  saveConfig(config)
678
1415
  output.parts.push({ type: "text", text: "Raven tool/MCP routing enabled. Non-Raven agents will be redirected to raven_seek for configured tools." })
1416
+ } else if (arg === "help") {
1417
+ output.parts.push({ type: "text", text: helpText() })
679
1418
  } else if (arg === "off") {
680
1419
  config.enabled = false
681
1420
  saveConfig(config)
682
1421
  output.parts.push({ type: "text", text: "Raven tool/MCP routing disabled. All agents can use tools directly." })
683
1422
  } else if (arg === "stats") {
684
- output.parts.push({ type: "text", text: `Raven context saved:\n This session: ${formatBytes(sessionBytes)} (~${formatTokens(sessionBytes)} context)\n All time: ${formatBytes(totalBytes)} (~${formatTokens(totalBytes)} context)` })
1423
+ output.parts.push({ type: "text", text: statsText() })
685
1424
  } else if (arg === "route") {
686
- output.parts.push({ type: "text", text: `${routeSummary()}\n\nUsage:\n /raven route tool add <tool_name>\n /raven route tool remove <tool_name>\n /raven route mcp add <server_name>\n /raven route mcp remove <server_name>\n /raven route keyword add <keyword>\n /raven route keyword remove <keyword>` })
687
- } else if (arg.startsWith("route ")) {
688
- const parts = raw.split(/\s+/)
689
- const kind = parts[1]?.toLowerCase()
690
- const action = parts[2]?.toLowerCase()
691
- const name = parts.slice(3).join(" ").trim()
692
- const key = kind === "tool" ? "routeTools" : kind === "mcp" || kind === "server" ? "routeMcpServers" : kind === "keyword" ? "routeToolKeywords" : undefined
693
-
694
- if (!key || !["add", "remove", "rm"].includes(action) || !name) {
695
- output.parts.push({ type: "text", text: "Usage:\n /raven route tool add <tool_name>\n /raven route tool remove <tool_name>\n /raven route mcp add <server_name>\n /raven route mcp remove <server_name>\n /raven route keyword add <keyword>\n /raven route keyword remove <keyword>" })
1425
+ output.parts.push({ type: "text", text: `${routeSummary()}\n\nUsage:\n /raven route tool add <tool_name>\n /raven route tool remove <tool_name>\n /raven route keyword add <keyword>\n /raven route keyword remove <keyword>\n\nGlobal MCPs are auto-routed by detected OpenCode MCP server name.` })
1426
+ } else if (arg.startsWith("route ")) {
1427
+ const parts = raw.split(/\s+/)
1428
+ const kind = parts[1]?.toLowerCase()
1429
+ const action = parts[2]?.toLowerCase()
1430
+ const name = parts.slice(3).join(" ").trim()
1431
+ const key = kind === "tool" ? "routeTools" : kind === "keyword" ? "routeToolKeywords" : undefined
1432
+
1433
+ if (!key || !["add", "remove", "rm"].includes(action) || !name) {
1434
+ output.parts.push({ type: "text", text: "Usage:\n /raven route tool add <tool_name>\n /raven route tool remove <tool_name>\n /raven route keyword add <keyword>\n /raven route keyword remove <keyword>\n\nGlobal MCPs are auto-routed by detected OpenCode MCP server name." })
696
1435
  } else {
697
1436
  const values = uniqueStrings(config[key])
698
1437
  const exists = values.some((value) => value.toLowerCase() === name.toLowerCase())
@@ -702,6 +1441,31 @@ export default ((input: PluginInput) => {
702
1441
  saveConfig(config)
703
1442
  output.parts.push({ type: "text", text: routeSummary() })
704
1443
  }
1444
+ } else if (arg === "mcp") {
1445
+ 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` })
1446
+ } else if (arg.startsWith("mcp ")) {
1447
+ const parts = raw.split(/\s+/)
1448
+ const action = parts[1]?.toLowerCase()
1449
+ if (action === "refresh") {
1450
+ const serverName = parts.slice(2).join(" ").trim() || undefined
1451
+ const result = await refreshOnDemandMcpDescriptions(true, serverName)
1452
+ await notifyIfMcpFailures(result.failed)
1453
+ output.parts.push({
1454
+ type: "text",
1455
+ text: `On-demand MCP refresh complete.\n Refreshed: ${result.refreshed.length ? result.refreshed.join(", ") : "none"}\n Failed: ${result.failed.length ? result.failed.join(", ") : "none"}`,
1456
+ })
1457
+ } else if (action === "detail") {
1458
+ const detail = parts[2]?.toLowerCase()
1459
+ if (detail !== "full" && detail !== "minimized") {
1460
+ output.parts.push({ type: "text", text: `Usage: /raven mcp detail full|minimized\nCurrent: ${config.onDemandMcpDescriptionDetail ?? "full"}` })
1461
+ } else {
1462
+ config.onDemandMcpDescriptionDetail = detail
1463
+ saveConfig(config)
1464
+ output.parts.push({ type: "text", text: `On-demand MCP guidance detail set to: ${detail}\nRestart opencode for startup instructions/tool descriptions to fully refresh.` })
1465
+ }
1466
+ } else {
1467
+ output.parts.push({ type: "text", text: "Usage:\n /raven mcp\n /raven mcp refresh [server_name]\n /raven mcp detail full\n /raven mcp detail minimized" })
1468
+ }
705
1469
  } else if (arg === "update") {
706
1470
  try {
707
1471
  const info = await refreshUpdateInfo()
@@ -755,7 +1519,7 @@ export default ((input: PluginInput) => {
755
1519
  ? `Update: ${info.latest} available. Run /raven update, then restart opencode.`
756
1520
  : `Update: up to date${info.latest ? ` (latest ${info.latest})` : ""}.`
757
1521
  } catch { /* keep fallback */ }
758
- output.parts.push({ type: "text", text: `Raven is ${enabled}. Version: ${PACKAGE_VERSION}. Model: ${model}. Reasoning: ${effort}. Timeout: ${timeout}s\n${update}\n\n${routeSummary()}\n\n${mcpSummary()}\n\nRaven context saved:\n This session: ${formatBytes(sessionBytes)} (~${formatTokens(sessionBytes)} context)\n All time: ${formatBytes(totalBytes)} (~${formatTokens(totalBytes)} context)\n\nCommands:\n /raven on — enable tool/MCP routing\n /raven off — disable tool/MCP routing\n /raven route — show or edit routed tools/MCP servers/keywords\n /raven update — check npm, clear plugin cache if newer, then restart opencode\n /raven model <name> — change Raven's model (requires restart)\n /raven effort <value> — change Raven's reasoning effort (requires restart)\n /raven timeout <seconds> — change raven_seek timeout\n /raven stats — show context saved` })
1522
+ output.parts.push({ type: "text", text: `Raven is ${enabled}. Version: ${PACKAGE_VERSION}. Model: ${model}. Reasoning: ${effort}. Timeout: ${timeout}s\n${update}\n\n${routeSummary()}\n\n${mcpSummary()}\n\n${statsText()}\n\nRun /raven help for commands.` })
759
1523
  }
760
1524
  },
761
1525
 
@@ -764,6 +1528,10 @@ export default ((input: PluginInput) => {
764
1528
  throw new Error("raven_seek is disabled inside Raven. Use Raven's direct search/fetch tools instead.")
765
1529
  }
766
1530
 
1531
+ if (input.tool === "raven_mcp" && !ravenSessions.has(input.sessionID) && sessionAgents.get(input.sessionID) !== "raven") {
1532
+ throw new Error("raven_mcp is only available inside Raven. Use raven_seek(query=\"Use the relevant on-demand MCP to handle this request\") instead.")
1533
+ }
1534
+
767
1535
  if (!config.enabled) return
768
1536
  if (ravenSessions.has(input.sessionID)) return
769
1537
  if (isExcluded(sessionAgents.get(input.sessionID))) return