Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/opencode/src/altimate/telemetry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,17 @@ export namespace Telemetry {
server_names: string[]
sources: string[]
}
| {
// altimate_change — issue #701: track when user is shown the unresolved
// MCP env-var warning toast (so we can measure whether the toast actually
// helps users self-recover vs. landing in the failed-server flow).
type: "mcp_unresolved_env_vars"
timestamp: number
session_id: string
server_count: number
var_count: number
servers: string[]
}
| {
type: "memory_operation"
timestamp: number
Expand Down
41 changes: 40 additions & 1 deletion packages/opencode/src/mcp/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,20 @@ const log = Log.create({ service: "mcp.discover" })
// that the launch site does NOT need a second resolution pass.
// See PR #666 review — double-interpolation regression fixed by doing this once,
// here, rather than twice.

/**
* Per-server unresolved env-var accumulator. Populated during discovery,
* drained once at MCP startup so the user gets a visible warning toast
* instead of a silently-empty `${VAR}` becoming `""`. See issue #701.
*/
export interface UnresolvedEnvVarRecord {
server: string
source: string
field: "env" | "headers"
vars: string[]
}
let _unresolvedEnvVars: UnresolvedEnvVarRecord[] = []

function resolveServerEnvVars(
obj: Record<string, unknown>,
context: { server: string; source: string; field: "env" | "headers" },
Expand All @@ -27,11 +41,19 @@ function resolveServerEnvVars(
out[key] = ConfigPaths.resolveEnvVarsInString(raw, stats)
}
if (stats.unresolvedNames.length > 0) {
// Dedupe — the same VAR can appear in multiple values within one block.
const dedupedVars = Array.from(new Set(stats.unresolvedNames))
log.warn("unresolved env var references in MCP config — substituting empty string", {
server: context.server,
source: context.source,
field: context.field,
unresolved: stats.unresolvedNames.join(", "),
unresolved: dedupedVars.join(", "),
})
_unresolvedEnvVars.push({
server: context.server,
source: context.source,
field: context.field,
vars: dedupedVars,
})
}
return out
Expand Down Expand Up @@ -229,6 +251,9 @@ export async function discoverExternalMcp(worktree: string): Promise<{
sources: string[]
}> {
log.info("Discovering MCP servers from external AI tool configs...")
// altimate_change — reset per-discovery accumulator so a re-discovery (e.g. config
// reload) does not show stale warnings from a prior run. See issue #701.
_unresolvedEnvVars = []
const result: Record<string, Config.Mcp> = Object.create(null)
const contributingSources: string[] = []
const homedir = os.homedir()
Expand Down Expand Up @@ -283,3 +308,17 @@ export function consumeDiscoveryResult() {
_lastDiscovery = null
return result
}

// altimate_change start — issue #701: surface unresolved env-var warnings to user
/**
* Returns and clears the unresolved env-var records accumulated during the most
* recent `discoverExternalMcp()` call. Drained once by MCP startup so the user
* sees a single warning toast per server instead of a silent empty-string
* substitution that fails downstream with a confusing error.
*/
export function consumeUnresolvedEnvVars(): UnresolvedEnvVarRecord[] {
const result = _unresolvedEnvVars
_unresolvedEnvVars = []
return result
}
// altimate_change end
47 changes: 46 additions & 1 deletion packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,9 +186,12 @@ export namespace MCP {

// altimate_change start — auto-discover MCP servers from external AI tool configs
let discoveryResult: { serverNames: string[]; sources: string[] } | null = null
// altimate_change — issue #701: surface unresolved env-var warnings to user
let unresolvedEnvVars: Awaited<ReturnType<typeof import("./discover").consumeUnresolvedEnvVars>> = []
try {
const { consumeDiscoveryResult } = await import("./discover")
const { consumeDiscoveryResult, consumeUnresolvedEnvVars } = await import("./discover")
discoveryResult = consumeDiscoveryResult()
unresolvedEnvVars = consumeUnresolvedEnvVars()
} catch {
// Discovery module not loaded — skip
}
Expand Down Expand Up @@ -242,6 +245,48 @@ export namespace MCP {
}
// altimate_change end

// altimate_change start — issue #701: surface unresolved env-var warnings to user
// An unresolved `${VAR}` in an MCP server's env/headers block silently becomes "",
// which then fails downstream with a confusing error. Show a warning toast naming
// the server and the missing vars so the user can set/export them and reload.
if (unresolvedEnvVars.length > 0) {
// Group by server so the message stays readable when one config has multiple
// unresolved vars across env + headers (e.g. a Snowflake remote server).
const grouped = new Map<string, { source: string; vars: Set<string> }>()
for (const record of unresolvedEnvVars) {
const key = record.server
const existing = grouped.get(key)
if (existing) {
for (const v of record.vars) existing.vars.add(v)
} else {
grouped.set(key, { source: record.source, vars: new Set(record.vars) })
}
}
const lines = Array.from(grouped.entries()).map(
([server, { source, vars }]) =>
`${server} (${source}): ${Array.from(vars).join(", ")}`,
)
Bus.publish(TuiEvent.ToastShow, {
title: "MCP env vars unresolved",
message:
`Substituted "" for these — server may fail to start. Set the env vars and reload, ` +
`or use \${VAR:-default} for an explicit fallback.\n` +
lines.join("\n"),
variant: "warning",
duration: 12000,
}).catch(() => {})
const totalVars = Array.from(grouped.values()).reduce((sum, g) => sum + g.vars.size, 0)
Telemetry.track({
type: "mcp_unresolved_env_vars",
timestamp: Date.now(),
session_id: Telemetry.getContext().sessionId,
server_count: grouped.size,
var_count: totalVars,
servers: Array.from(grouped.keys()),
})
}
// altimate_change end

return {
status,
clients,
Expand Down
Loading
Loading