[bug] MCP Apps host does not apply opencode's {env:} config substitution, making correctly-configured MCP servers unreachable
Summary
mcp-app-host.js reads the opencode config file directly and passes MCP headers through verbatim. opencode substitutes {env:VAR} before use; the Apps host does not. Any remote MCP server whose headers use the documented {env:} syntax therefore receives the literal template as a credential, and the Apps host can never connect to it.
Tool calls work (opencode makes those), so the server looks healthy. Only the interactive view fails.
Environment
- OpenWork desktop 0.18.48, public distribution, Linux x86_64 AppImage
- opencode sidecar 1.18.30 (bundled)
Reproduce
- In a workspace
opencode.json, configure a remote MCP server whose auth header uses{env:}— the syntax opencode's own docs use:
{
"mcp": {
"my-server": {
"type": "remote",
"url": "https://gateway.example.com/mcp/my-server",
"headers": { "x-api-key": "Bearer {env:MY_API_KEY}" },
"enabled": true
}
}
}- Export
MY_API_KEYso opencode resolves it. - Call any tool on that server.
Expected: the tool result renders; if the tool advertises no ui:// resource, nothing extra is shown.
Actual: the tool result is correct, followed by:
Interactive view unavailable. The normal tool result is still available. Streamable HTTP POST: HTTP 401
Code: MCP_APP_RESOURCE_RESOLUTION_FAILED
Cause code: mcp_unreachable
Stage: resource-resolution
Checkpoints: resolve-started -> resolve-failed-1+566ms -> resolve-failed-2+1657ms -> resolve-failed-3+4893msThe gateway logged what it actually received: 401: Virtual Key expected. Received={env****KEY}, expected to start with 'sk-' — the literal template, not an empty or wrong value.
Cause
server/dist/mcp-app-host.js:12 — the intent is stated correctly:
async function listMcp(serverConfig, workspaceId, workspaceRoot) {
// Account-scoped gateways live in the engine-global runtime layer. Resolve
// Apps against the same effective configuration that produced the tool call.
return listMcpFromRuntimeSnapshot(workspaceRoot, await readEffectiveRuntimeOpencodeConfig(serverConfig, workspaceId));
}server/dist/mcp.js:387 reads the raw file and stores the entry unmodified:
const { data: config } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {}, { allowInvalid: true });server/dist/mcp-app-host.js:225 and :102 then hand it straight to the transport:
const requestInit = { headers: stringHeaders(config.headers) };
function stringHeaders(value) {
if (!isRecord(value)) return {};
return Object.fromEntries(Object.entries(value).filter((entry) => typeof entry[1] === "string"));
}stringHeaders is a type filter. It is the same config file, but not the same resolution.
opencode resolves it in ConfigVariable.substitute, applied to the whole config text before parsing:
r = o.text.replace(/\{env:([^}]+)\}/g, (D, n) => (o.env?.[n] ?? process.env[n]) || "")Note the || "": a missing variable yields an empty string, never the literal. Receiving the literal template is therefore proof that substitution never ran, which rules out a misconfigured environment.
Searching the unpacked app.asar (13,435 files) finds exactly one non-test occurrence of {env:, and it matches the token only in order to discard it — server/dist/engine-v2-preview.js:139:
const explicitKey = typeof apiKey === "string" && apiKey.trim() !== "" && !apiKey.includes("{env:") ? apiKey : undefined;Suggested fix
OpenWork already imports createOpencodeClient (server/dist/server.js:7) and calls opencode's /config in three places (server.js:2185, :2473, :2502), so resolved configuration is already reachable from the server. Either source the Apps host from that, or apply the same substitution step before building requestInit. {file:…} deserves the same treatment.
Secondary observation
resolveMcpAppResource (mcp-app-host.js:543) must open a connection and call listTools before it can discover that a tool has no ui:// resource:
const tool = (await listTools(client)).find(...);
if (!tool) return null;
const resourceUri = toolUiResourceUri(tool);
if (!resourceUri) return null;So every remote MCP server is probed on every tool result, and mcp_unreachable is in the renderer's surfaced-error set. Servers that ship no MCP Apps at all still raise a user-facing error on any transient connection failure. Fixing the substitution hides this, but it remains: a server with no interactive view should fail silently.
Source: different-ai/openwork