Skip to content

Commit 5536f0d

Browse files
committed
Dash - Add MCP Server for integration with Cursor, etc
1 parent 6d2f542 commit 5536f0d

File tree

8 files changed

+1845
-34
lines changed

8 files changed

+1845
-34
lines changed

cmd/configure.go

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,25 @@ import (
1212
)
1313

1414
type ConfigFile struct {
15-
ApiKey string `json:"CRONITOR_API_KEY"`
16-
PingApiAuthKey string `json:"CRONITOR_PING_API_KEY"`
17-
ExcludeText []string `json:"CRONITOR_EXCLUDE_TEXT,omitempty"`
18-
Hostname string `json:"CRONITOR_HOSTNAME"`
19-
Log string `json:"CRONITOR_LOG"`
20-
Env string `json:"CRONITOR_ENV"`
21-
DashUsername string `json:"CRONITOR_DASH_USER"`
22-
DashPassword string `json:"CRONITOR_DASH_PASS"`
23-
AllowedIPs string `json:"CRONITOR_ALLOWED_IPS"`
24-
CorsAllowedOrigins string `json:"CRONITOR_CORS_ALLOWED_ORIGINS"`
25-
Users string `json:"CRONITOR_USERS"`
15+
ApiKey string `json:"CRONITOR_API_KEY"`
16+
PingApiAuthKey string `json:"CRONITOR_PING_API_KEY"`
17+
ExcludeText []string `json:"CRONITOR_EXCLUDE_TEXT,omitempty"`
18+
Hostname string `json:"CRONITOR_HOSTNAME"`
19+
Log string `json:"CRONITOR_LOG"`
20+
Env string `json:"CRONITOR_ENV"`
21+
DashUsername string `json:"CRONITOR_DASH_USER"`
22+
DashPassword string `json:"CRONITOR_DASH_PASS"`
23+
AllowedIPs string `json:"CRONITOR_ALLOWED_IPS"`
24+
CorsAllowedOrigins string `json:"CRONITOR_CORS_ALLOWED_ORIGINS"`
25+
Users string `json:"CRONITOR_USERS"`
26+
MCPEnabled bool `json:"CRONITOR_MCP_ENABLED,omitempty"`
27+
MCPInstances map[string]MCPInstanceConfig `json:"mcp_instances,omitempty"`
28+
}
29+
30+
type MCPInstanceConfig struct {
31+
URL string `json:"url"`
32+
Username string `json:"username"`
33+
Password string `json:"password"`
2634
}
2735

2836
// configureCmd represents the configure command
@@ -68,6 +76,30 @@ Example setting common exclude text for use with 'cronitor discover':
6876
configData.AllowedIPs = viper.GetString(varAllowedIPs)
6977
configData.CorsAllowedOrigins = viper.GetString("CRONITOR_CORS_ALLOWED_ORIGINS")
7078
configData.Users = viper.GetString(varUsers)
79+
configData.MCPEnabled = viper.GetBool(varMCPEnabled)
80+
81+
// Load MCP instances if configured
82+
if viper.IsSet("mcp_instances") {
83+
// Get the raw config and manually convert
84+
rawInstances := viper.GetStringMap("mcp_instances")
85+
configData.MCPInstances = make(map[string]MCPInstanceConfig)
86+
87+
for name, rawConfig := range rawInstances {
88+
if configMap, ok := rawConfig.(map[string]interface{}); ok {
89+
instance := MCPInstanceConfig{}
90+
if url, ok := configMap["url"].(string); ok {
91+
instance.URL = url
92+
}
93+
if username, ok := configMap["username"].(string); ok {
94+
instance.Username = username
95+
}
96+
if password, ok := configMap["password"].(string); ok {
97+
instance.Password = password
98+
}
99+
configData.MCPInstances[name] = instance
100+
}
101+
}
102+
}
71103

72104
fmt.Println("\nConfiguration File:")
73105
fmt.Println(configFilePath())
@@ -137,6 +169,16 @@ Example setting common exclude text for use with 'cronitor discover':
137169
fmt.Println(configData.Users)
138170
}
139171

