Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ 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 (still not safe for automation, since bare
`dhq mcp` with piped stdin starts the local server).

## [0.22.0] - 2026-10-28

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions internal/commands/agent_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,11 @@ var commandMetadataTable = map[string]AgentMetadata{
SupportsJSON: true, SafeForAutomation: true,
},
"dhq mcp": {
Interactive: true,
SupportsJSON: false, SafeForAutomation: false,
// `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)
Expand Down
7 changes: 4 additions & 3 deletions internal/commands/agent_metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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_MCPJSONButNotAutomationSafe(t *testing.T) {
m := lookupAgentMetadata("dhq mcp")
assert.True(t, m.Interactive)
assert.False(t, m.SafeForAutomation)
assert.True(t, m.SupportsJSON)
assert.False(t, m.SafeForAutomation, "bare `dhq mcp` with piped stdin starts the blocking local server")
assert.False(t, m.Interactive)
}

func TestAgentMetadata_AuthLogout(t *testing.T) {
Expand Down
238 changes: 165 additions & 73 deletions internal/commands/mcp.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package commands

import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
Expand All @@ -17,87 +19,176 @@ 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 {
// 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(),
"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
Expand Down Expand Up @@ -162,14 +253,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 + `"
}
}
}`
Expand Down
Loading
Loading