Skip to content

Commit 280a18a

Browse files
committed
fix(cursor): bake literal runtime env into container mcp.json (was ${env:}, broke in webterminal)
The container adapter's ~/.cursor/mcp.json forwarded ZCP_API_KEY + serviceId/hostname/projectId via Cursor's ${env:NAME} substitution, which resolves against cursor-agent's OWN launch environment. Some real launch contexts lack the zembed vars: live-confirmed 2026-07-03 that the Zerops webterminal launches cursor-agent without them, so ${env:ZCP_API_KEY} resolved empty and zcp serve closed the MCP connection at startup — "MCP error -32000: Connection closed", 0 tools in the interactive TUI. Earlier SSH-based verification passed only because SSH shells DO carry the vars, masking the bug. Fix (grok-parity): bake the resolved LITERAL values at init — serviceId/hostname/projectId from runtime.Info, ZCP_API_KEY from the init process env, each omitted when empty. This makes the MCP server independent of cursor-agent's launch env. Cursor was the only adapter depending on the agent's launch env for these vars (grok already bakes literals). Proven live: env -i HOME PATH cursor-agent mcp list-tools zerops → literal-baked file enumerates 22 tools (incl. the three container-only ones), the ${env:} file fails "Connection closed"; the real fixed `zcp init` writes literals and works in the stripped env. Pinned by TestCursor_MCPEntry_BakesLiteralRuntimeEnv (replaces TestCursor_MCPEntry_EnvForwardsRuntimeDetectionVars) + TestCursor_MCPEntry_OmitsEmptyEnvVars.
1 parent 333a8c2 commit 280a18a

2 files changed

Lines changed: 95 additions & 51 deletions

File tree

internal/init/adapters/cursor.go

Lines changed: 42 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ package adapters
22

33
import (
44
"fmt"
5+
"os"
56
"path/filepath"
67
"strings"
78
"time"
9+
10+
"github.com/zeropsio/zcp/internal/runtime"
811
)
912

