-
Notifications
You must be signed in to change notification settings - Fork 134
fix: surface the real reason an MCP server is unavailable #1159
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
60468f7
81625e5
0e4c309
23db199
fc19989
1e0a928
91c3aff
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -159,6 +159,10 @@ async function substituteWellKnownRemoteConfig(input: { | |
| }) { | ||
| if (!isRecord(input.value) || typeof input.value.url !== "string") return undefined | ||
|
|
||
| // altimate_change start — upstream_fix (#701): the url and every header below publish under | ||
| // this same source, so clear once here and let those calls union into one record. | ||
| ConfigVariable.resetBlankedEnvVars(input.source) | ||
| // altimate_change end | ||
| const url = await ConfigVariable.substitute({ | ||
| text: input.value.url, | ||
| type: "virtual", | ||
|
|
@@ -341,6 +345,14 @@ export const layer = Layer.effect( | |
|
|
||
| const loadFile = Effect.fnUntraced(function* (filepath: string, env?: Record<string, string>) { | ||
| yield* Effect.logInfo("loading", { path: filepath }) | ||
| // altimate_change start — upstream_fix (#701): substitution unions now, so whoever begins a | ||
| // load clears this source first. Before the empty-file return, not after: a config that is | ||
| // deleted or emptied must drop the names it recorded while it still had a `{env:VAR}`, | ||
| // otherwise `mcp list` warns about a variable that appears in no config at all. | ||
| // Deliberately NOT inside loadConfig — the well-known flow records url/header blanks under | ||
| // the same source before calling it, and a reset in there threw those names away. | ||
| ConfigVariable.resetBlankedEnvVars(filepath) | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
coderabbitai[bot] marked this conversation as resolved.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WARNING:
Reply with |
||
| // altimate_change end | ||
| const text = yield* readConfigFile(filepath) | ||
| if (!text) return {} as Info | ||
| return yield* loadConfig(text, { path: filepath }, env) | ||
|
|
@@ -598,6 +610,9 @@ export const layer = Layer.effect( | |
|
|
||
| if (process.env.OPENCODE_CONFIG_CONTENT) { | ||
| const source = "OPENCODE_CONFIG_CONTENT" | ||
| // altimate_change start — upstream_fix (#701): clear before this load. | ||
| ConfigVariable.resetBlankedEnvVars(source) | ||
| // altimate_change end | ||
| const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, { | ||
| dir: ctx.directory, | ||
| source, | ||
|
|
@@ -625,6 +640,9 @@ export const layer = Layer.effect( | |
|
|
||
| if (Option.isSome(configOpt)) { | ||
| const source = `${url}/api/config` | ||
| // altimate_change start — upstream_fix (#701): clear before this load. | ||
| ConfigVariable.resetBlankedEnvVars(source) | ||
| // altimate_change end | ||
| const next = yield* loadConfig(JSON.stringify(configOpt.value), { | ||
| dir: path.dirname(source), | ||
| source, | ||
|
|
@@ -671,6 +689,9 @@ export const layer = Layer.effect( | |
| // macOS managed preferences (.mobileconfig deployed via MDM) override everything | ||
| const managed = yield* Effect.promise(() => ConfigManaged.readManagedPreferences()) | ||
| if (managed) { | ||
| // altimate_change start — upstream_fix (#701): clear before this load. | ||
| ConfigVariable.resetBlankedEnvVars(managed.source) | ||
| // altimate_change end | ||
| // altimate_change start — note a managed datamate key before merging | ||
| const managedPrefs = yield* loadConfig(managed.text, { | ||
| dir: path.dirname(managed.source), | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -104,6 +104,11 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: | |
|
|
||
| const load = (text: string, configFilepath: string): Effect.Effect<Info> => | ||
| Effect.gen(function* () { | ||
| // altimate_change start — upstream_fix (#701): substitution unions now instead of | ||
| // replacing, so every caller clears first. Without this a `{env:VAR}` in tui.json that | ||
| // was later fixed kept being reported blank for the life of the process. | ||
| ConfigVariable.resetBlankedEnvVars(configFilepath) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: printf '%s\n' '--- applicable repository guidance ---'
find /tmp/coderabbit-repo-knowledge/altimateai-altimate-code-32b04b74 -maxdepth 2 -type f -name '*.md' -print | sort | while read -r f; do
case "$f" in
*/learnings/*|*/architecture/*|*/\*.md) head -5 "$f" ;;
esac
done
printf '%s\n' '--- target file outline and relevant source ---'
ast-grep outline packages/opencode/src/config/tui.ts
sed -n '80,175p' packages/opencode/src/config/tui.ts
printf '%s\n' '--- ConfigVariable bindings and callers ---'
rg -n -C 4 'resetBlankedEnvVars|blankedEnv|function loadFile|const loadFile|loadFile\\(' packages/opencode/src/configRepository: AltimateAI/altimate-code Length of output: 7621 🏁 Script executed: printf '%s\n' '--- ConfigVariable definition ---'
rg -n -C 8 'resetBlankedEnvVars|blankedEnv|substitute\\(' packages/opencode/src
printf '%s\n' '--- safe file read contract ---'
rg -n -C 8 'readFileStringSafe' packages/opencode/src
printf '%s\n' '--- TuiConfig load/reload callers ---'
sed -n '165,330p' packages/opencode/src/config/tui.ts
rg -n -C 6 'TuiConfig\\.loadState|loadState\\(' packages/opencode/src packages/opencode/testRepository: AltimateAI/altimate-code Length of output: 8540 🏁 Script executed: printf '%s\n' '--- exact ConfigVariable references ---'
rg -n -F -C 8 'resetBlankedEnvVars' packages/opencode/src
rg -n -F -C 8 'blankedEnv' packages/opencode/src
rg -n -F -C 8 'ConfigVariable.substitute' packages/opencode/src
printf '%s\n' '--- loadState references ---'
rg -n -F -C 8 'loadState' packages/opencode/src/config/tui.ts packages/opencode/src packages/opencode/test 2>/dev/null | head -240
printf '%s\n' '--- candidate variable files ---'
fd -i 'variable|env' packages/opencode/src/config packages/opencode/src | head -80Repository: AltimateAI/altimate-code Length of output: 25711 Reset blank-variable diagnostics before 🤖 Prompt for AI Agents |
||
| // altimate_change end | ||
| const expanded = yield* Effect.promise(() => | ||
| ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" }), | ||
| ) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,6 +28,27 @@ type SubstituteInput = ParseSource & { | |
| // altimate_change end | ||
| } | ||
|
|
||
| // altimate_change start — upstream_fix (#701): keep the names of variables that silently blanked. | ||
| // An unresolved bare `${VAR}` is left LITERAL above on purpose, so it stays visible and is not | ||
| // recorded here. `{env:VAR}` has no such deferral — it becomes "" and the config parses clean, so | ||
| // a missing `{env:SNOWFLAKE_PASSWORD}` launches an MCP server with a blank credential and fails | ||
| // later with an error naming neither the variable nor this file. Keyed by config source; the | ||
| // newest parse of a file replaces its entry so a fixed variable stops being reported. | ||
| const _blankedEnv = new Map<string, Set<string>>() | ||
|
|
||
| /** Drop `src`'s record so a load starts clean; substitution then unions within that load. */ | ||
| export function resetBlankedEnvVars(src: string) { | ||
| _blankedEnv.delete(src) | ||
| } | ||
|
|
||
| /** Variable names that silently became "" during config substitution, grouped by config source. */ | ||
| export function blankedEnvVars(): { source: string; names: string[] }[] { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Prompt for AI agents |
||
| return [..._blankedEnv.entries()] | ||
| .map(([src, names]) => ({ source: src, names: [...names].sort() })) | ||
| .sort((a, b) => a.source.localeCompare(b.source)) | ||
| } | ||
| // altimate_change end | ||
|
|
||
| function source(input: ParseSource) { | ||
| return input.type === "path" ? input.path : input.source | ||
| } | ||
|
|
@@ -42,6 +63,9 @@ export async function substitute(input: SubstituteInput) { | |
| // altimate_change start — upstream_fix: restore ${VAR}/${VAR:-default}/$${VAR} config interpolation | ||
| const format = input.format ?? "json" | ||
| const encode = (value: string) => (format === "raw" ? value : JSON.stringify(value).slice(1, -1)) | ||
| // altimate_change — upstream_fix (#701): collect blanked names for this parse, replacing any | ||
| // earlier entry for the same source rather than accumulating stale ones. | ||
| const blanked = new Set<string>() | ||
| let text = input.text.replace(ConfigPaths.ENV_VAR_PATTERN, (match, escaped, dollarVar, dollarDefault, braceVar) => { | ||
| if (escaped !== undefined) return "$" + escaped | ||
| if (dollarVar !== undefined) { | ||
|
|
@@ -56,12 +80,27 @@ export async function substitute(input: SubstituteInput) { | |
| return match | ||
| } | ||
| if (braceVar !== undefined) { | ||
| return (input.env?.[braceVar] ?? process.env[braceVar]) || "" | ||
| const value = input.env?.[braceVar] ?? process.env[braceVar] | ||
| // altimate_change — upstream_fix (#701): record the blank, then behave exactly as before. | ||
| if (!value) blanked.add(braceVar) | ||
| return value || "" | ||
| } | ||
| return match | ||
| }) | ||
| // altimate_change end | ||
|
|
||
| // altimate_change start — upstream_fix (#701): publish after the whole text is scanned. | ||
| // Union, not replace: one source is substituted more than once — a remote config resolves | ||
| // its `url` and then each header separately, all under the same source. Replacing meant a | ||
| // later clean call erased the names an earlier call had found, so `mcp list` silently | ||
| // omitted a blank credential. Clearing is `resetBlankedEnvVars`, called per load below. | ||
| if (blanked.size > 0) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The union change removed the self-healing Prompt for AI agentsThere was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SUGGESTION: Dropping the Before, a clean parse removed Reply with |
||
| const existing = _blankedEnv.get(source(input)) | ||
| if (existing) for (const name of blanked) existing.add(name) | ||
| else _blankedEnv.set(source(input), blanked) | ||
| } | ||
| // altimate_change end | ||
|
|
||
| const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g)) | ||
| if (!fileMatches.length) return text | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,11 +34,47 @@ function resolveServerEnvVars( | |
| field: context.field, | ||
| unresolved: stats.unresolvedNames.join(", "), | ||
| }) | ||
| // altimate_change start — upstream_fix: remember it for the user, not just the log (#701). | ||
| // An unresolved `${SNOWFLAKE_PASSWORD}` becomes "" and the server launches with a blank | ||
| // credential, failing later with something that names neither the variable nor the config | ||
| // file. The log line already had the answer; nobody reads it. Recorded here so `/mcps` can | ||
| // say so. Mirrors the `setDiscoveryResult` handoff below. | ||
| const seen = _unresolvedEnv.get(context.server) ?? new Set<string>() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WARNING:
Fix: rebuild the entry per run rather than unioning — reset Reply with |
||
| for (const name of stats.unresolvedNames) seen.add(name) | ||
| _unresolvedEnv.set(context.server, seen) | ||
|
coderabbitai[bot] marked this conversation as resolved.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When an external server name collides with an existing main-config server, this line records diagnostics for the discarded external entry. Prompt for AI agents
cubic-dev-ai[bot] marked this conversation as resolved.
|
||
| // altimate_change end | ||
| } | ||
| return out | ||
| } | ||
| // altimate_change end | ||
|
|
||
| // altimate_change start — upstream_fix: unresolved-variable record for the user surface (#701). | ||
| /** Server name -> variable names that resolved to "" during discovery. */ | ||
| const _unresolvedEnv = new Map<string, Set<string>>() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Include the discovery source in the Prompt for AI agents |
||
|
|
||
| /** | ||
| * Variable names that silently became "" for `server`, from the most recent discovery. | ||
| * | ||
| * The record is cleared at the start of every `discoverExternalMcp` run and then unioned | ||
| * within that run, because one server is resolved twice — once for `headers` and once for | ||
| * `environment`. Without the reset the map only ever grew: a server whose `{env:VAR}` had | ||
| * since been fixed kept its old entry (the recording site below is inside an | ||
| * `unresolvedNames.length > 0` guard, so a clean run never touched it), and `/mcps` went on | ||
| * telling the user to set a variable that already resolved. | ||
| * | ||
| * Only the latest run's servers are present, so a daemon that discovers for a second project | ||
| * replaces the first project's entries rather than mixing the two under a shared server name. | ||
| */ | ||
| export function unresolvedEnvVars(server: string): string[] { | ||
| return [...(_unresolvedEnv.get(server) ?? [])].sort() | ||
| } | ||
|
|
||
| /** Drop the previous run's records. Called once per `discoverExternalMcp`. */ | ||
| function resetUnresolvedEnv() { | ||
| _unresolvedEnv.clear() | ||
| } | ||
| // altimate_change end | ||
|
|
||
| interface ExternalMcpSource { | ||
| /** Relative path from base directory */ | ||
| file: string | ||
|
|
@@ -302,6 +338,8 @@ export async function discoverExternalMcp(projectDir: string): Promise<{ | |
| sources: string[] | ||
| }> { | ||
| log.info("Discovering MCP servers from external AI tool configs...") | ||
| // Start from a clean slate so a variable fixed since the last run stops being reported. | ||
| resetUnresolvedEnv() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When two project instances discover concurrently, this global reset races with the asynchronous scan. One run can erase or retain another project's names, so Prompt for AI agents |
||
| const result: Record<string, ConfigMCPV1.Info> = Object.create(null) | ||
| const contributingSources: string[] = [] | ||
| const homedir = os.homedir() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,6 +34,11 @@ import PROMPT_PLAN from "../session/prompt/plan.txt" | |
| import BUILD_SWITCH from "../session/prompt/build-switch.txt" | ||
| import MAX_STEPS from "../session/prompt/max-steps.txt" | ||
| import { defer } from "../util/defer" | ||
| // altimate_change — upstream_fix (#701): unresolved-env record for the /mcps view. | ||
| import * as McpDiscover from "../mcp/discover" | ||
| // altimate_change start — upstream_fix (#701): file-scoped blank-variable diagnostics. | ||
| import { ConfigVariable } from "../config/variable" | ||
| // altimate_change end | ||
| import { ToolRegistry } from "../tool/registry" | ||
| import { MCP } from "../mcp" | ||
| import { LSP } from "../lsp" | ||
|
|
@@ -2894,11 +2899,29 @@ NOTE: At any point in time through this workflow you should feel free to ask the | |
|
|
||
| // altimate_change start — shared text formatter for /mcps runtime status (#972) | ||
| /** @internal Exported for tests. */ | ||
| export function formatMcpStatusForDisplay(name: string, status: MCP.Status) { | ||
| // altimate_change start — upstream_fix (#701): exported so the wording is testable without | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SUGGESTION: Redundant nested Reply with |
||
| // standing up a session; `/mcps` is otherwise only reachable through the whole handler. | ||
| /** File-scoped blank-variable lines for `/mcps`, empty string when there are none. */ | ||
| export function formatBlankedEnvForDisplay(entries: { source: string; names: string[] }[]): string { | ||
| return entries | ||
| .map(({ source, names }) => "- `" + names.join(", ") + "` resolved to empty in `" + source + "` (set or remove)") | ||
| .join("\n") | ||
| } | ||
| // altimate_change end | ||
|
|
||
| export function formatMcpStatusForDisplay(name: string, status: MCP.Status, unresolvedEnv: string[] = []) { | ||
| const icon = status.status === "connected" ? "\u2713" : "\u25cb" | ||
| if (status.status === "failed") return icon + " " + status.status + " (" + status.error + ")" | ||
| if (status.status === "needs_auth") return icon + " Needs authentication (run: altimate mcp auth " + name + ")" | ||
| return icon + " " + status.status | ||
| // upstream_fix (#701): a server whose `${VAR}` did not resolve launched with that value | ||
| // blank — most often a password. It then fails with a downstream error naming neither the | ||
| // variable nor the config file, and the only trace is a log line nobody opens. Say it here, | ||
| // where the user is already looking, and say it even when the server appears connected: a | ||
| // blank credential often connects and fails on first use. | ||
| const blanks = | ||
| unresolvedEnv.length > 0 ? " \u2014 unresolved: " + unresolvedEnv.join(", ") + " (set or remove)" : "" | ||
| if (status.status === "failed") return icon + " " + status.status + " (" + status.error + ")" + blanks | ||
| if (status.status === "needs_auth") | ||
| return icon + " Needs authentication (run: altimate mcp auth " + name + ")" + blanks | ||
| return icon + " " + status.status + blanks | ||
| } | ||
| // altimate_change end | ||
|
|
||
|
|
@@ -2953,11 +2976,20 @@ NOTE: At any point in time through this workflow you should feel free to ask the | |
| const model = await lastModel(input.sessionID) | ||
| const statusMap = await MCP.status() | ||
| const rows = Object.entries(statusMap) | ||
| .map(([srv, s]) => "| `" + srv + "` | " + formatMcpStatusForDisplay(srv, s) + " |") | ||
| .map( | ||
| ([srv, s]) => | ||
| "| `" + srv + "` | " + formatMcpStatusForDisplay(srv, s, McpDiscover.unresolvedEnvVars(srv)) + " |", | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Concretely: a server configured as |
||
| ) | ||
| .join("\n") | ||
| const responseText = rows | ||
| ? "MCP servers:\n\n| Server | Status |\n|---|---|\n" + rows | ||
| : "No MCP servers configured." | ||
| // altimate_change start — upstream_fix (#701): `/mcps` showed only the per-server | ||
| // unresolved variables from discovery, while `mcp list` also reported file-scoped blanks. | ||
| // A server templated as `"url": "https://{env:MY_HOST}/mcp"` records against the config | ||
| // file rather than the server, so it appeared in the CLI and not here — in the session | ||
| // view, which is where someone is when a server will not connect. | ||
| const blanked = formatBlankedEnvForDisplay(ConfigVariable.blankedEnvVars()) | ||
| const table = rows ? "MCP servers:\n\n| Server | Status |\n|---|---|\n" + rows : "No MCP servers configured." | ||
| const responseText = blanked ? table + "\n\n" + blanked : table | ||
| // altimate_change end | ||
|
|
||
| return respond(userMsg.info.id, responseText, model) | ||
| } | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.