#5102·openwork

[bug] MCP Apps host does not apply opencode's {env:} config substitution, making correctly-configured MCP servers unreachable

Author: juancarlosmCreated Sep 17, 2026Updated Sep 17, 2026

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

  1. In a workspace opencode.json, configure a remote MCP server whose auth header uses {env:} — the syntax opencode's own docs use:
json
{
  "mcp": {
    "my-server": {
      "type": "remote",
      "url": "https://gateway.example.com/mcp/my-server",
      "headers": { "x-api-key": "Bearer {env:MY_API_KEY}" },
      "enabled": true
    }
  }
}
  1. Export MY_API_KEY so opencode resolves it.
  2. 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+4893ms

The 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:

javascript
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:

javascript
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:

javascript
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:

javascript
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:

javascript
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:

javascript
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.