opencode-raven 2.0.1 → 2.1.0

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,423 @@ 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)
595
+ if (!entries.length) return "No on-demand MCPs are configured."
596
+
597
+ const detail = config.onDemandMcpDescriptionDetail ?? "full"
598
+ const lines = entries.map(([name, server]) => {
599
+ const metadata = mcpMetadata[name] ?? {}
600
+ const userDescription = truncateText(server.description, 220)
601
+ const generated = truncateText(metadata.autoGeneratedDescription, 260)
602
+ const parts = [userDescription, generated ? `Discovered capabilities: ${generated}` : ""].filter(Boolean)
603
+ const summary = parts.length ? parts.join(" ") : "Description discovery pending."
604
+ if (detail === "minimized" || !metadata.autoGeneratedTools?.length) return `- ${name}: ${summary}`
605
+ const tools = metadata.autoGeneratedTools
606
+ .map((item) => `${item.name}${item.description ? ` - ${truncateText(item.description, 140)}` : ""}`)
607
+ .join("; ")
608
+ return `- ${name}: ${summary} Tools: ${tools}`
609
+ })
610
+ if (target === "raven") {
611
+ return `Raven can handle these on-demand MCP capabilities through raven_mcp:\n${lines.join("\n")}`
612
+ }
613
+ 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")}`
614
+ }
615
+
616
+ function resolveEnvPlaceholders(value: string): string {
617
+ return value.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => {
618
+ const envValue = process.env[name]
619
+ if (envValue === undefined) throw new Error(`Missing environment variable ${name}`)
620
+ return envValue
621
+ })
622
+ }
623
+
624
+ function resolveStringRecord(value: Record<string, string> | undefined): Record<string, string> | undefined {
625
+ if (!value) return undefined
626
+ const result: Record<string, string> = {}
627
+ for (const [key, raw] of Object.entries(value)) {
628
+ result[key] = resolveEnvPlaceholders(raw)
629
+ }
630
+ return result
631
+ }
632
+
633
+ function cleanProcessEnv(): Record<string, string> {
634
+ const result: Record<string, string> = {}
635
+ for (const [key, value] of Object.entries(process.env)) {
636
+ if (typeof value === "string") result[key] = value
637
+ }
638
+ return result
639
+ }
640
+
641
+ async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
642
+ let timer: ReturnType<typeof setTimeout> | undefined
643
+ try {
644
+ return await Promise.race([
645
+ promise,
646
+ new Promise<never>((_, reject) => {
647
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${Math.round(ms / 1000)}s`)), ms)
648
+ }),
649
+ ])
650
+ } finally {
651
+ if (timer) clearTimeout(timer)
652
+ }
653
+ }
654
+
655
+ type McpConnection = {
656
+ client: Client
657
+ transport: any
658
+ serverName: string
659
+ lastUsed: number
660
+ }
661
+
662
+ const mcpConnections = new Map<string, McpConnection>()
663
+
664
+ async function closeMcpConnection(connection: McpConnection) {
665
+ try {
666
+ if (typeof connection.transport?.terminateSession === "function") {
667
+ await connection.transport.terminateSession()
668
+ }
669
+ } catch { /* best-effort */ }
670
+ try {
671
+ await connection.client.close()
672
+ } catch { /* best-effort */ }
673
+ }
674
+
675
+ async function closeMcpConnectionKey(key: string) {
676
+ const existing = mcpConnections.get(key)
677
+ if (!existing) return
678
+ mcpConnections.delete(key)
679
+ await closeMcpConnection(existing)
680
+ }
681
+
682
+ function closeIdleMcpConnections(maxAgeMs = 10 * 60 * 1000) {
683
+ const now = Date.now()
684
+ for (const [key, connection] of mcpConnections) {
685
+ if (now - connection.lastUsed > maxAgeMs) {
686
+ void closeMcpConnectionKey(key)
687
+ }
688
+ }
689
+ }
690
+
691
+ function mcpConnectionKey(sessionId: string, serverName: string): string {
692
+ return `${sessionId}:${serverName.toLowerCase()}`
693
+ }
694
+
695
+ function getOnDemandMcpServer(serverName: string): [string, OnDemandMcpServer] | undefined {
696
+ const entries = Object.entries(config.onDemandMcpServers ?? {})
697
+ return entries.find(([name]) => name.toLowerCase() === serverName.toLowerCase())
698
+ }
699
+
700
+ async function connectOnDemandMcp(serverName: string, server: OnDemandMcpServer): Promise<McpConnection> {
701
+ const timeout = (config.timeout ?? 180) * 1000
702
+ const client = new Client({ name: PACKAGE_NAME, version: PACKAGE_VERSION })
703
+
704
+ if (server.type === "stdio") {
705
+ const env = { ...cleanProcessEnv(), ...resolveStringRecord(server.env) }
706
+ const transport = new StdioClientTransport({
707
+ command: resolveEnvPlaceholders(server.command),
708
+ args: server.args?.map((arg) => resolveEnvPlaceholders(arg)),
709
+ env,
710
+ cwd: server.cwd ? resolveEnvPlaceholders(server.cwd) : undefined,
711
+ })
712
+ await withTimeout(client.connect(transport), timeout, `Connecting to ${serverName}`)
713
+ return { client, transport, serverName, lastUsed: Date.now() }
714
+ }
715
+
716
+ const url = new URL(resolveEnvPlaceholders(server.url))
717
+ const headers = resolveStringRecord(server.headers)
718
+ try {
719
+ const transport = new StreamableHTTPClientTransport(url, headers ? { requestInit: { headers } } : undefined)
720
+ await withTimeout(client.connect(transport), timeout, `Connecting to ${serverName}`)
721
+ return { client, transport, serverName, lastUsed: Date.now() }
722
+ } catch (err) {
723
+ try { await client.close() } catch { /* best-effort */ }
724
+ const fallbackClient = new Client({ name: PACKAGE_NAME, version: PACKAGE_VERSION })
725
+ const fallback = new SSEClientTransport(url, headers ? { requestInit: { headers } } : undefined)
726
+ await withTimeout(fallbackClient.connect(fallback), timeout, `Connecting to ${serverName} via SSE`)
727
+ return { client: fallbackClient, transport: fallback, serverName, lastUsed: Date.now() }
728
+ }
729
+ }
730
+
731
+ async function getMcpConnection(sessionId: string, serverName: string): Promise<McpConnection> {
732
+ closeIdleMcpConnections()
733
+ const match = getOnDemandMcpServer(serverName)
734
+ if (!match) throw new Error(`Unknown on-demand MCP server: ${serverName}`)
735
+ const [canonicalName, server] = match
736
+ const key = mcpConnectionKey(sessionId, canonicalName)
737
+ const existing = mcpConnections.get(key)
738
+ if (existing) {
739
+ existing.lastUsed = Date.now()
740
+ return existing
741
+ }
742
+ const connection = await connectOnDemandMcp(canonicalName, server)
743
+ mcpConnections.set(key, connection)
744
+ return connection
745
+ }
746
+
747
+ async function listAllMcpTools(client: Client): Promise<any[]> {
748
+ const tools: any[] = []
749
+ let cursor: string | undefined
750
+ do {
751
+ const result: any = await client.listTools(cursor ? { cursor } : undefined as any)
752
+ tools.push(...(result.tools ?? []))
753
+ cursor = result.nextCursor
754
+ } while (cursor)
755
+ return tools
756
+ }
757
+
758
+ async function listAllMcpResources(client: Client): Promise<any[]> {
759
+ const resources: any[] = []
760
+ let cursor: string | undefined
761
+ do {
762
+ const result: any = await client.listResources(cursor ? { cursor } : undefined as any)
763
+ resources.push(...(result.resources ?? []))
764
+ cursor = result.nextCursor
765
+ } while (cursor)
766
+ return resources
767
+ }
768
+
769
+ async function listAllMcpPrompts(client: Client): Promise<any[]> {
770
+ const prompts: any[] = []
771
+ let cursor: string | undefined
772
+ do {
773
+ const result: any = await client.listPrompts(cursor ? { cursor } : undefined as any)
774
+ prompts.push(...(result.prompts ?? []))
775
+ cursor = result.nextCursor
776
+ } while (cursor)
777
+ return prompts
778
+ }
779
+
780
+ function generatedToolsFromMcpTools(tools: any[]): OnDemandMcpToolInfo[] {
781
+ return tools
782
+ .filter((item) => item?.name)
783
+ .map((item) => ({
784
+ name: String(item.name),
785
+ ...(item.description ? { description: truncateText(item.description, 500) } : {}),
786
+ }))
787
+ }
788
+
789
+ function estimateAvoidedMcpSchemaBytes(tools: any[]): number {
790
+ if (!tools.length) return 0
791
+ return JSON.stringify(tools).length
792
+ }
793
+
794
+ function synthesizeMcpDescription(serverName: string, tools: OnDemandMcpToolInfo[]): string {
795
+ if (!tools.length) return `${serverName} exposes on-demand MCP capabilities. Use list_tools for available operations.`
796
+ const descriptions = tools.map((item) => item.description || item.name).filter(Boolean)
797
+ const joined = descriptions.slice(0, 6).join("; ")
798
+ return truncateText(`${serverName} provides on-demand MCP tools for ${joined}.`, 300)
799
+ }
800
+
801
+ async function refreshOnDemandMcpDescriptions(force = false, serverName?: string): Promise<{ refreshed: string[]; failed: string[] }> {
802
+ const entries = Object.entries(config.onDemandMcpServers ?? {})
803
+ .filter(([name]) => !serverName || name.toLowerCase() === serverName.toLowerCase())
804
+ .filter(([name]) => {
805
+ const metadata = mcpMetadata[name]
806
+ return force || metadata?.status === "pending" || metadata?.status === "failed" || !metadata?.autoGeneratedDescription || !metadata.autoGeneratedTools?.length || metadata.avoidedSchemaBytes === undefined
807
+ })
808
+ const refreshed: string[] = []
809
+ const failed: string[] = []
810
+ if (!entries.length) return { refreshed, failed }
811
+ let changed = false
812
+ for (const [name, server] of entries) {
813
+ let connection: McpConnection | undefined
814
+ const checkedAt = new Date().toISOString()
815
+ try {
816
+ connection = await connectOnDemandMcp(name, server)
817
+ const rawTools = await listAllMcpTools(connection.client)
818
+ const tools = generatedToolsFromMcpTools(rawTools)
819
+ mcpMetadata[name] = {
820
+ autoGeneratedTools: tools,
821
+ autoGeneratedDescription: synthesizeMcpDescription(name, tools),
822
+ avoidedSchemaBytes: estimateAvoidedMcpSchemaBytes(rawTools),
823
+ status: "ready",
824
+ lastCheckedAt: checkedAt,
825
+ }
826
+ changed = true
827
+ refreshed.push(name)
828
+ } catch (err: any) {
829
+ mcpMetadata[name] = {
830
+ ...(mcpMetadata[name] ?? {}),
831
+ status: "failed",
832
+ lastError: truncateText(err?.message ?? err, 500),
833
+ lastCheckedAt: checkedAt,
834
+ }
835
+ changed = true
836
+ failed.push(name)
837
+ // Keep startup/discovery best-effort; /raven mcp exposes failures.
838
+ } finally {
839
+ if (connection) await closeMcpConnection(connection)
840
+ }
841
+ }
842
+ if (changed) {
843
+ saveMcpMetadata(mcpMetadata)
844
+ saveDynamicGuidance()
845
+ }
846
+ return { refreshed, failed }
847
+ }
848
+
849
+ function scheduleOnDemandMcpRefresh(): Promise<{ refreshed: string[]; failed: string[] }> {
850
+ if (mcpRefreshScheduled && mcpRefreshPromise) return mcpRefreshPromise
851
+ mcpRefreshScheduled = true
852
+ mcpRefreshPromise = new Promise((resolve) => {
853
+ setTimeout(() => {
854
+ void refreshOnDemandMcpDescriptions().then(resolve)
855
+ }, 1000)
856
+ })
857
+ return mcpRefreshPromise
858
+ }
859
+
860
+ function onDemandMcpRuntimeStatus(name: string): "pending" | "ready" | "failed" {
861
+ const metadata = mcpMetadata[name]
862
+ if (metadata?.status) return metadata.status
863
+ return "pending"
864
+ }
865
+
866
+ function displayMcpStatus(status: "pending" | "ready" | "failed"): "pending" | "loaded" | "failed" {
867
+ return status === "ready" ? "loaded" : status
868
+ }
869
+
870
+ function onDemandMcpStatusBuckets(): { loaded: string[]; failed: string[]; pending: string[] } {
871
+ const buckets = { loaded: [] as string[], failed: [] as string[], pending: [] as string[] }
872
+ for (const name of Object.keys(config.onDemandMcpServers ?? {})) {
873
+ const status = onDemandMcpRuntimeStatus(name)
874
+ if (status === "ready") buckets.loaded.push(name)
875
+ else if (status === "failed") buckets.failed.push(name)
876
+ else buckets.pending.push(name)
877
+ }
878
+ return buckets
879
+ }
880
+
881
+ function failedOnDemandMcpNames(names?: string[]): string[] {
882
+ const configured = Object.keys(config.onDemandMcpServers ?? {})
883
+ const wanted = names?.length ? names : configured
884
+ return wanted.filter((name) => configured.includes(name) && onDemandMcpRuntimeStatus(name) === "failed")
885
+ }
886
+
887
+ function setOnDemandMcpHealth(serverName: string, status: "ready" | "failed", error?: unknown) {
888
+ const match = getOnDemandMcpServer(serverName)
889
+ if (!match) return undefined
890
+ const [name] = match
891
+ const next: OnDemandMcpMetadata = {
892
+ ...(mcpMetadata[name] ?? {}),
893
+ status,
894
+ lastCheckedAt: new Date().toISOString(),
895
+ }
896
+ if (status === "failed") next.lastError = truncateText(error, 500)
897
+ else delete next.lastError
898
+ mcpMetadata[name] = next
899
+ saveMcpMetadata(mcpMetadata)
900
+ return name
901
+ }
902
+
903
+ function onDemandMcpStatus(): string {
904
+ const entries = Object.entries(config.onDemandMcpServers ?? {})
905
+ if (!entries.length) return "Raven on-demand MCPs: none configured."
906
+ const buckets = onDemandMcpStatusBuckets()
907
+ const lines = entries.map(([name, server]) => {
908
+ const metadata = mcpMetadata[name]
909
+ const source = server.description ? "user description" : "no user description"
910
+ const generated = metadata?.autoGeneratedDescription ? "generated" : "missing generated description"
911
+ const tools = metadata?.autoGeneratedTools?.length ? `${metadata.autoGeneratedTools.length} tools` : "tools pending"
912
+ const status = onDemandMcpRuntimeStatus(name)
913
+ const avoided = status === "ready" && metadata?.avoidedSchemaBytes ? `, ${formatBytes(metadata.avoidedSchemaBytes)} avoided schema` : ""
914
+ const error = status === "failed" && metadata?.lastError ? `, error: ${metadata.lastError}` : ""
915
+ return `- ${name} (${server.type}): ${displayMcpStatus(status)}, ${source}, ${generated}, ${tools}${avoided}${error}`
916
+ })
917
+ 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")}`
918
+ }
919
+
920
+ function avoidedMcpSchemaBytes(): number {
921
+ return Object.keys(config.onDemandMcpServers ?? {}).reduce((sum, name) => {
922
+ const metadata = mcpMetadata[name]
923
+ if (onDemandMcpRuntimeStatus(name) !== "ready") return sum
924
+ return sum + Math.max(0, metadata?.avoidedSchemaBytes ?? 0)
925
+ }, 0)
926
+ }
927
+
928
+ function statsText(): string {
929
+ const avoided = avoidedMcpSchemaBytes()
930
+ 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)`
931
+ }
932
+
933
+ function helpText(): string {
934
+ 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`
935
+ }
936
+
937
+ function formatMcpList(title: string, items: any[], fields: string[] = ["name", "description"]): string {
938
+ if (!items.length) return `${title}: none found.`
939
+ const lines = items.map((item) => {
940
+ const primary = fields.map((field) => item?.[field]).find(Boolean) ?? JSON.stringify(item)
941
+ const description = item?.description && primary !== item.description ? ` - ${truncateText(item.description, 300)}` : ""
942
+ return `- ${truncateText(primary, 180)}${description}`
943
+ })
944
+ return `${title}:\n${lines.join("\n")}`
945
+ }
946
+
947
+ function formatMcpToolsForRaven(serverName: string, tools: any[]): string {
948
+ if (!tools.length) return `Tools for ${serverName}: none found.`
949
+ const lines = tools.map((item) => {
950
+ const description = item?.description ? `\n Description: ${truncateText(item.description, 600)}` : ""
951
+ const schema = item?.inputSchema ? `\n Input schema: ${truncateText(JSON.stringify(item.inputSchema), 3000)}` : ""
952
+ return `- ${item?.name ?? "(unnamed)"}${description}${schema}`
953
+ })
954
+ return `Tools for ${serverName}:\n${lines.join("\n")}`
955
+ }
956
+
957
+ function formatMcpResult(result: any): string {
958
+ if (!result) return "No result."
959
+ if (Array.isArray(result.content)) {
960
+ const parts = result.content.map((part: any) => {
961
+ if (part?.type === "text") return part.text
962
+ return JSON.stringify(part)
963
+ })
964
+ return parts.join("\n") || JSON.stringify(result)
965
+ }
966
+ return typeof result === "string" ? result : JSON.stringify(result, null, 2)
967
+ }
968
+
969
+ function parseMcpArguments(raw: string | undefined): Record<string, any> {
970
+ if (!raw?.trim()) return {}
971
+ const parsed = JSON.parse(raw)
972
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
973
+ throw new Error("argumentsJson must be a JSON object")
974
+ }
975
+ return parsed
358
976
  }
359
977
 
360
978
  // ── Context saved by Raven delegation ──
@@ -496,34 +1114,58 @@ export default ((input: PluginInput) => {
496
1114
  } catch { /* update checks are best-effort */ }
497
1115
  }
498
1116
 
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
- }
1117
+ async function notifyIfMcpFailures(names?: string[]) {
1118
+ try {
1119
+ const failed = failedOnDemandMcpNames(names)
1120
+ if (!failed.length) return
1121
+ const key = failed.sort().join("|")
1122
+ if (key === lastNotifiedMcpFailureKey) return
1123
+ lastNotifiedMcpFailureKey = key
1124
+ const label = failed.length === 1 ? failed[0] : `${failed.length} on-demand MCPs`
1125
+ await (client as any).tui?.showToast?.({
1126
+ body: {
1127
+ title: "Raven MCP warning",
1128
+ message: `${label} failed to load. Run /raven mcp for details.`,
1129
+ variant: "warning",
1130
+ duration: 12000,
1131
+ },
1132
+ })
1133
+ } catch { /* MCP failure notifications are best-effort */ }
1134
+ }
1135
+
1136
+ async function notifyIfDuplicateMcps() {
1137
+ try {
1138
+ if (!duplicateMcpServers.length) return
1139
+ const label = duplicateMcpServers.length === 1 ? duplicateMcpServers[0] : `${duplicateMcpServers.length} MCPs`
1140
+ await (client as any).tui?.showToast?.({
1141
+ body: {
1142
+ title: "Raven MCP duplicate config",
1143
+ message: `${label} configured both globally and on-demand. Prefer one location to avoid duplicate schema/connection behavior.`,
1144
+ variant: "warning",
1145
+ duration: 12000,
1146
+ },
1147
+ })
1148
+ } catch { /* duplicate MCP notifications are best-effort */ }
510
1149
  }
511
1150
 
512
1151
  return {
513
1152
  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
- }
1153
+ globalMcpServers = uniqueStrings(Object.entries(configInput.mcp ?? {})
1154
+ .filter(([, value]) => !value || typeof value !== "object" || (value as any).enabled !== false)
1155
+ .map(([name]) => name))
1156
+ const onDemandNames = new Set(Object.keys(config.onDemandMcpServers ?? {}).map((name) => name.toLowerCase()))
1157
+ duplicateMcpServers = globalMcpServers.filter((name) => onDemandNames.has(name.toLowerCase()))
521
1158
 
522
1159
  // Inject MCP guidance as a startup instruction file (absolute path for npm compat)
1160
+ saveDynamicGuidance()
1161
+ cleanupOldRootFiles()
523
1162
  configInput.instructions = configInput.instructions || []
524
1163
  if (!configInput.instructions.includes(MCP_GUIDANCE_MD)) {
525
1164
  configInput.instructions.push(MCP_GUIDANCE_MD)
526
1165
  }
1166
+ if (!configInput.instructions.includes(dynamicGuidanceFile)) {
1167
+ configInput.instructions.push(dynamicGuidanceFile)
1168
+ }
527
1169
 
528
1170
  // Register Raven from Raven.md, with config file overrides
529
1171
  configInput.agent = configInput.agent || {}
@@ -544,18 +1186,21 @@ export default ((input: PluginInput) => {
544
1186
  configInput.command = configInput.command || {}
545
1187
  if (!configInput.command.raven) {
546
1188
  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",
1189
+ template: "Manage Raven: /raven on|off|route|mcp|update|model <name>|status",
1190
+ description: "Toggle Raven routing, manage routed/on-demand MCPs, or change Raven's model",
549
1191
  }
550
1192
  }
551
1193
 
1194
+ scheduleOnDemandMcpRefresh()
552
1195
  updateToastPending = true
1196
+ mcpFailureToastPending = true
1197
+ duplicateMcpToastPending = duplicateMcpServers.length > 0
553
1198
  },
554
1199
 
555
1200
  // Register raven_seek tool — lets agents with task:false still delegate through Raven
556
1201
  tool: {
557
1202
  "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.",
1203
+ 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
1204
  args: {
560
1205
  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
1206
  },
@@ -641,6 +1286,85 @@ export default ((input: PluginInput) => {
641
1286
  }
642
1287
  },
643
1288
  }),
