Skip to content

Commit d984560

Browse files
authored
feat: add service control commands and enhance logging capabilities (#75)
1 parent 5d5471f commit d984560

File tree

1 file changed

+122
-15
lines changed
  • cli/src/cmd/app/commands

1 file changed

+122
-15
lines changed

cli/src/cmd/app/commands/mcp.go

Lines changed: 122 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
const (
2323
defaultCommandTimeout = 30 * time.Second
2424
dependencyInstallTimeout = 5 * time.Minute
25+
maxLogTailLines = 10000 // Maximum number of log lines to retrieve
2526
)
2627

2728
// Command constants
@@ -31,7 +32,6 @@ const (
3132
jsonOutputFlag = "--output"
3233
jsonOutputVal = "json"
3334
cwdFlag = "--cwd"
34-
projectFlag = "--project"
3535
)
3636

3737
// Allowed values for validation
@@ -86,13 +86,19 @@ This server complements azd's core MCP capabilities:
8686
1. Always use get_services to check current state before starting/stopping services
8787
2. Use check_requirements before installing dependencies to see what's needed
8888
3. Use get_service_logs to diagnose issues when services fail to start
89-
4. Read azure.yaml resource to understand project structure before operations
89+
4. Read azure://project/azure.yaml resource to understand project structure before operations
9090
9191
**Tool Categories:**
9292
- Observability: get_services, get_service_logs, get_project_info
93-
- Operations: run_services, stop_services, restart_service, install_dependencies
93+
- Operations: run_services, stop_services, start_service, restart_service, install_dependencies
9494
- Configuration: check_requirements, get_environment_variables, set_environment_variable
9595
96+
**Service Lifecycle:**
97+
- run_services: Start all services (background process, use get_services to check status)
98+
- start_service: Start a specific stopped service
99+
- stop_services: Stop all or a specific running service (graceful shutdown)
100+
- restart_service: Stop and start a specific service
101+
96102
**Integration Notes:**
97103
- Works with projects created by azd init or azd templates
98104
- Monitors services started by azd app run
@@ -101,7 +107,7 @@ This server complements azd's core MCP capabilities:
101107
// Create MCP server with all capabilities
102108
// Server name follows azd extension naming convention: {namespace}-mcp-server
103109
s := server.NewMCPServer(
104-
"app-mcp-server", "0.1.0",
110+
"app-mcp-server", Version,
105111
server.WithToolCapabilities(true),
106112
server.WithResourceCapabilities(false, true), // subscribe=false, listChanged=true
107113
server.WithPromptCapabilities(false), // listChanged=false
@@ -117,6 +123,7 @@ This server complements azd's core MCP capabilities:
117123
// Operational tools
118124
newRunServicesTool(),
119125
newStopServicesTool(),
126+
newStartServiceTool(),
120127
newRestartServiceTool(),
121128
newInstallDependenciesTool(),
122129
newCheckRequirementsTool(),
@@ -234,6 +241,20 @@ func extractProjectDirArg(args map[string]interface{}) ([]string, error) {
234241
return cmdArgs, nil
235242
}
236243

244+
// extractValidatedProjectDir extracts and validates projectDir from args, returning the path.
245+
// Falls back to getProjectDir() if not provided in args.
246+
func extractValidatedProjectDir(args map[string]interface{}) (string, error) {
247+
projectDir := getProjectDir()
248+
if pd, ok := getStringParam(args, "projectDir"); ok {
249+
validatedPath, err := validateProjectDir(pd)
250+
if err != nil {
251+
return "", err
252+
}
253+
projectDir = validatedPath
254+
}
255+
return projectDir, nil
256+
}
257+
237258
// validateRequiredParam validates that a required parameter exists and returns the value
238259
func validateRequiredParam(args map[string]interface{}, key string) (string, error) {
239260
val, ok := args[key].(string)
@@ -455,8 +476,8 @@ func newGetServiceLogsTool() server.ServerTool {
455476

456477
if tail, ok := getFloat64Param(args, "tail"); ok && tail > 0 {
457478
// Cap tail at reasonable maximum
458-
if tail > 10000 {
459-
tail = 10000
479+
if tail > float64(maxLogTailLines) {
480+
tail = float64(maxLogTailLines)
460481
}
461482
cmdArgs = append(cmdArgs, "--tail", fmt.Sprintf("%.0f", tail))
462483
}
@@ -746,15 +767,94 @@ func newStopServicesTool() server.ServerTool {
746767
mcp.WithString("projectDir",
747768
mcp.Description("Optional project directory path. If not provided, uses current directory."),
748769
),
770+
mcp.WithString("serviceName",
771+
mcp.Description("Optional specific service to stop. If not provided, stops all running services."),
772+
),
749773
),
750774
Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
751-
// Note: azd app doesn't have a direct stop command, so we provide guidance
752-
result := map[string]interface{}{
753-
"status": "info",
754-
"message": "To stop services, use Ctrl+C in the terminal running 'azd app run', or use system tools to kill the process.",
755-
"tip": "You can use get_services to find the PID of running services.",
775+
args := getArgsMap(request)
776+
777+
// Get project directory
778+
projectDir, err := extractValidatedProjectDir(args)
779+
if err != nil {
780+
return mcp.NewToolResultError(fmt.Sprintf("Invalid project directory: %v", err)), nil
781+
}
782+
783+
// Create service controller
784+
ctrl, err := NewServiceController(projectDir)
785+
if err != nil {
786+
return mcp.NewToolResultError(fmt.Sprintf("Failed to initialize service controller: %v", err)), nil
787+
}
788+
789+
// Check if a specific service was requested
790+
if serviceName, ok := getStringParam(args, "serviceName"); ok {
791+
if err := security.ValidateServiceName(serviceName, false); err != nil {
792+
return mcp.NewToolResultError(err.Error()), nil
793+
}
794+
result := ctrl.StopService(ctx, serviceName)
795+
return marshalToolResult(result)
796+
}
797+
798+
// Stop all running services
799+
runningServices := ctrl.GetRunningServices()
800+
if len(runningServices) == 0 {
801+
return marshalToolResult(BulkServiceControlResult{
802+
Success: true,
803+
Message: "No running services to stop",
804+
Results: []ServiceControlResult{},
805+
})
806+
}
807+
808+
result := ctrl.BulkStop(ctx, runningServices)
809+
return marshalToolResult(result)
810+
},
811+
}
812+
}
813+
814+
// newStartServiceTool creates the start_service tool
815+
func newStartServiceTool() server.ServerTool {
816+
return server.ServerTool{
817+
Tool: mcp.NewTool(
818+
"start_service",
819+
mcp.WithTitleAnnotation("Start Service"),
820+
mcp.WithDescription("Start a specific stopped service. Use this to start individual services that were previously stopped."),
821+
mcp.WithReadOnlyHintAnnotation(false),
822+
mcp.WithIdempotentHintAnnotation(false),
823+
mcp.WithDestructiveHintAnnotation(false),
824+
mcp.WithString("serviceName",
825+
mcp.Description("Name of the service to start"),
826+
mcp.Required(),
827+
),
828+
mcp.WithString("projectDir",
829+
mcp.Description("Optional project directory path. If not provided, uses current directory."),
830+
),
831+
),
832+
Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
833+
args := getArgsMap(request)
834+
835+
serviceName, err := validateRequiredParam(args, "serviceName")
836+
if err != nil {
837+
return mcp.NewToolResultError(err.Error()), nil
756838
}
757839

840+
// Validate service name to prevent injection
841+
if err := security.ValidateServiceName(serviceName, false); err != nil {
842+
return mcp.NewToolResultError(err.Error()), nil
843+
}
844+
845+
// Get project directory
846+
projectDir, err := extractValidatedProjectDir(args)
847+
if err != nil {
848+
return mcp.NewToolResultError(fmt.Sprintf("Invalid project directory: %v", err)), nil
849+
}
850+
851+
// Create service controller
852+
ctrl, err := NewServiceController(projectDir)
853+
if err != nil {
854+
return mcp.NewToolResultError(fmt.Sprintf("Failed to initialize service controller: %v", err)), nil
855+
}
856+
857+
result := ctrl.StartService(ctx, serviceName)
758858
return marshalToolResult(result)
759859
},
760860
}
@@ -791,12 +891,19 @@ func newRestartServiceTool() server.ServerTool {
791891
return mcp.NewToolResultError(err.Error()), nil
792892
}
793893

794-
result := map[string]interface{}{
795-
"status": "info",
796-
"message": fmt.Sprintf("To restart service '%s', first stop it (Ctrl+C or kill PID), then use run_services to start it again.", serviceName),
797-
"tip": "Use get_services to find the current PID of the service.",
894+
// Get project directory
895+
projectDir, err := extractValidatedProjectDir(args)
896+
if err != nil {
897+
return mcp.NewToolResultError(fmt.Sprintf("Invalid project directory: %v", err)), nil
898+
}
899+
900+
// Create service controller
901+
ctrl, err := NewServiceController(projectDir)
902+
if err != nil {
903+
return mcp.NewToolResultError(fmt.Sprintf("Failed to initialize service controller: %v", err)), nil
798904
}
799905

906+
result := ctrl.RestartService(ctx, serviceName)
800907
return marshalToolResult(result)
801908
},
802909
}

0 commit comments

Comments
 (0)