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
15 changes: 11 additions & 4 deletions cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,7 @@ func NewCmdRoot(streams genericclioptions.IOStreams) *cobra.Command {
os.Exit(1)
}

// Checks the skipVersionCheck flag and the command being run to determine if the version check should run
if shouldRunVersionCheck(skipVersionCheck, cmd.Use) {
if !skipVersionCheck && !shouldSkipVersionCheckForCmd(cmd) {
versionCheck()
}
},
Expand Down Expand Up @@ -140,13 +139,21 @@ func shouldRunVersionCheck(skipVersionCheckFlag bool, commandName string) bool {
}

func canCommandSkipVersionCheck(commandName string) bool {
// Checks if the specific command is in the allowlist
return slice.ContainsString(getSkipVersionCommands(), commandName, nil)
}

func shouldSkipVersionCheckForCmd(cmd *cobra.Command) bool {
for c := cmd; c != nil; c = c.Parent() {
if canCommandSkipVersionCheck(c.Use) {
return true
}
}
return false
}

// Returns allowlist of commands that can skip version check
func getSkipVersionCommands() []string {
return []string{"upgrade", "version"}
return []string{"upgrade", "version", "mcp"}
}

func versionCheck() {
Expand Down
132 changes: 132 additions & 0 deletions cmd/rhobs/mcpCmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package rhobs

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"time"

"github.com/modelcontextprotocol/go-sdk/mcp"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)

func checkVaultToken(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()

cmd := exec.CommandContext(ctx, "vault", "token", "lookup")
cmd.Env = append(os.Environ(), "VAULT_ADDR=https://vault.devshift.net")
cmd.Stdout = nil
cmd.Stderr = nil
if err := cmd.Run(); err != nil {
if errors.Is(err, exec.ErrNotFound) {
return fmt.Errorf("vault CLI not found in PATH; install Vault and retry")
}
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return fmt.Errorf("vault token lookup timed out; verify VAULT_ADDR and network connectivity")
}
return fmt.Errorf("vault token expired or missing, run: VAULT_ADDR=https://vault.devshift.net vault login -method=oidc")
}
return nil
}

func newCmdMcp() *cobra.Command {
cmd := &cobra.Command{
Use: "mcp",
Short: "RHOBS MCP server for AI agent integration",
Long: `MCP (Model Context Protocol) server that exposes RHOBS metrics, logs,
and alerts querying as tools for AI agents.

Compatible with any MCP client (Claude Code, Cursor, Windsurf, custom agents).

Subcommands:
server Start the stdio MCP server
config Print MCP client configuration JSON

Quick start:
claude --mcp-config "$(osdctl rhobs mcp config)"

Prerequisites:
- OCM login: ocm login --use-auth-code --url <environment>
- Vault login: VAULT_ADDR=https://vault.devshift.net vault login -method=oidc
- osdctl config: ~/.config/osdctl must have rhobs_<env>_vault_path entries`,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
return nil
},
}

cmd.AddCommand(newCmdMcpServer())
cmd.AddCommand(newCmdMcpConfig())

return cmd
}

func newCmdMcpServer() *cobra.Command {
return &cobra.Command{
Use: "server",
Short: "Start the RHOBS MCP server",
Args: cobra.NoArgs,
SilenceErrors: true,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
log.SetOutput(io.Discard)

if err := checkVaultToken(cmd.Context()); err != nil {
return err
}

server := mcp.NewServer(&mcp.Implementation{
Name: "osdctl-rhobs",
Version: "1.0.0",
}, nil)

registerMcpTools(server)

return server.Run(cmd.Context(), &mcp.StdioTransport{})
},
}
}

func newCmdMcpConfig() *cobra.Command {
return &cobra.Command{
Use: "config",
Short: "Print MCP client configuration JSON",
Long: `Print MCP client configuration JSON for use with AI agents.

Usage with Claude Code:
claude --mcp-config "$(osdctl rhobs mcp config)"

Or add to ~/.claude/mcp_settings.json manually.`,
Args: cobra.NoArgs,
SilenceErrors: true,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
execPath, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to determine osdctl binary path: %v", err)
}

config := map[string]interface{}{
"mcpServers": map[string]interface{}{
"osdctl-rhobs": map[string]interface{}{
"command": execPath,
"args": []string{"rhobs", "mcp", "server"},
},
},
}

output, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config: %v", err)
}

fmt.Println(string(output))
return nil
},
}
}
57 changes: 57 additions & 0 deletions cmd/rhobs/mcp_helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package rhobs

import (
"encoding/json"
"fmt"
"sync"

"github.com/modelcontextprotocol/go-sdk/mcp"
"golang.org/x/sync/singleflight"
)

var fetcherCache sync.Map
var fetcherInit singleflight.Group

func getCachedFetcher(clusterId string, usage RhobsFetchUsage) (*RhobsFetcher, error) {
key := fmt.Sprintf("%s:%s", clusterId, usage)
if cached, ok := fetcherCache.Load(key); ok {
return cached.(*RhobsFetcher), nil
}

v, err, _ := fetcherInit.Do(key, func() (interface{}, error) {
if cached, ok := fetcherCache.Load(key); ok {
return cached, nil
}
fetcher, err := CreateRhobsFetcher(clusterId, usage, commonOptions.hiveOcmUrl)
if err != nil {
return nil, err
}
actual, _ := fetcherCache.LoadOrStore(key, fetcher)
return actual, nil
})
if err != nil {
return nil, err
}
return v.(*RhobsFetcher), nil
}

func mcpResultJSON(data interface{}) (*mcp.CallToolResult, error) {
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal result: %v", err)
}
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: string(jsonData)}},
}, nil
}

func mcpError(format string, args ...interface{}) (*mcp.CallToolResult, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf(format, args...)}},
IsError: true,
}, nil
}

func boolPtr(b bool) *bool {
return &b
}
Loading