1289
+ "raven_mcp": tool({
1290
+ 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.",
1291
+ args: {
1292
+ server: tool.schema.string().describe("On-demand MCP server name from Raven config, e.g. context7, exa, grep_app, unityMCP."),
1293
+ operation: tool.schema.enum(["list_tools", "call_tool", "list_resources", "read_resource", "list_prompts", "get_prompt"]).describe("MCP operation to perform."),
1294
+ name: tool.schema.string().optional().describe("Tool name for call_tool, resource URI for read_resource, or prompt name for get_prompt."),
1295
+ 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."),
1296
+ },
1297
+ async execute(args, context) {
1298
+ if (!ravenSessions.has(context.sessionID) && sessionAgents.get(context.sessionID) !== "raven") {
1299
+ return { title: "Raven MCP", output: "raven_mcp is only available inside Raven. Use raven_seek for MCP requests." }
1300
+ }
1301
+
1302
+ const operation = args.operation as RavenMcpOperation
1303
+ const timeout = (config.timeout ?? 180) * 1000
1304
+ let connected = false
1305
+ try {
1306
+ const connection = await getMcpConnection(context.sessionID, args.server)
1307
+ connected = true
1308
+ setOnDemandMcpHealth(args.server, "ready")
1309
+ connection.lastUsed = Date.now()
1310
+ const client = connection.client
1311
+
1312
+ if (operation === "list_tools") {
1313
+ const tools = await withTimeout(listAllMcpTools(client), timeout, `Listing ${args.server} tools`)
1314
+ const match = getOnDemandMcpServer(args.server)
1315
+ if (match) {
1316
+ const [name] = match
1317
+ const generatedTools = generatedToolsFromMcpTools(tools)
1318
+ mcpMetadata[name] = {
1319
+ autoGeneratedTools: generatedTools,
1320
+ autoGeneratedDescription: mcpMetadata[name]?.autoGeneratedDescription || synthesizeMcpDescription(name, generatedTools),
1321
+ avoidedSchemaBytes: estimateAvoidedMcpSchemaBytes(tools),
1322
+ status: "ready",
1323
+ lastCheckedAt: new Date().toISOString(),
1324
+ }
1325
+ saveMcpMetadata(mcpMetadata)
1326
+ }
1327
+ return { title: `Raven MCP: ${args.server}`, output: formatMcpToolsForRaven(args.server, tools) }
1328
+ }
1329
+
1330
+ if (operation === "call_tool") {
1331
+ if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "call_tool requires name." }
1332
+ const result = await withTimeout(client.callTool({ name: args.name, arguments: parseMcpArguments(args.argumentsJson) }), timeout, `Calling ${args.server}.${args.name}`)
1333
+ return { title: `Raven MCP: ${args.server}.${args.name}`, output: formatMcpResult(result) }
1334
+ }
1335
+
1336
+ if (operation === "list_resources") {
1337
+ const resources = await withTimeout(listAllMcpResources(client), timeout, `Listing ${args.server} resources`)
1338
+ return { title: `Raven MCP: ${args.server}`, output: formatMcpList(`Resources for ${args.server}`, resources, ["uri", "name", "description"]) }
1339
+ }
1340
+
1341
+ if (operation === "read_resource") {
1342
+ if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "read_resource requires name as the resource URI." }
1343
+ const result = await withTimeout(client.readResource({ uri: args.name }), timeout, `Reading ${args.server} resource`)
1344
+ return { title: `Raven MCP: ${args.server}`, output: formatMcpResult(result) }
1345
+ }
1346
+
1347
+ if (operation === "list_prompts") {
1348
+ const prompts = await withTimeout(listAllMcpPrompts(client), timeout, `Listing ${args.server} prompts`)
1349
+ return { title: `Raven MCP: ${args.server}`, output: formatMcpList(`Prompts for ${args.server}`, prompts) }
1350
+ }
1351
+
1352
+ if (operation === "get_prompt") {
1353
+ if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "get_prompt requires name." }
1354
+ const result = await withTimeout(client.getPrompt({ name: args.name, arguments: parseMcpArguments(args.argumentsJson) }), timeout, `Getting ${args.server} prompt`)
1355
+ return { title: `Raven MCP: ${args.server}.${args.name}`, output: formatMcpResult(result) }
1356
+ }
1357
+
1358
+ return { title: `Raven MCP: ${args.server}`, output: `Unsupported operation: ${operation}` }
1359
+ } catch (err: any) {
1360
+ if (!connected) {
1361
+ const name = setOnDemandMcpHealth(args.server, "failed", err?.message ?? err)
1362
+ if (name) await notifyIfMcpFailures([name])
1363
+ }
1364
+ return { title: `Raven MCP: ${args.server}`, output: `MCP call failed: ${err?.message ?? err}` }
1365
+ }
1366
+ },
1367
+ }),
644
1368
  },
