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
2 changes: 1 addition & 1 deletion desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ See [TRIAGE.md](./TRIAGE.md) for full documentation.
### ✨ Routines
- **Project Routine Library**: Load built-ins plus `.boatman/routines.json` and `.boatman/routines/*.json` from the active project
- **Datadog GraphQL Slow Queries**: Run the saved slow-query investigation from the desktop app
- **Datadog MCP Auth**: Open Claude Code's Datadog MCP auth flow in an interactive terminal from the Routines tab
- **Datadog MCP Auth**: Open Claude Code's Datadog MCP auth flow in an interactive terminal from the Routines tab, creating `boatman-datadog-mcp` on Datadog's current `/v1/mcp` endpoint when needed
- **Dry-Run Checks**: Verify Datadog readiness and prompt shape before model execution
- **Markdown Reports**: Save daily investigation output under project-local `.boatman/routines`

Expand Down
4 changes: 2 additions & 2 deletions desktop/frontend/src/components/routines/RoutineView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ describe('RoutineView', () => {
vi.mocked(ListProjectRoutines).mockResolvedValue([routine] as any);
vi.mocked(ListRoutines).mockResolvedValue([routine] as any);
vi.mocked(AuthenticateDatadogMCP).mockResolvedValue({
mcpName: 'plugin:datadog:datadog-mcp',
command: 'claude mcp login plugin:datadog:datadog-mcp',
mcpName: 'boatman-datadog-mcp',
command: 'claude mcp add --transport http --scope user boatman-datadog-mcp https://mcp.datadoghq.com/v1/mcp?toolsets=core,llmobs,alerting,apm,onboarding && claude mcp login boatman-datadog-mcp',
message: 'Datadog MCP auth opened in an interactive terminal. Complete the browser flow, then click Check.',
interactive: true,
launched: true,
Expand Down
4 changes: 2 additions & 2 deletions desktop/frontend/src/test/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ vi.mock('../../wailsjs/go/main/App', () => ({
ListMemoryDocuments: vi.fn().mockResolvedValue([]),
GetMemoryDocument: vi.fn().mockResolvedValue(null),
AuthenticateDatadogMCP: vi.fn().mockResolvedValue({
mcpName: 'plugin:datadog:datadog-mcp',
command: 'claude mcp login plugin:datadog:datadog-mcp',
mcpName: 'boatman-datadog-mcp',
command: 'claude mcp add --transport http --scope user boatman-datadog-mcp https://mcp.datadoghq.com/v1/mcp?toolsets=core,llmobs,alerting,apm,onboarding && claude mcp login boatman-datadog-mcp',
message: 'Datadog MCP auth opened in an interactive terminal. Complete the browser flow, then click Check.',
interactive: true,
launched: true,
Expand Down
150 changes: 137 additions & 13 deletions desktop/routines.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,33 @@ import (
"github.com/philjestin/boatman-ecosystem/shared/agentruntime/runstore"
)

const datadogClaudeMCPName = "plugin:datadog:datadog-mcp"
const (
datadogClaudeMCPName = "plugin:datadog:datadog-mcp"
datadogBoatmanMCPName = "boatman-datadog-mcp"
datadogManualMCPName = "datadog-mcp"
datadogLegacyRemotePath = "/api/unstable/mcp-server/mcp"
datadogRoutineToolsetsQuery = "toolsets=core,llmobs,alerting,apm,onboarding"
)

type claudeMCPLoginTarget struct {
Name string
URL string
EnsureHTTP bool
}

var runClaudeMCPCommand = func(ctx context.Context, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, "claude", args...)
out, err := cmd.CombinedOutput()
return string(out), err
}

var launchClaudeMCPLogin = func(ctx context.Context, mcpName string) (string, error) {
command := terminalDatadogMCPLoginCommand(mcpName)
var launchClaudeMCPLogin = func(ctx context.Context, target claudeMCPLoginTarget) (string, error) {
command := terminalDatadogMCPLoginCommand(target)
switch goruntime.GOOS {
case "darwin":
return launchMacTerminal(ctx, command)
case "windows":
return launchWindowsTerminal(ctx, "claude mcp login "+mcpName)
return launchWindowsTerminal(ctx, terminalWindowsDatadogMCPLoginCommand(target))
default:
return launchLinuxTerminal(ctx, command)
}
Expand Down Expand Up @@ -150,12 +162,13 @@ func (a *App) AuthenticateDatadogMCP() (*DatadogMCPAuthResult, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

target := a.datadogMCPLoginTarget(ctx)
result := &DatadogMCPAuthResult{
MCPName: datadogClaudeMCPName,
Command: "claude mcp login " + datadogClaudeMCPName,
MCPName: target.Name,
Command: datadogMCPLoginCommandPreview(target),
Interactive: true,
}
output, err := launchClaudeMCPLogin(ctx, datadogClaudeMCPName)
output, err := launchClaudeMCPLogin(ctx, target)
result.Output = strings.TrimSpace(output)
if err != nil {
return result, fmt.Errorf("failed to open interactive Datadog MCP authentication: %w%s", err, commandOutputSuffix(output))
Expand Down Expand Up @@ -361,7 +374,12 @@ func desktopRoutineIntegrationRefs(ctx context.Context, routine routines.Routine
if dryRun {
continue
}
return statuses, nil, nil, fmt.Errorf("routine %q Datadog MCP is not authenticated; click Authenticate in the Routines tab or run %q", routine.ID, "claude mcp login "+datadogClaudeMCPName)
target := claudeMCPLoginTarget{
Name: datadogBoatmanMCPName,
URL: datadogMCPURLForSite(prefs.DatadogSite),
EnsureHTTP: true,
}
return statuses, nil, nil, fmt.Errorf("routine %q Datadog MCP is not authenticated; click Authenticate in the Routines tab or run %q", routine.ID, datadogMCPLoginCommandPreview(target))
}
}
env := desktopEnvForIntegration(item, prefs)
Expand Down Expand Up @@ -412,8 +430,51 @@ func desktopHasDatadogAPIKeys(prefs config.UserPreferences) bool {
return apiKey != "" && appKey != ""
}

func (a *App) datadogMCPLoginTarget(ctx context.Context) claudeMCPLoginTarget {
for _, name := range []string{datadogBoatmanMCPName, datadogManualMCPName} {
if status, ok := claudeMCPStatus(ctx, name); ok && !isLegacyDatadogMCP(status) {
return claudeMCPLoginTarget{Name: name}
}
}
if status, ok := claudeMCPStatus(ctx, datadogClaudeMCPName); ok && !isLegacyDatadogMCP(status) {
return claudeMCPLoginTarget{Name: datadogClaudeMCPName}
}
return claudeMCPLoginTarget{
Name: datadogBoatmanMCPName,
URL: datadogMCPURLForSite(a.datadogSitePreference()),
EnsureHTTP: true,
}
}

func (a *App) datadogSitePreference() string {
if a != nil && a.config != nil {
if site := strings.TrimSpace(a.config.GetPreferences().DatadogSite); site != "" {
return site
}
}
if site := strings.TrimSpace(os.Getenv("DD_SITE")); site != "" {
return site
}
return "datadoghq.com"
}

func claudeManagedDatadogStatus(ctx context.Context) (mcp.IntegrationStatus, bool) {
output, err := runClaudeMCPCommand(ctx, "mcp", "get", datadogClaudeMCPName)
for _, name := range []string{datadogBoatmanMCPName, datadogManualMCPName, datadogClaudeMCPName} {
status, ok := claudeMCPStatus(ctx, name)
if !ok {
continue
}
if isLegacyDatadogMCP(status) && status.State != integrations.StateConnected {
status.Message = "Datadog plugin MCP uses a legacy remote endpoint; click Authenticate to add Boatman's current Datadog MCP server"
status.Metadata["fallback_mcp_name"] = datadogBoatmanMCPName
}
return status, true
}
return mcp.IntegrationStatus{}, false
}

func claudeMCPStatus(ctx context.Context, name string) (mcp.IntegrationStatus, bool) {
output, err := runClaudeMCPCommand(ctx, "mcp", "get", name)
text := strings.TrimSpace(output)
lower := strings.ToLower(text)
if err != nil && strings.Contains(lower, "no mcp server") {
Expand All @@ -429,9 +490,12 @@ func claudeManagedDatadogStatus(ctx context.Context) (mcp.IntegrationStatus, boo
LastChecked: time.Now().UTC(),
Metadata: map[string]string{
"auth_method": "claude_mcp",
"mcp_name": datadogClaudeMCPName,
"mcp_name": name,
},
}
if url := claudeMCPURL(text); url != "" {
status.Metadata["url"] = url
}
if strings.Contains(lower, "status:") && (strings.Contains(lower, "connected") || strings.Contains(lower, "✓") || strings.Contains(lower, "✔")) {
status.State = integrations.StateConnected
status.Message = "Datadog MCP is authenticated through Claude Code"
Expand All @@ -451,12 +515,72 @@ func claudeManagedDatadogStatus(ctx context.Context) (mcp.IntegrationStatus, boo
return status, true
}

func terminalDatadogMCPLoginCommand(mcpName string) string {
login := "claude mcp login " + shellQuote(mcpName)
func isLegacyDatadogMCP(status mcp.IntegrationStatus) bool {
return strings.Contains(status.Metadata["url"], datadogLegacyRemotePath)
}

func claudeMCPURL(output string) string {
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(strings.ToLower(line), "url:") {
return strings.TrimSpace(line[len("url:"):])
}
}
return ""
}

func datadogMCPURLForSite(site string) string {
host := strings.ToLower(strings.TrimSpace(site))
host = strings.TrimPrefix(host, "https://")
host = strings.TrimPrefix(host, "http://")
host = strings.TrimPrefix(host, "app.")
host = strings.TrimSuffix(host, "/")
switch host {
case "", "us1", "datadoghq.com":
host = "datadoghq.com"
case "us3", "us3.datadoghq.com":
host = "us3.datadoghq.com"
case "us5", "us5.datadoghq.com":
host = "us5.datadoghq.com"
case "eu", "datadoghq.eu":
host = "datadoghq.eu"
case "ap1", "ap1.datadoghq.com":
host = "ap1.datadoghq.com"
case "ap2", "ap2.datadoghq.com":
host = "ap2.datadoghq.com"
case "fed", "us1-fed", "ddog-gov.com":
host = "ddog-gov.com"
}
if host == "datadoghq.com" {
return "https://mcp.datadoghq.com/v1/mcp?" + datadogRoutineToolsetsQuery
}
return "https://mcp." + host + "/v1/mcp?" + datadogRoutineToolsetsQuery
}

func datadogMCPLoginCommandPreview(target claudeMCPLoginTarget) string {
if !target.EnsureHTTP {
return "claude mcp login " + target.Name
}
return "claude mcp add --transport http --scope user " + target.Name + " " + target.URL + " && claude mcp login " + target.Name
}

func terminalDatadogMCPLoginCommand(target claudeMCPLoginTarget) string {
ensure := ""
if target.EnsureHTTP {
ensure = fmt.Sprintf("if ! claude mcp get %s >/dev/null 2>&1; then claude mcp add --transport http --scope user %s %s; fi; ", shellQuote(target.Name), shellQuote(target.Name), shellQuote(target.URL))
}
login := "claude mcp login " + shellQuote(target.Name)
success := shellQuote("Boatman: Datadog MCP auth finished. Return to Boatman and click Check.")
failure := shellQuote("Boatman: Datadog MCP auth did not finish. Fix the issue above, then run Authenticate again.")
keepOpen := shellQuote("Boatman: this terminal will stay open so you can inspect the auth output.")
return fmt.Sprintf("%s; status=$?; echo; if [ $status -eq 0 ]; then echo %s; else echo %s; fi; echo %s; exec \"${SHELL:-/bin/sh}\" -l", login, success, failure, keepOpen)
return fmt.Sprintf("%s%s; status=$?; echo; if [ $status -eq 0 ]; then echo %s; else echo %s; fi; echo %s; exec \"${SHELL:-/bin/sh}\" -l", ensure, login, success, failure, keepOpen)
}

func terminalWindowsDatadogMCPLoginCommand(target claudeMCPLoginTarget) string {
if !target.EnsureHTTP {
return "claude mcp login " + target.Name
}
return "claude mcp get " + target.Name + " >NUL 2>NUL || claude mcp add --transport http --scope user " + target.Name + " " + target.URL + " && claude mcp login " + target.Name
}

func launchMacTerminal(ctx context.Context, command string) (string, error) {
Expand Down
69 changes: 62 additions & 7 deletions desktop/routines_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"testing"

"boatman/config"
Expand Down Expand Up @@ -50,6 +51,9 @@ func TestDesktopRoutineIntegrationRefsUsesClaudeManagedDatadog(t *testing.T) {
t.Setenv("DD_APP_KEY", "")
t.Setenv("DD_SITE", "")
withClaudeMCPCommand(t, func(_ context.Context, args ...string) (string, error) {
if len(args) == 3 && args[0] == "mcp" && args[1] == "get" && args[2] != datadogClaudeMCPName {
return `No MCP server named "` + args[2] + `". Configured servers:`, errors.New("not found")
}
if len(args) == 3 && args[0] == "mcp" && args[1] == "get" && args[2] == datadogClaudeMCPName {
return `plugin:datadog:datadog-mcp:
Scope: Dynamic config (from command line)
Expand Down Expand Up @@ -80,24 +84,75 @@ func TestDesktopRoutineIntegrationRefsUsesClaudeManagedDatadog(t *testing.T) {
}

func TestAuthenticateDatadogMCPOpensInteractiveLogin(t *testing.T) {
var gotName string
withClaudeMCPLoginLauncher(t, func(_ context.Context, mcpName string) (string, error) {
gotName = mcpName
t.Setenv("DD_SITE", "")
withClaudeMCPCommand(t, func(_ context.Context, args ...string) (string, error) {
if len(args) == 3 && args[0] == "mcp" && args[1] == "get" {
return `No MCP server named "` + args[2] + `". Configured servers:`, errors.New("not found")
}
return "", errors.New("unexpected command")
})

var gotTarget claudeMCPLoginTarget
withClaudeMCPLoginLauncher(t, func(_ context.Context, target claudeMCPLoginTarget) (string, error) {
gotTarget = target
return "opened", nil
})

result, err := (&App{}).AuthenticateDatadogMCP()
if err != nil {
t.Fatalf("AuthenticateDatadogMCP error: %v", err)
}
if gotName != datadogClaudeMCPName {
t.Fatalf("mcpName = %q, want %q", gotName, datadogClaudeMCPName)
if gotTarget.Name != datadogBoatmanMCPName || !gotTarget.EnsureHTTP {
t.Fatalf("target = %#v, want Boatman-managed HTTP Datadog MCP", gotTarget)
}
if result.MCPName != datadogClaudeMCPName || result.Output != "opened" || !result.Interactive || !result.Launched {
if !strings.Contains(gotTarget.URL, "https://mcp.datadoghq.com/v1/mcp") {
t.Fatalf("target URL = %q, want current Datadog MCP endpoint", gotTarget.URL)
}
if result.MCPName != datadogBoatmanMCPName || result.Output != "opened" || !result.Interactive || !result.Launched {
t.Fatalf("result = %#v", result)
}
}

func TestAuthenticateDatadogMCPFallsBackFromLegacyPluginEndpoint(t *testing.T) {
withClaudeMCPCommand(t, func(_ context.Context, args ...string) (string, error) {
if len(args) != 3 || args[0] != "mcp" || args[1] != "get" {
return "", errors.New("unexpected command")
}
switch args[2] {
case datadogBoatmanMCPName, datadogManualMCPName:
return `No MCP server named "` + args[2] + `". Configured servers:`, errors.New("not found")
case datadogClaudeMCPName:
return `plugin:datadog:datadog-mcp:
Scope: Dynamic config (from command line)
Status: ✘ Failed to connect
Type: http
URL: https://mcp.datadoghq.com/api/unstable/mcp-server/mcp?toolsets=core,llmobs,alerting,apm,onboarding`, errors.New("failed to connect")
default:
return "", errors.New("unexpected mcp name")
}
})

var gotTarget claudeMCPLoginTarget
withClaudeMCPLoginLauncher(t, func(_ context.Context, target claudeMCPLoginTarget) (string, error) {
gotTarget = target
return "opened", nil
})

result, err := (&App{}).AuthenticateDatadogMCP()
if err != nil {
t.Fatalf("AuthenticateDatadogMCP error: %v", err)
}
if gotTarget.Name != datadogBoatmanMCPName || !gotTarget.EnsureHTTP {
t.Fatalf("target = %#v, want Boatman-managed fallback", gotTarget)
}
if strings.Contains(gotTarget.URL, datadogLegacyRemotePath) || !strings.Contains(gotTarget.URL, "/v1/mcp") {
t.Fatalf("target URL = %q, want non-legacy Datadog MCP endpoint", gotTarget.URL)
}
if result.MCPName != datadogBoatmanMCPName {
t.Fatalf("result MCPName = %q, want %q", result.MCPName, datadogBoatmanMCPName)
}
}

func TestListProjectRoutinesLoadsProjectDefinitions(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, ".boatman"), 0755); err != nil {
Expand Down Expand Up @@ -181,7 +236,7 @@ func withClaudeMCPCommand(t *testing.T, fn func(context.Context, ...string) (str
})
}

func withClaudeMCPLoginLauncher(t *testing.T, fn func(context.Context, string) (string, error)) {
func withClaudeMCPLoginLauncher(t *testing.T, fn func(context.Context, claudeMCPLoginTarget) (string, error)) {
t.Helper()
original := launchClaudeMCPLogin
launchClaudeMCPLogin = fn
Expand Down
2 changes: 1 addition & 1 deletion docs/pages/desktop/getting-started.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@

6. **Run Repeatable Routines**
- Open the **Routines** tab for the active project
- Click **Authenticate** if Datadog MCP needs Claude Code auth, then finish it in the terminal window
- Click **Authenticate** if Datadog MCP needs Claude Code auth, then finish it in the terminal window. Boatman will create a current `boatman-datadog-mcp` server if the Claude plugin endpoint is stale.
- Run the Datadog GraphQL slow-query routine
- Review the report in the tab or under `.boatman/routines`

Expand Down
2 changes: 1 addition & 1 deletion docs/pages/desktop/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Boatman Desktop is a native desktop application built with Wails (Go + React) th
### Routines
- **Project Routine Library**: Load built-ins plus `.boatman/routines.json` and `.boatman/routines/*.json` from the active project
- **Datadog GraphQL Slow Queries**: Run a saved Datadog MCP investigation from the desktop app
- **Datadog MCP Auth**: Open Claude Code's Datadog MCP auth flow in an interactive terminal from the Routines tab
- **Datadog MCP Auth**: Open Claude Code's Datadog MCP auth flow in an interactive terminal from the Routines tab, with a Boatman-managed Datadog MCP fallback
- **Dry-Run Preview**: Check integration readiness and request shape before invoking a model
- **Markdown Reports**: Save routine output under project-local `.boatman/routines`

Expand Down
Loading
Loading