172+
fmt.Println("\nMCP Enabled:")
173+
fmt.Println(configData.MCPEnabled)
174+
175+
if len(configData.MCPInstances) > 0 {
176+
fmt.Println("\nMCP Instances:")
177+
for name, instance := range configData.MCPInstances {
178+
fmt.Printf(" %s: %s\n", name, instance.URL)
179+
}
180+
}
181+
140182
if verbose {
141183
fmt.Println("\nEnviornment Variables:")
142184
for _, pair := range os.Environ() {
@@ -181,6 +223,7 @@ func init() {
181223
configureCmd.Flags().String("log", "", "Path to debug log file")
182224
configureCmd.Flags().String("env", "", "Environment name (e.g. staging, production)")
183225
configureCmd.Flags().String("users", "", "Comma-separated list of users whose crontabs to include")
226+
configureCmd.Flags().Bool(varMCPEnabled, false, "Enable MCP instances")
184227

185228
viper.BindPFlag(varExcludeText, configureCmd.Flags().Lookup("exclude-from-name"))
186229
viper.BindPFlag(varDashUsername, configureCmd.Flags().Lookup("dash-username"))
@@ -190,4 +233,5 @@ func init() {
190233
viper.BindPFlag(varLog, configureCmd.Flags().Lookup("log"))
191234
viper.BindPFlag(varEnv, configureCmd.Flags().Lookup("env"))
192235
viper.BindPFlag(varUsers, configureCmd.Flags().Lookup("users"))
236+
viper.BindPFlag(varMCPEnabled, configureCmd.Flags().Lookup(varMCPEnabled))
193237
}

cmd/dash.go

Lines changed: 215 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030
"time"
3131

3232
"github.com/cronitorio/cronitor-cli/lib"
33+
"github.com/mark3labs/mcp-go/server"
3334
"github.com/spf13/cobra"
3435
"github.com/spf13/viper"
3536
"golang.org/x/time/rate"
@@ -591,12 +592,56 @@ func openBrowser(url string) {
591592
}
592593
}
593594

595+
// Add new configuration variables
596+
const (
597+
varMCPEnabled = "CRONITOR_MCP_ENABLED"
598+
varMCPMode = "CRONITOR_MCP_MODE"
599+
varMCPInstance = "CRONITOR_MCP_INSTANCE"
600+
)
601+
594602
var dashCmd = &cobra.Command{
595603
Use: "dash",
596604
Short: "Start the web dashboard",
597605
Long: `Start the Crontab Guru web dashboard.
598-
The dashboard provides a web interface for managing your cron jobs and crontab files.`,
606+
The dashboard provides a web interface for managing your cron jobs and crontab files.
607+
608+
MCP Mode:
609+
When --mcp-instance flag is used, the dashboard runs in MCP (Model Context Protocol) mode
610+
for integration with Cursor IDE or other LLM applications. In this mode, it does
611+
not start a web server but instead communicates via stdio protocol.`,
599612
Run: func(cmd *cobra.Command, args []string) {
613+
// Get MCP instance name
614+
mcpInstance, _ := cmd.Flags().GetString("mcp-instance")
615+
616+
// Check if MCP mode is enabled
617+
mcpEnabled := false
618+
619+
// If mcp-instance is provided, automatically enable MCP mode
620+
if mcpInstance != "" {
621+
mcpEnabled = true
622+
} else {
623+
// Check explicit --mcp flag for backward compatibility
624+
mcpEnabled, _ = cmd.Flags().GetBool("mcp")
625+
if !mcpEnabled {
626+
// Check environment variable
627+
mcpEnabled = viper.GetBool(varMCPEnabled)
628+
}
629+
630+
// If MCP is enabled but no instance specified, use default
631+
if mcpEnabled && mcpInstance == "" {
632+
mcpInstance = viper.GetString(varMCPInstance)
633+
if mcpInstance == "" {
634+
mcpInstance = "default"
635+
}
636+
}
637+
}
638+
639+
// If MCP mode is enabled, run MCP server instead of web dashboard
640+
if mcpEnabled {
641+
runMCPServer(mcpInstance)
642+
return
643+
}
644+
600645
// Check for dashboard credentials before starting the server
601646
username := viper.GetString(varDashUsername)
602647
password := viper.GetString(varDashPassword)
@@ -938,6 +983,34 @@ The dashboard provides a web interface for managing your cron jobs and crontab f
938983
},
939984
}
940985

986+
// runMCPServer starts the MCP server for Cursor integration
987+
func runMCPServer(instanceName string) {
988+
// Create MCP server
989+
s := server.NewMCPServer(
990+
fmt.Sprintf("Cronitor Dashboard (%s)", instanceName),
991+
Version,
992+
server.WithToolCapabilities(false),
993+
)
994+
995+
// Create and register the Cronitor MCP handler
996+
handler := lib.NewCronitorMCPHandler(instanceName)
997+
998+
// Register tools
999+
if err := handler.RegisterTools(s); err != nil {
1000+
fatal(fmt.Sprintf("Failed to register MCP tools: %v", err), 1)
1001+
}
1002+
1003+
// Register resources
1004+
if err := handler.RegisterResources(s); err != nil {
1005+
fatal(fmt.Sprintf("Failed to register MCP resources: %v", err), 1)
1006+
}
1007+
1008+
// Start the stdio server
1009+
if err := server.ServeStdio(s); err != nil {
1010+
fatal(fmt.Sprintf("MCP server error: %v", err), 1)
1011+
}
1012+
}
1013+
9411014
// Helper function to slugify a string for filenames
9421015
func slugify(s string) string {
9431016
// Convert to lowercase
@@ -980,22 +1053,30 @@ func init() {
9801053
RootCmd.AddCommand(dashCmd)
9811054
dashCmd.Flags().Int("port", 9000, "Port to run the dashboard on")
9821055
dashCmd.Flags().Bool("safe-mode", false, "Limit the ability to edit jobs, crontabs, and settings")
1056+
dashCmd.Flags().Bool("mcp", false, "Enable MCP server mode for Cursor integration (deprecated: use --mcp-instance instead)")
1057+
dashCmd.Flags().String("mcp-instance", "", "MCP instance name to connect to (automatically enables MCP mode)")
1058+
1059+
// Bind MCP flags to viper
1060+
viper.BindPFlag(varMCPEnabled, dashCmd.Flags().Lookup("mcp"))
1061+
viper.BindPFlag(varMCPInstance, dashCmd.Flags().Lookup("mcp-instance"))
9831062
}
9841063

9851064
type SettingsResponse struct {
9861065
ConfigFile
987-
EnvVars map[string]bool `json:"env_vars"`
988-
ConfigFilePath string `json:"config_file_path"`
989-
Version string `json:"version"`
990-
Hostname string `json:"hostname"`
991-
Timezone string `json:"timezone"`
992-
Timezones []string `json:"timezones"`
993-
OS string `json:"os"`
994-
SafeMode bool `json:"safe_mode"`
995-
UpdateStatus *UpdateStatus `json:"update_status,omitempty"`
996-
AllowedIPs string `json:"allowed_ips"`
997-
CorsAllowedOrigins string `json:"cors_allowed_origins"`
998-
ClientIP string `json:"client_ip"`
1066+
EnvVars map[string]bool `json:"env_vars"`
1067+
ConfigFilePath string `json:"config_file_path"`
1068+
Version string `json:"version"`
1069+
Hostname string `json:"hostname"`
1070+
Timezone string `json:"timezone"`
1071+
Timezones []string `json:"timezones"`
1072+
OS string `json:"os"`
1073+
SafeMode bool `json:"safe_mode"`
1074+
UpdateStatus *UpdateStatus `json:"update_status,omitempty"`
1075+
AllowedIPs string `json:"allowed_ips"`
1076+
CorsAllowedOrigins string `json:"cors_allowed_origins"`
1077+
ClientIP string `json:"client_ip"`
1078+
MCPEnabled bool `json:"mcp_enabled"`
1079+
MCPInstances map[string]MCPInstanceConfig `json:"mcp_instances,omitempty"`
9991080
}
10001081

10011082
// handleSettings handles GET and POST requests for settings
@@ -1120,13 +1201,16 @@ func handleSettings(w http.ResponseWriter, r *http.Request) {
11201201
"CRONITOR_ALLOWED_IPS": os.Getenv(varAllowedIPs) != "",
11211202
"CRONITOR_CORS_ALLOWED_ORIGINS": os.Getenv("CRONITOR_CORS_ALLOWED_ORIGINS") != "",
11221203
"CRONITOR_USERS": os.Getenv(varUsers) != "",
1204+
"CRONITOR_MCP_ENABLED": os.Getenv(varMCPEnabled) != "",
11231205
},
11241206
OS: runtime.GOOS,
11251207
SafeMode: isSafeModeEnabled,
11261208
UpdateStatus: updateStatus,
11271209
AllowedIPs: os.Getenv(varAllowedIPs),
11281210
CorsAllowedOrigins: os.Getenv("CRONITOR_CORS_ALLOWED_ORIGINS"),
11291211
ClientIP: getClientIP(r),
1212+
MCPEnabled: configData.MCPEnabled,
1213+
MCPInstances: configData.MCPInstances,
11301214
}
11311215

11321216
// Override config values with environment variables if they exist
@@ -1205,6 +1289,16 @@ func handleSettings(w http.ResponseWriter, r *http.Request) {
12051289
}
12061290
}
12071291

1292+
// Handle MCP settings
1293+
if os.Getenv(varMCPEnabled) == "" {
1294+
viper.Set(varMCPEnabled, configData.MCPEnabled)
1295+
}
1296+
1297+
// Always update MCP instances configuration
1298+
if configData.MCPInstances != nil {
1299+
viper.Set("mcp_instances", configData.MCPInstances)
1300+
}
1301+
12081302
// Marshal the config data
12091303
b, err := json.MarshalIndent(configData, "", " ")
12101304
if err != nil {
@@ -2788,6 +2882,114 @@ func performUpdateWithRestart(status *UpdateStatus, sendProgress func(string)) e
27882882
// Give the UI a moment to receive the message
27892883
time.Sleep(1 * time.Second)
27902884

2885+
// Check if we're running under systemd
2886+
if isRunningUnderSystemd() {
2887+
sendProgress("Detected systemd service, requesting service restart...")
2888+
2889+
// When running under systemd, we should let systemd handle the restart
2890+
// Signal systemd to restart the service
2891+
if err := requestSystemdRestart(); err != nil {
2892+
// If systemd restart fails, fall back to manual restart
2893+
log(fmt.Sprintf("Failed to request systemd restart, falling back to manual restart: %v", err))
2894+
return performManualRestart(sendProgress)
2895+
}
2896+
2897+
sendProgress("Update complete! Service restarting via systemd...")
2898+
2899+
// Give a brief moment for the response to be sent, then exit gracefully
2900+
go func() {
2901+
time.Sleep(500 * time.Millisecond)
2902+
os.Exit(0)
2903+
}()
2904+
2905+
return nil
2906+
}
2907+
2908+
// For non-systemd environments, use manual restart
2909+
return performManualRestart(sendProgress)
2910+
}
2911+
2912+
// isRunningUnderSystemd checks if the current process is running under systemd
2913+
func isRunningUnderSystemd() bool {
2914+
// Check if NOTIFY_SOCKET environment variable is set (systemd sets this)
2915+
if os.Getenv("NOTIFY_SOCKET") != "" {
2916+
return true
2917+
}
2918+
2919+
// Check if we're running as PID 1's direct child and systemd is PID 1
2920+
ppid := os.Getppid()
2921+
if ppid == 1 {
2922+
// Check if PID 1 is systemd
2923+
if cmdline, err := os.ReadFile("/proc/1/cmdline"); err == nil {
2924+
return strings.Contains(string(cmdline), "systemd")
2925+
}
2926+
}
2927+
2928+
// Check if systemd journal is available
2929+
if _, err := os.Stat("/run/systemd/journal"); err == nil {
2930+
// Also check if we have systemd process manager
2931+
if _, err := os.Stat("/run/systemd/system"); err == nil {
2932+
return true
2933+
}
2934+
}
2935+
2936+
return false
2937+
}
2938+
2939+
// requestSystemdRestart attempts to restart the service via systemd
2940+
func requestSystemdRestart() error {
2941+
// Try to determine the service name by looking at the process
2942+
serviceName := getSystemdServiceName()
2943+
if serviceName == "" {
2944+
return fmt.Errorf("could not determine systemd service name")
2945+
}
2946+
2947+
// Use systemctl to restart the service
2948+
cmd := exec.Command("systemctl", "restart", serviceName)
2949+
if err := cmd.Run(); err != nil {
2950+
return fmt.Errorf("failed to restart systemd service %s: %v", serviceName, err)
2951+
}
2952+
2953+
return nil
2954+
}
2955+
2956+
// getSystemdServiceName attempts to determine the systemd service name
2957+
func getSystemdServiceName() string {
2958+
// Try to get service name from systemd environment
2959+
if serviceName := os.Getenv("SYSTEMD_SERVICE"); serviceName != "" {
2960+
return serviceName
2961+
}
2962+
2963+
// Try common service names based on executable name
2964+
executable, err := os.Executable()
2965+
if err != nil {
2966+
return ""
2967+
}
2968+
2969+
baseName := strings.TrimSuffix(filepath.Base(executable), filepath.Ext(executable))
2970+
2971+
// Common patterns for systemd service names
2972+
possibleNames := []string{
2973+
baseName + ".service",
2974+
baseName + "-dashboard.service",
2975+
"crontab-guru-dashboard.service", // User mentioned this specific name
2976+
"cronitor-cli.service",
2977+
"cronitor.service",
2978+
}
2979+
2980+
// Check which service exists and is active
2981+
for _, name := range possibleNames {
2982+
cmd := exec.Command("systemctl", "is-active", "--quiet", name)
2983+
if err := cmd.Run(); err == nil {
2984+
return name
2985+
}
2986+
}
2987+
2988+
return ""
2989+
}
2990+
2991+
// performManualRestart performs a manual restart (non-systemd)
2992+
func performManualRestart(sendProgress func(string)) error {
27912993
// Get current executable path and arguments
27922994
executable, err := os.Executable()
27932995
if err != nil {

0 commit comments

Comments
 (0)