645
1369
 
646
1370
  // Track agent ↔ session mapping for allowlist + Raven exclusion
@@ -660,12 +1384,21 @@ export default ((input: PluginInput) => {
660
1384
  ravenSessionParents.set(evt.properties.parentID, evt.properties.id)
661
1385
  }
662
1386
 
663
- if (!updateToastPending) return
664
- updateToastPending = false
665
- setTimeout(() => void notifyIfUpdateAvailable(), 500)
1387
+ if (updateToastPending) {
1388
+ updateToastPending = false
1389
+ setTimeout(() => void notifyIfUpdateAvailable(), 500)
1390
+ }
1391
+ if (mcpFailureToastPending) {
1392
+ mcpFailureToastPending = false
1393
+ void scheduleOnDemandMcpRefresh().then((result) => notifyIfMcpFailures(result.failed))
1394
+ }
1395
+ if (duplicateMcpToastPending) {
1396
+ duplicateMcpToastPending = false
1397
+ setTimeout(() => void notifyIfDuplicateMcps(), 700)
1398
+ }
666
1399
  },
667
1400
 
668
- // /raven on|off|route|model <name>|effort <value>|timeout <seconds>|stats|status
1401
+ // /raven help|on|off|route|mcp|model <name>|effort <value>|timeout <seconds>|stats|status
669
1402
  async "command.execute.before"(input: any, output: any) {
670
1403
  if (input.command !== "raven") return
671
1404
  output.parts.length = 0
@@ -676,23 +1409,25 @@ export default ((input: PluginInput) => {
676
1409
  config.enabled = true
677
1410
  saveConfig(config)
678
1411
  output.parts.push({ type: "text", text: "Raven tool/MCP routing enabled. Non-Raven agents will be redirected to raven_seek for configured tools." })
1412
+ } else if (arg === "help") {
1413
+ output.parts.push({ type: "text", text: helpText() })
679
1414
  } else if (arg === "off") {
680
1415
  config.enabled = false
681
1416
  saveConfig(config)
682
1417
  output.parts.push({ type: "text", text: "Raven tool/MCP routing disabled. All agents can use tools directly." })
683
1418
  } 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)` })
1419
+ output.parts.push({ type: "text", text: statsText() })
685
1420
  } 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>" })
1421
+ 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.` })
1422
+ } else if (arg.startsWith("route ")) {
1423
+ const parts = raw.split(/\s+/)
1424
+ const kind = parts[1]?.toLowerCase()
1425
+ const action = parts[2]?.toLowerCase()
1426
+ const name = parts.slice(3).join(" ").trim()
1427
+ const key = kind === "tool" ? "routeTools" : kind === "keyword" ? "routeToolKeywords" : undefined
1428
+
1429
+ if (!key || !["add", "remove", "rm"].includes(action) || !name) {
1430
+ 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
1431
  } else {
697
1432
  const values = uniqueStrings(config[key])
698
1433
  const exists = values.some((value) => value.toLowerCase() === name.toLowerCase())
@@ -702,6 +1437,31 @@ export default ((input: PluginInput) => {
702
1437
  saveConfig(config)
703
1438
  output.parts.push({ type: "text", text: routeSummary() })
704
1439
  }
1440
+ } else if (arg === "mcp") {
1441
+ 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` })
1442
+ } else if (arg.startsWith("mcp ")) {
1443
+ const parts = raw.split(/\s+/)
1444
+ const action = parts[1]?.toLowerCase()
1445
+ if (action === "refresh") {
1446
+ const serverName = parts.slice(2).join(" ").trim() || undefined
1447
+ const result = await refreshOnDemandMcpDescriptions(true, serverName)
1448
+ await notifyIfMcpFailures(result.failed)
1449
+ output.parts.push({
1450
+ type: "text",
1451
+ text: `On-demand MCP refresh complete.\n Refreshed: ${result.refreshed.length ? result.refreshed.join(", ") : "none"}\n Failed: ${result.failed.length ? result.failed.join(", ") : "none"}`,
1452
+ })
1453
+ } else if (action === "detail") {
1454
+ const detail = parts[2]?.toLowerCase()
1455
+ if (detail !== "full" && detail !== "minimized") {
1456
+ output.parts.push({ type: "text", text: `Usage: /raven mcp detail full|minimized\nCurrent: ${config.onDemandMcpDescriptionDetail ?? "full"}` })
1457
+ } else {
1458
+ config.onDemandMcpDescriptionDetail = detail
1459
+ saveConfig(config)
1460
+ output.parts.push({ type: "text", text: `On-demand MCP guidance detail set to: ${detail}\nRestart opencode for startup instructions/tool descriptions to fully refresh.` })
1461
+ }
1462
+ } else {
1463
+ 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" })
1464
+ }
705
1465
  } else if (arg === "update") {
706
1466
  try {
707
1467
  const info = await refreshUpdateInfo()
@@ -755,7 +1515,7 @@ export default ((input: PluginInput) => {
755
1515
  ? `Update: ${info.latest} available. Run /raven update, then restart opencode.`
756
1516
  : `Update: up to date${info.latest ? ` (latest ${info.latest})` : ""}.`
757
1517
  } 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` })
1518
+ 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
1519
  }
760
1520
  },
761
1521
 
@@ -764,6 +1524,10 @@ export default ((input: PluginInput) => {
764
1524
  throw new Error("raven_seek is disabled inside Raven. Use Raven's direct search/fetch tools instead.")
765
1525
  }
766
1526
 
1527
+ if (input.tool === "raven_mcp" && !ravenSessions.has(input.sessionID) && sessionAgents.get(input.sessionID) !== "raven") {
1528
+ 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.")
1529
+ }
1530
+
767
1531
  if (!config.enabled) return
768
1532
  if (ravenSessions.has(input.sessionID)) return
769
1533
  if (isExcluded(sessionAgents.get(input.sessionID))) return