From f1d6f2e36df73cfc7148d12c7597e6cb66c7ceb6 Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Fri, 30 Oct 2026 16:21:26 +0100 Subject: [PATCH 1/3] feat(mcp): point `dhq mcp` at the hosted MCP server `dhq mcp` now prints setup for the hosted, read-only DeployHQ MCP server (https://mcp.deployhq.com/mcp): the `claude mcp add --transport http` command, Claude Desktop connector steps, and an mcpServers JSON snippet (`--json` returns the same). Guidance goes to stderr, data to stdout. The local deployhq-mcp-server stdio launcher moves behind `--local`. Existing client configs ({"command":"dhq","args":["mcp"]}) keep working: with piped stdin and no --json, it still starts the local server and warns on stderr that this is deprecated. --local and an explicit --json take priority over the piped-stdin heuristic. Agent metadata for `dhq mcp` now reports JSON support and automation safety. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 15 ++ README.md | 2 +- internal/commands/agent_metadata.go | 7 +- internal/commands/agent_metadata_test.go | 7 +- internal/commands/mcp.go | 235 ++++++++++++++++------- internal/commands/mcp_test.go | 111 +++++++++-- skills/deployhq/references/auth-setup.md | 16 +- 7 files changed, 300 insertions(+), 93 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9052c67..d0190af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 While the CLI is pre-1.0, minor versions may carry breaking changes to the public `pkg/sdk` surface; these are always called out under **Breaking (SDK)**. +## [Unreleased] + +### Changed + +- **`dhq mcp`** now points at the hosted DeployHQ MCP server + (`https://mcp.deployhq.com/mcp`): in a terminal it prints the setup (the + `claude mcp add --transport http` command and an `mcpServers` JSON snippet; + `--json` returns the same, even when stdin is piped). The hosted server is read-only for now. The local + `deployhq-mcp-server` stdio launcher moved behind `dhq mcp --local`. When stdin + is piped, as with existing `{"command": "dhq", "args": ["mcp"]}` client + configs, it still starts the local server and warns that this is deprecated. + `MCPConfigSnippet()` now returns the hosted `http` config. Setup guidance goes + to stderr and only the JSON config snippet to stdout. `dhq commands --json` now + reports `dhq mcp` as JSON-capable and safe for automation. + ## [0.22.0] - 2026-10-28 ### Added diff --git a/README.md b/README.md index b044566..4d36fa6 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ dhq doctor (health check) dhq update (self-update to latest version) dhq skills list | install (auto-detect AI agents and install the DeployHQ skill) dhq setup claude | codex | cursor | windsurf (deprecated — use 'dhq skills') -dhq mcp (start MCP server in stdio mode) +dhq mcp (set up the hosted MCP server; --local runs the stdio server) ``` ## Deploy with Live Progress diff --git a/internal/commands/agent_metadata.go b/internal/commands/agent_metadata.go index 997dcc5..abf4d6a 100644 --- a/internal/commands/agent_metadata.go +++ b/internal/commands/agent_metadata.go @@ -221,8 +221,11 @@ var commandMetadataTable = map[string]AgentMetadata{ SupportsJSON: true, SafeForAutomation: true, }, "dhq mcp": { - Interactive: true, - SupportsJSON: false, SafeForAutomation: false, + // Prints hosted-server setup; safe with --json. Without it, piped stdin + // starts the deprecated local stdio server, and --local is long-running + // and can prompt to install the npm package. + Idempotent: true, + SupportsJSON: true, SafeForAutomation: true, }, // Configuration resources (all non-interactive CRUD) diff --git a/internal/commands/agent_metadata_test.go b/internal/commands/agent_metadata_test.go index ed1579a..542b9c4 100644 --- a/internal/commands/agent_metadata_test.go +++ b/internal/commands/agent_metadata_test.go @@ -176,10 +176,11 @@ func TestNonInteractive_ConfigureFails(t *testing.T) { assert.Contains(t, err.Error(), "interactive-only") } -func TestAgentMetadata_MCPNotSafe(t *testing.T) { +func TestAgentMetadata_MCPSetupIsJSONSafe(t *testing.T) { m := lookupAgentMetadata("dhq mcp") - assert.True(t, m.Interactive) - assert.False(t, m.SafeForAutomation) + assert.True(t, m.SupportsJSON) + assert.True(t, m.SafeForAutomation) + assert.False(t, m.Interactive) } func TestAgentMetadata_AuthLogout(t *testing.T) { diff --git a/internal/commands/mcp.go b/internal/commands/mcp.go index f457f9e..7103e28 100644 --- a/internal/commands/mcp.go +++ b/internal/commands/mcp.go @@ -1,6 +1,8 @@ package commands import ( + "encoding/json" + "fmt" "os" "os/exec" "path/filepath" @@ -17,87 +19,173 @@ type mcpBinary struct { args []string // extra args prepended before user args } +// hostedMCPURL is the hosted, read-only DeployHQ MCP server. Nothing to +// install; the user signs in to DeployHQ from their assistant. +const hostedMCPURL = "https://mcp.deployhq.com/mcp" + func newMCPCmd() *cobra.Command { - return &cobra.Command{ + var local bool + cmd := &cobra.Command{ Use: "mcp", - Short: "Start MCP server (stdio passthrough)", - Long: `Start the DeployHQ MCP server in stdio mode. -This passes stdin/stdout through to the MCP server binary, -allowing AI agents to use DeployHQ via the Model Context Protocol. - -The MCP server binary is searched in: - 1. deployhq-mcp-server in PATH - 2. ./node_modules/.bin/deployhq-mcp-server - 3. npx deployhq-mcp-server`, + Short: "Set up the hosted DeployHQ MCP server", + Long: `Show how to connect an AI assistant to the hosted DeployHQ MCP server at +` + hostedMCPURL + ` + +Nothing to install: you sign in to DeployHQ from your assistant. The hosted +server is read-only (accounts, projects, servers and deployments); use the +CLI for anything that changes a deployment. + +The previous local stdio server (the deployhq-mcp-server npm package) is still +available with --local. It is also used when stdin is piped, so existing client +configs of the form {"command": "dhq", "args": ["mcp"]} keep working; that +fallback is deprecated.`, RunE: func(cmd *cobra.Command, args []string) error { env := cliCtx.Envelope - - bin := findMCPBinary() - if bin == nil { - // Offer to install if running interactively - if !env.NonInteractive { - env.Status("MCP server not found.") - prompt := promptui.Select{ - Label: "Install deployhq-mcp-server", - Items: []string{ - "npm install -g deployhq-mcp-server", - "Cancel", - }, - } - idx, _, err := prompt.Run() - if err != nil || idx == 1 { - return &output.UserError{Message: "MCP server not installed"} - } - - env.Status("Installing deployhq-mcp-server...") - c := exec.Command("npm", "install", "-g", "deployhq-mcp-server") - c.Stdout = env.Stderr - c.Stderr = env.Stderr - if err := c.Run(); err != nil { - return &output.InternalError{Message: "npm install failed", Cause: err} - } - - // Re-detect after install - bin = findMCPBinary() - if bin == nil { - return &output.InternalError{Message: "installed but binary not found in PATH"} - } - } else { - return &output.UserError{ - Message: "MCP server binary not found", - Hint: "Install with: npm install -g deployhq-mcp-server", - } - } + switch decideMCPMode(local, stdinIsPiped(), env.JSONMode) { + case mcpModeLocal: + return runLocalMCP(args) + case mcpModeLegacyStdio: + env.Warn("`dhq mcp` over stdio is deprecated; point your client at the hosted server %s (run `dhq mcp` in a terminal for setup). Use `dhq mcp --local` to keep the local server.", hostedMCPURL) + return runLocalMCP(args) } + return printHostedMCPSetup(env) + }, + } + cmd.Flags().BoolVar(&local, "local", false, "Run the local stdio server (deployhq-mcp-server) instead of showing hosted setup") + return cmd +} - // The MCP server reads credentials from DEPLOYHQ_ACCOUNT / DEPLOYHQ_EMAIL / - // DEPLOYHQ_API_KEY and exits with status 1 if any is missing. Inject the - // resolved CLI credentials so users who logged in via keyring (the common - // case) don't have to re-export them just to start the server. - account, email, apiKey, err := cliCtx.Credentials() - if err != nil { - return err - } +type mcpMode int + +const ( + mcpModeSetup mcpMode = iota // print hosted-server setup + mcpModeLocal // --local: run the stdio server + mcpModeLegacyStdio // piped stdin: an old client config launched us +) + +// decideMCPMode picks what `dhq mcp` does. --local and an explicit --json are +// authoritative; piped stdin alone is only a heuristic for "an MCP client +// launched me", so it must not override an explicit request for setup output +// (agents and CI routinely give subprocesses a pipe for stdin). +func decideMCPMode(local, stdinPiped, explicitJSON bool) mcpMode { + switch { + case local: + return mcpModeLocal + case explicitJSON: + return mcpModeSetup + case stdinPiped: + return mcpModeLegacyStdio + } + return mcpModeSetup +} + +// printHostedMCPSetup writes the hosted setup. Stdout carries only data (the +// JSON config snippet, or the structured --json object); the guidance is +// human text and goes to stderr. +func printHostedMCPSetup(env *output.Envelope) error { + if env.WantsJSON() { + return env.WriteJSON(map[string]interface{}{ + "url": hostedMCPURL, + "claude_code": claudeCodeMCPCommand(), + "config": json.RawMessage(MCPConfigSnippet()), + }) + } + env.Status("Hosted DeployHQ MCP server: %s", hostedMCPURL) + env.Status("Read-only for now: look at accounts, projects, servers and deployments.") + env.Status("Sign in to DeployHQ from your assistant; use the CLI to deploy or roll back.") + env.Status("") + env.Status("Claude Code: %s", claudeCodeMCPCommand()) + env.Status("Claude Desktop: Settings > Connectors > Add custom connector, and enter the URL above.") + env.Status("Cursor and other clients that read an mcpServers config: add this to your MCP config (printed on stdout):") + env.Status("") + _, _ = fmt.Fprintln(env.Stdout, MCPConfigSnippet()) + env.Status("") + env.Status("Local stdio server (deprecated): dhq mcp --local") + return nil +} + +// stdinIsPiped reports whether stdin is not a terminal, i.e. an MCP client has +// launched us and expects a stdio server. +func stdinIsPiped() bool { + fi, err := os.Stdin.Stat() + if err != nil { + return false + } + return fi.Mode()&os.ModeCharDevice == 0 +} - cmdArgs := append(bin.args, args...) - cliCtx.Logger.Write("Starting MCP server: %s %v", bin.path, cmdArgs) +func claudeCodeMCPCommand() string { + return "claude mcp add --transport http deployhq " + hostedMCPURL +} - c := exec.Command(bin.path, cmdArgs...) - c.Stdin = os.Stdin - c.Stdout = os.Stdout - c.Stderr = os.Stderr - c.Env = mergeEnv(os.Environ(), map[string]string{ - "DEPLOYHQ_ACCOUNT": account, - "DEPLOYHQ_EMAIL": email, - "DEPLOYHQ_API_KEY": apiKey, - }) +// runLocalMCP starts the local deployhq-mcp-server over stdio. +func runLocalMCP(args []string) error { + env := cliCtx.Envelope + + bin := findMCPBinary() + if bin == nil { + // Offer to install if running interactively + if !env.NonInteractive { + env.Status("MCP server not found.") + prompt := promptui.Select{ + Label: "Install deployhq-mcp-server", + Items: []string{ + "npm install -g deployhq-mcp-server", + "Cancel", + }, + } + idx, _, err := prompt.Run() + if err != nil || idx == 1 { + return &output.UserError{Message: "MCP server not installed"} + } + env.Status("Installing deployhq-mcp-server...") + c := exec.Command("npm", "install", "-g", "deployhq-mcp-server") + c.Stdout = env.Stderr + c.Stderr = env.Stderr if err := c.Run(); err != nil { - return &output.InternalError{Message: "MCP server exited", Cause: err} + return &output.InternalError{Message: "npm install failed", Cause: err} } - return nil - }, + + // Re-detect after install + bin = findMCPBinary() + if bin == nil { + return &output.InternalError{Message: "installed but binary not found in PATH"} + } + } else { + return &output.UserError{ + Message: "MCP server binary not found", + Hint: "Install with: npm install -g deployhq-mcp-server", + } + } } + + // The MCP server reads credentials from DEPLOYHQ_ACCOUNT / DEPLOYHQ_EMAIL / + // DEPLOYHQ_API_KEY and exits with status 1 if any is missing. Inject the + // resolved CLI credentials so users who logged in via keyring (the common + // case) don't have to re-export them just to start the server. + account, email, apiKey, err := cliCtx.Credentials() + if err != nil { + return err + } + + cmdArgs := append(bin.args, args...) + cliCtx.Logger.Write("Starting MCP server: %s %v", bin.path, cmdArgs) + + c := exec.Command(bin.path, cmdArgs...) + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + c.Env = mergeEnv(os.Environ(), map[string]string{ + "DEPLOYHQ_ACCOUNT": account, + "DEPLOYHQ_EMAIL": email, + "DEPLOYHQ_API_KEY": apiKey, + }) + + if err := c.Run(); err != nil { + return &output.InternalError{Message: "MCP server exited", Cause: err} + } + return nil } // mergeEnv returns env with each key in overrides set to its value, only when @@ -162,14 +250,15 @@ func findMCPBinary() *mcpBinary { return nil } -// MCPConfigSnippet returns a JSON snippet for configuring the MCP server -// in agent config files (e.g. Claude Desktop, Cursor). +// MCPConfigSnippet returns an mcpServers JSON snippet for the hosted MCP server, +// for clients that read that config format (e.g. Cursor). Claude Desktop adds +// remote servers as account-level custom connectors instead. func MCPConfigSnippet() string { return `{ "mcpServers": { "deployhq": { - "command": "dhq", - "args": ["mcp"] + "type": "http", + "url": "` + hostedMCPURL + `" } } }` diff --git a/internal/commands/mcp_test.go b/internal/commands/mcp_test.go index f3c0a13..7beaadc 100644 --- a/internal/commands/mcp_test.go +++ b/internal/commands/mcp_test.go @@ -1,17 +1,21 @@ package commands import ( + "bytes" + "encoding/json" "sort" "strings" "testing" + + "github.com/deployhq/deployhq-cli/internal/output" ) func TestMergeEnv(t *testing.T) { tests := []struct { - name string - env []string - overrides map[string]string - wantHas []string + name string + env []string + overrides map[string]string + wantHas []string wantAbsent []string }{ { @@ -21,17 +25,17 @@ func TestMergeEnv(t *testing.T) { wantHas: []string{"PATH=/usr/bin", "HOME=/root", "DEPLOYHQ_ACCOUNT=acme", "DEPLOYHQ_EMAIL=a@b"}, }, { - name: "preserves user-set keys", - env: []string{"DEPLOYHQ_ACCOUNT=other", "PATH=/usr/bin"}, - overrides: map[string]string{"DEPLOYHQ_ACCOUNT": "acme", "DEPLOYHQ_EMAIL": "a@b"}, - wantHas: []string{"DEPLOYHQ_ACCOUNT=other", "DEPLOYHQ_EMAIL=a@b", "PATH=/usr/bin"}, + name: "preserves user-set keys", + env: []string{"DEPLOYHQ_ACCOUNT=other", "PATH=/usr/bin"}, + overrides: map[string]string{"DEPLOYHQ_ACCOUNT": "acme", "DEPLOYHQ_EMAIL": "a@b"}, + wantHas: []string{"DEPLOYHQ_ACCOUNT=other", "DEPLOYHQ_EMAIL=a@b", "PATH=/usr/bin"}, wantAbsent: []string{"DEPLOYHQ_ACCOUNT=acme"}, }, { - name: "skips empty override values", - env: []string{"PATH=/usr/bin"}, - overrides: map[string]string{"DEPLOYHQ_API_KEY": ""}, - wantHas: []string{"PATH=/usr/bin"}, + name: "skips empty override values", + env: []string{"PATH=/usr/bin"}, + overrides: map[string]string{"DEPLOYHQ_API_KEY": ""}, + wantHas: []string{"PATH=/usr/bin"}, wantAbsent: []string{"DEPLOYHQ_API_KEY="}, }, { @@ -65,3 +69,86 @@ func TestMergeEnv(t *testing.T) { }) } } + +func TestMCPConfigSnippet_Hosted(t *testing.T) { + got := MCPConfigSnippet() + if !strings.Contains(got, `"type": "http"`) || !strings.Contains(got, hostedMCPURL) { + t.Fatalf("snippet should target hosted server, got:\n%s", got) + } + if strings.Contains(got, `"command"`) { + t.Fatalf("snippet should not use a stdio command, got:\n%s", got) + } +} + +func TestMCPCmd_HasLocalFlag(t *testing.T) { + if newMCPCmd().Flags().Lookup("local") == nil { + t.Fatal("dhq mcp should keep a --local flag for the stdio server") + } +} + +func TestDecideMCPMode(t *testing.T) { + tests := []struct { + name string + local, piped, explicitJSON bool + want mcpMode + }{ + {"terminal", false, false, false, mcpModeSetup}, + {"--local wins", true, false, false, mcpModeLocal}, + {"--local wins over pipe", true, true, false, mcpModeLocal}, + {"--local wins over json", true, false, true, mcpModeLocal}, + {"legacy client config", false, true, false, mcpModeLegacyStdio}, + {"explicit --json beats pipe", false, true, true, mcpModeSetup}, + {"explicit --json in terminal", false, false, true, mcpModeSetup}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := decideMCPMode(tt.local, tt.piped, tt.explicitJSON); got != tt.want { + t.Fatalf("decideMCPMode(%v,%v,%v) = %v, want %v", tt.local, tt.piped, tt.explicitJSON, got, tt.want) + } + }) + } +} + +func TestPrintHostedMCPSetup_Text(t *testing.T) { + var stdout, stderr bytes.Buffer + env := &output.Envelope{Stdout: &stdout, Stderr: &stderr, IsTTY: true} + if err := printHostedMCPSetup(env); err != nil { + t.Fatal(err) + } + // stdout is data only: the config snippet, no guidance prose. + if got := strings.TrimSpace(stdout.String()); got != MCPConfigSnippet() { + t.Fatalf("stdout should be only the config snippet, got:\n%s", got) + } + for _, want := range []string{hostedMCPURL, "claude mcp add --transport http", "Connectors", "dhq mcp --local"} { + if !strings.Contains(stderr.String(), want) { + t.Errorf("stderr missing %q:\n%s", want, stderr.String()) + } + } +} + +func TestPrintHostedMCPSetup_JSON(t *testing.T) { + var stdout, stderr bytes.Buffer + env := &output.Envelope{Stdout: &stdout, Stderr: &stderr, JSONMode: true} + if err := printHostedMCPSetup(env); err != nil { + t.Fatal(err) + } + var got struct { + URL string `json:"url"` + ClaudeCode string `json:"claude_code"` + Config struct { + MCPServers map[string]map[string]string `json:"mcpServers"` + } `json:"config"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("stdout is not JSON: %v\n%s", err, stdout.String()) + } + if got.URL != hostedMCPURL || got.ClaudeCode != claudeCodeMCPCommand() { + t.Errorf("unexpected url/claude_code: %+v", got) + } + if got.Config.MCPServers["deployhq"]["url"] != hostedMCPURL { + t.Errorf("config should be a nested object targeting the hosted URL: %+v", got.Config) + } + if stderr.Len() != 0 { + t.Errorf("JSON mode should not write guidance to stderr, got: %s", stderr.String()) + } +} diff --git a/skills/deployhq/references/auth-setup.md b/skills/deployhq/references/auth-setup.md index 30de2bc..7015f90 100644 --- a/skills/deployhq/references/auth-setup.md +++ b/skills/deployhq/references/auth-setup.md @@ -225,12 +225,24 @@ dhq setup claude --uninstall # remove (no dhq skills equivalent yet) ``` ### `dhq mcp` -Show MCP (Model Context Protocol) server configuration. +Show how to connect an assistant to the hosted DeployHQ MCP server +(`https://mcp.deployhq.com/mcp`). Nothing to install; the user signs in to +DeployHQ from their assistant. The hosted server is read-only (accounts, +projects, servers, deployments), so use the CLI for anything that changes a +deployment. ```bash -dhq mcp +dhq mcp --json # hosted setup as JSON (url, claude_code, config) +dhq mcp # human setup on stderr, mcpServers JSON snippet on stdout +dhq mcp --local # run the deprecated local stdio server instead +claude mcp add --transport http deployhq https://mcp.deployhq.com/mcp ``` +When stdin is piped (an old client config of `command: dhq, args: [mcp]`), +`dhq mcp` still starts the local stdio server and warns on stderr; pass `--json` +to get the setup instead. Claude Desktop adds remote servers via Settings > +Connectors (custom connector), not the JSON snippet. + ## Telemetry ### `dhq telemetry status` From 961fd5789b010eb567274a79c40eacb094524ca5 Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Fri, 30 Oct 2026 16:27:17 +0100 Subject: [PATCH 2/3] fix(mcp): don't mark `dhq mcp` safe for automation Bare `dhq mcp` with piped stdin falls back to the blocking local stdio server, so agents relying on safe_for_automation could hang unless they know to pass --json. Keep SupportsJSON, drop SafeForAutomation. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 3 ++- internal/commands/agent_metadata.go | 10 +++++----- internal/commands/agent_metadata_test.go | 4 ++-- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0190af..c209ced 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,8 @@ While the CLI is pre-1.0, minor versions may carry breaking changes to the publi configs, it still starts the local server and warns that this is deprecated. `MCPConfigSnippet()` now returns the hosted `http` config. Setup guidance goes to stderr and only the JSON config snippet to stdout. `dhq commands --json` now - reports `dhq mcp` as JSON-capable and safe for automation. + reports `dhq mcp` as JSON-capable (still not safe for automation, since bare + `dhq mcp` with piped stdin starts the local server). ## [0.22.0] - 2026-10-28 diff --git a/internal/commands/agent_metadata.go b/internal/commands/agent_metadata.go index abf4d6a..cd87ec9 100644 --- a/internal/commands/agent_metadata.go +++ b/internal/commands/agent_metadata.go @@ -221,11 +221,11 @@ var commandMetadataTable = map[string]AgentMetadata{ SupportsJSON: true, SafeForAutomation: true, }, "dhq mcp": { - // Prints hosted-server setup; safe with --json. Without it, piped stdin - // starts the deprecated local stdio server, and --local is long-running - // and can prompt to install the npm package. - Idempotent: true, - SupportsJSON: true, SafeForAutomation: true, + // `dhq mcp --json` prints hosted-server setup, but bare `dhq mcp` with + // piped stdin starts the deprecated local stdio server and blocks, and + // --local is long-running and can prompt to install the npm package. + // Not safe for automation unless the caller knows to pass --json. + SupportsJSON: true, SafeForAutomation: false, }, // Configuration resources (all non-interactive CRUD) diff --git a/internal/commands/agent_metadata_test.go b/internal/commands/agent_metadata_test.go index 542b9c4..224bebe 100644 --- a/internal/commands/agent_metadata_test.go +++ b/internal/commands/agent_metadata_test.go @@ -176,10 +176,10 @@ func TestNonInteractive_ConfigureFails(t *testing.T) { assert.Contains(t, err.Error(), "interactive-only") } -func TestAgentMetadata_MCPSetupIsJSONSafe(t *testing.T) { +func TestAgentMetadata_MCPJSONButNotAutomationSafe(t *testing.T) { m := lookupAgentMetadata("dhq mcp") assert.True(t, m.SupportsJSON) - assert.True(t, m.SafeForAutomation) + assert.False(t, m.SafeForAutomation, "bare `dhq mcp` with piped stdin starts the blocking local server") assert.False(t, m.Interactive) } From 7c656d8fb6e2c372bd5f14484672f5167a85ad13 Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Fri, 30 Oct 2026 16:29:59 +0100 Subject: [PATCH 3/3] fix(mcp): only wrap hosted setup in JSON for an explicit --json WantsJSON() auto-switches on any non-TTY stdout, so `dhq mcp > mcp.json` wrote the {url, claude_code, config} wrapper instead of a config a client can read. Gate the wrapper on --json; redirected output stays the bare mcpServers snippet. Co-Authored-By: Claude Sonnet 5.5 --- internal/commands/mcp.go | 5 ++++- internal/commands/mcp_test.go | 12 ++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/internal/commands/mcp.go b/internal/commands/mcp.go index 7103e28..3c6674a 100644 --- a/internal/commands/mcp.go +++ b/internal/commands/mcp.go @@ -83,7 +83,10 @@ func decideMCPMode(local, stdinPiped, explicitJSON bool) mcpMode { // JSON config snippet, or the structured --json object); the guidance is // human text and goes to stderr. func printHostedMCPSetup(env *output.Envelope) error { - if env.WantsJSON() { + // Only an explicit --json gets the wrapper object. A redirected stdout + // (`dhq mcp > mcp.json`) must stay the bare mcpServers config, which is + // what a client reads; WantsJSON() alone would auto-switch on any pipe. + if env.JSONMode && env.WantsJSON() { return env.WriteJSON(map[string]interface{}{ "url": hostedMCPURL, "claude_code": claudeCodeMCPCommand(), diff --git a/internal/commands/mcp_test.go b/internal/commands/mcp_test.go index 7beaadc..012ac0a 100644 --- a/internal/commands/mcp_test.go +++ b/internal/commands/mcp_test.go @@ -152,3 +152,15 @@ func TestPrintHostedMCPSetup_JSON(t *testing.T) { t.Errorf("JSON mode should not write guidance to stderr, got: %s", stderr.String()) } } + +func TestPrintHostedMCPSetup_RedirectedStdoutIsBareConfig(t *testing.T) { + var stdout, stderr bytes.Buffer + // stdout redirected to a file (non-TTY) without an explicit --json + env := &output.Envelope{Stdout: &stdout, Stderr: &stderr, IsTTY: false} + if err := printHostedMCPSetup(env); err != nil { + t.Fatal(err) + } + if got := strings.TrimSpace(stdout.String()); got != MCPConfigSnippet() { + t.Fatalf("redirected stdout should be the bare mcpServers config, got:\n%s", got) + } +}