1013
// Cursor implements Adapter for the Cursor IDE's headless CLI
@@ -135,7 +138,7 @@ func (Cursor) ContainerInit(env Env) error {
135138
if err != nil {
136139
return fmt.Errorf("load %s: %w", configPath, err)
137140
}
138-
UpsertPath(data, cursorMCPServerEntry(), "mcpServers", "zerops")
141+
UpsertPath(data, cursorMCPServerEntry(env.RT), "mcpServers", "zerops")
139142
if err := SaveJSONFile(configPath, data); err != nil {
140143
return fmt.Errorf("write %s: %w", configPath, err)
141144
}
@@ -176,35 +179,49 @@ func cursorWorkspaceDir(workspacePath string) string {
176179
// - type=stdio is required by Cursor's schema (distinguishes from
177180
// SSE / streamable HTTP transports).
178181
// - command + args invoke `zcp serve` via the stdio transport.
179-
// - env uses Cursor's "${env:NAME}" substitution syntax to forward
180-
// the four vars the zcp serve subprocess needs from the Cursor
181-
// process env. This is REQUIRED because Cursor spawns the MCP
182-
// subprocess with a STRIPPED env (verified empirically 2026-05-24
183-
// by wrapping zcp serve with a logger — Cursor passed only
184-
// HOME/USER/PATH to the subprocess).
182+
// - env forwards the four vars the zcp serve subprocess needs. This is
183+
// REQUIRED because Cursor spawns the MCP subprocess with a STRIPPED
184+
// env (verified 2026-05-24 by wrapping zcp serve with a logger —
185+
// Cursor passed only HOME/USER/PATH). Without them, zcp serve sees
186+
// runtime.Detect returning InContainer=false (skipping the three
187+
// container-only tools) and, worse, a missing ZCP_API_KEY makes
188+
// zcp serve close the connection at startup ("MCP error -32000:
189+
// Connection closed").
185190
//
186-
// Without this env block, zcp serve sees serviceId="" → runtime.Detect
187-
// returns InContainer=false → server.go skips three container-only
188-
// tools (zerops_browser gated on InContainer, zerops_dev_server and
189-
// zerops_deploy_batch gated on sshDeployer != nil which only initializes
190-
// in container mode). Plus ZCP_API_KEY is missing → API calls fail
191-
// auth.
191+
// The values are baked as RESOLVED LITERALS (grok-parity), NOT Cursor's
192+
// "${env:NAME}" substitution. "${env:NAME}" resolves against
193+
// CURSOR-AGENT's OWN launch env at spawn time — which some real launch
194+
// contexts lack: live-confirmed 2026-07-03 that the Zerops webterminal
195+
// launches cursor-agent WITHOUT the zembed vars, so
196+
// "${env:ZCP_API_KEY}" resolved empty and every MCP call failed with
197+
// "Connection closed" (while an SSH shell, which does carry the vars,
198+
// worked — masking the bug in earlier verification). Baking the value
199+
// the init process already holds makes the server independent of the
200+
// launch env, exactly as grokMCPServerEntry does.
192201
//
193-
// Same bug class as Codex (commit 07a2044a) — restrictive env
194-
// pass-through requires explicit enumeration. Cursor's mechanism
195-
// (env-value substitution via "${env:NAME}") differs from Codex's
196-
// (env_vars allowlist) but the structural fix is the same: name every
197-
// var zcp serve reads at startup.
198-
func cursorMCPServerEntry() map[string]any {
202+
// serviceId/hostname/projectId come from rt (resolved by runtime.Detect
203+
// at init); ZCP_API_KEY from the init process env (same pattern as
204+
// grok's os.Getenv read). Each is omitted when empty so no phantom blank
205+
// value lands in the config.
206+
func cursorMCPServerEntry(rt runtime.Info) map[string]any {
207+
serverEnv := map[string]any{}
208+
if rt.ServiceID != "" {
209+
serverEnv["serviceId"] = rt.ServiceID
210+
}
211+
if rt.ServiceName != "" {
212+
serverEnv["hostname"] = rt.ServiceName
213+
}
214+
if rt.ProjectID != "" {
215+
serverEnv["projectId"] = rt.ProjectID
216+
}
217+
if key := strings.TrimSpace(os.Getenv("ZCP_API_KEY")); key != "" {
218+
serverEnv["ZCP_API_KEY"] = key
219+
}
220+
199221
return map[string]any{
200222
"type": "stdio",
201223
"command": "zcp",
202224
"args": []any{"serve"},
203-
"env": map[string]any{
204-
"ZCP_API_KEY": "${env:ZCP_API_KEY}",
205-
"serviceId": "${env:serviceId}",
206-
"hostname": "${env:hostname}",
207-
"projectId": "${env:projectId}",
208-
},
225+
"env": serverEnv,
209226
}
210227
}

internal/init/adapters/cursor_test.go

Lines changed: 53 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -365,23 +365,29 @@ func TestCursor_ContainerInit_EmptyHomeReturnsError(t *testing.T) {
365365
}
366366
}
367367

368-
// TestCursor_MCPEntry_EnvForwardsRuntimeDetectionVars pins the env
369-
// block contract — Cursor RESTRICTS the spawned MCP subprocess's env
370-
// (verified 2026-05-24 by wrapping zcp serve with a logger; Cursor
371-
// passed only HOME/USER/PATH). Without explicit forwarding of
372-
// ZCP_API_KEY + serviceId + hostname + projectId, zcp serve sees
373-
// runtime.Detect returning InContainer=false and 3 tools fail to
374-
// register (zerops_browser, zerops_dev_server, zerops_deploy_batch).
375-
// Pin guards against well-meaning "drop the env block, it's redundant"
376-
// edits — same bug class as Codex commit 07a2044a's env_vars fix.
368+
// TestCursor_MCPEntry_BakesLiteralRuntimeEnv pins the env block
369+
// contract — Cursor RESTRICTS the spawned MCP subprocess's env (verified
370+
// 2026-05-24 by wrapping zcp serve with a logger; Cursor passed only
371+
// HOME/USER/PATH). Without explicit forwarding of ZCP_API_KEY +
372+
// serviceId + hostname + projectId, zcp serve sees runtime.Detect
373+
// returning InContainer=false / loses API auth.
377374
//
378-
// "${env:NAME}" is Cursor's documented substitution syntax — value
379-
// resolves to the named var from Cursor's calling-process env at
380-
// subprocess spawn time.
381-
func TestCursor_MCPEntry_EnvForwardsRuntimeDetectionVars(t *testing.T) {
382-
t.Parallel()
375+
// The values are baked as RESOLVED LITERALS (grok-parity), NOT Cursor's
376+
// "${env:NAME}" substitution. "${env:NAME}" resolves against
377+
// CURSOR-AGENT's own launch env — which some real launch contexts lack
378+
// (live-confirmed 2026-07-03: the Zerops webterminal launches
379+
// cursor-agent without the zembed vars, so "${env:ZCP_API_KEY}"
380+
// resolved empty and zcp serve closed the MCP connection — "MCP error
381+
// -32000: Connection closed"). Baking the value the init process already
382+
// holds makes the server independent of the launch env. Pin guards
383+
// against a regression back to "${env:...}".
384+
func TestCursor_MCPEntry_BakesLiteralRuntimeEnv(t *testing.T) {
385+
// Not parallel — sets ZCP_API_KEY so the literal assertion is deterministic.
383386
home := t.TempDir()
384387
env := newCursorEnv(t, home)
388+
env.RT = runtime.Info{InContainer: true, ServiceID: "svc-123", ServiceName: "appdev", ProjectID: "proj-456"}
389+
t.Setenv("ZCP_API_KEY", "secret-key-value")
390+
385391
if err := adapters.NewCursor().ContainerInit(env); err != nil {
386392
t.Fatal(err)
387393
}
@@ -392,18 +398,15 @@ func TestCursor_MCPEntry_EnvForwardsRuntimeDetectionVars(t *testing.T) {
392398
if !ok {
393399
t.Fatalf("mcpServers.zerops.env missing or wrong shape; got %v (type %T)", zerops["env"], zerops["env"])
394400
}
395-
396-
required := []string{"ZCP_API_KEY", "serviceId", "hostname", "projectId"}
397-
for _, name := range required {
398-
v, has := envMap[name]
399-
if !has {
400-
t.Errorf("env.%s missing — without it Cursor's restrictive subprocess env strips this var and zcp serve loses runtime detection / auth", name)
401-
continue
402-
}
403-
s, _ := v.(string)
404-
want := "${env:" + name + "}"
405-
if s != want {
406-
t.Errorf("env.%s = %q, want %q (Cursor's documented substitution syntax)", name, s, want)
401+
want := map[string]string{
402+
"serviceId": "svc-123",
403+
"hostname": "appdev",
404+
"projectId": "proj-456",
405+
"ZCP_API_KEY": "secret-key-value",
406+
}
407+
for k, v := range want {
408+
if envMap[k] != v {
409+
t.Errorf("env.%s = %v, want %q (literal value — must not interpolate: launch env may lack the var)", k, envMap[k], v)
407410
}
408411
}
409412

@@ -415,6 +418,30 @@ func TestCursor_MCPEntry_EnvForwardsRuntimeDetectionVars(t *testing.T) {
415418
}
416419
}
417420

421+
// TestCursor_MCPEntry_OmitsEmptyEnvVars pins that env keys are omitted
422+
// (not written blank) when unresolved — a blank ZCP_API_KEY/serviceId is
423+
// worse than absent (it shadows nothing but reads as a phantom value).
424+
// Grok-parity (TestGrok_MCPEntry_OmitsAPIKeyWhenUnset).
425+
func TestCursor_MCPEntry_OmitsEmptyEnvVars(t *testing.T) {
426+
// Not parallel — clears ZCP_API_KEY.
427+
home := t.TempDir()
428+
env := newCursorEnv(t, home)
429+
env.RT = runtime.Info{InContainer: true} // no ServiceID/Name/ProjectID
430+
t.Setenv("ZCP_API_KEY", "")
431+
432+
if err := adapters.NewCursor().ContainerInit(env); err != nil {
433+
t.Fatal(err)
434+
}
435+
config := loadCursorJSON(t, home)
436+
zerops := config["mcpServers"].(map[string]any)["zerops"].(map[string]any)
437+
envMap, _ := zerops["env"].(map[string]any)
438+
for _, k := range []string{"ZCP_API_KEY", "serviceId", "hostname", "projectId"} {
439+
if _, present := envMap[k]; present {
440+
t.Errorf("env.%s present but should be omitted when unresolved; got %v", k, envMap[k])
441+
}
442+
}
443+
}
444+
418445
// TestCursor_MCPEntry_TypeStdioRequired pins the `type=stdio` field —
419446
// Cursor's schema requires explicit transport type to distinguish from
420447
// SSE / Streamable HTTP. Omitting type would either default to remote

0 commit comments

Comments
 (0)