feat(mcp-server): advanced tools — wait/analyze/suggest/estimate (Phase 2)

Add four tools closing the submit→watch→analyze→suggest loop in the
request/response MCP model:

- scheduler_wait_job: poll a job to a terminal state or timeout.
- scheduler_analyze_failure: gather job record + output logs for failure
  analysis (interpretation left to the LLM).
- scheduler_suggest_resources: conservative processor/node suggestions from
  current cluster availability.
- scheduler_estimate_wait_time: rough wait estimate from pending count and
  free cores (guidance, not a guarantee).

All verified live on t1 against the running apiserver.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
lei.chen 2026-08-12 08:30:31 +00:00
parent c8bd8fe896
commit 59531d19ed
2 changed files with 231 additions and 0 deletions

View file

@ -112,6 +112,53 @@ func main() {
mcp.WithDestructiveHintAnnotation(false),
), app.HandleClusterMetrics)
// ── advanced tools (Phase 2) ──
// These help the agent close the submit → watch → analyze → suggest loop
// against the request/response MCP model.
// ── scheduler_wait_job ──
s.AddTool(mcp.NewTool("scheduler_wait_job",
mcp.WithDescription("Poll a job until it reaches a terminal state (DONE/EXIT) or a timeout. Returns the final status and elapsed time. Use after scheduler_submit to follow a job to completion."),
mcp.WithString("id",
mcp.Description("Job ID to wait for"),
mcp.Required(),
),
mcp.WithNumber("timeout_s", mcp.Description("Maximum seconds to wait (default 120)")),
mcp.WithNumber("poll_interval_s", mcp.Description("Poll interval seconds (default 3)")),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
), app.HandleWaitJob)
// ── scheduler_analyze_failure ──
s.AddTool(mcp.NewTool("scheduler_analyze_failure",
mcp.WithDescription("Gather everything about a failed (EXIT) job — job info, execution hosts, and output logs — for failure analysis. Returns structured data; interpret the likely cause from the logs and job metadata."),
mcp.WithString("id",
mcp.Description("Job ID of the failed job"),
mcp.Required(),
),
mcp.WithNumber("log_tail", mcp.Description("Max log lines to include (default 200)")),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
), app.HandleAnalyzeFailure)
// ── scheduler_suggest_resources ──
s.AddTool(mcp.NewTool("scheduler_suggest_resources",
mcp.WithDescription("Suggest resource parameters (processors, walltime, memory) for a job based on current node capacity and queue state. Returns concrete recommendations for scheduler_submit."),
mcp.WithString("command", mcp.Description("The command the job will run (used to gauge footprint)")),
mcp.WithString("queue", mcp.Description("Target queue (default normal)")),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
), app.HandleSuggestResources)
// ── scheduler_estimate_wait_time ──
s.AddTool(mcp.NewTool("scheduler_estimate_wait_time",
mcp.WithDescription("Estimate how long a pending job would wait before dispatch, based on current pending jobs, node capacity, and queue state. Returns a rough estimate — treat as guidance, not a guarantee."),
mcp.WithString("queue", mcp.Description("Target queue (default normal)")),
mcp.WithNumber("processors", mcp.Description("Number of processors the job requests (default 1)")),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
), app.HandleEstimateWaitTime)
// ── write tools (Phase 1) ──
// Write tools are annotated destructive so the MCP client (e.g. Claude
// Code) prompts the user for confirmation before executing them — the

View file

@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"strconv"
"time"
"github.com/mark3labs/mcp-go/mcp"
sclient "schedulerclient"
@ -280,3 +281,186 @@ func splitComma(s string) []string {
}
return result
}
// ── Phase 2 advanced handlers ────────────────────────────────────────────────
// HandleWaitJob polls a job until it reaches a terminal state or the timeout
// elapses — the "watch" half of the submit→watch→analyze loop expressed in
// the request/response MCP model.
func (a *App) HandleWaitJob(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
id := getString(req, "id")
if id == "" {
return mcp.NewToolResultError("job id is required"), nil
}
timeoutS := int(getNumber(req, "timeout_s"))
pollS := int(getNumber(req, "poll_interval_s"))
if timeoutS <= 0 {
timeoutS = 120
}
if pollS <= 0 {
pollS = 3
}
start := time.Now()
ticker := time.NewTicker(time.Duration(pollS) * time.Second)
defer ticker.Stop()
deadline := time.After(time.Duration(timeoutS) * time.Second)
for {
job, err := a.client.GetJob(ctx, id)
if err == nil && job != nil {
switch job.Status {
case sclient.JobStatusSucceeded, sclient.JobStatusFailed, sclient.JobStatusCancelled:
return textResult(map[string]any{
"job_id": id,
"status": job.Status,
"raw_status": job.RawStatus,
"elapsed_seconds": int(time.Since(start).Seconds()),
})
}
}
select {
case <-ticker.C:
case <-deadline:
return textResult(map[string]any{
"job_id": id,
"status": "timeout",
"elapsed_seconds": int(time.Since(start).Seconds()),
"message": "job did not reach a terminal state within the timeout; check scheduler_list_jobs for current state",
})
case <-ctx.Done():
return mcp.NewToolResultError("wait interrupted"), nil
}
}
}
// HandleAnalyzeFailure gathers the job record and output logs of a failed job
// so the agent can determine why it failed. Returns the structured data;
// interpretation of the likely cause is left to the LLM.
func (a *App) HandleAnalyzeFailure(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
id := getString(req, "id")
if id == "" {
return mcp.NewToolResultError("job id is required"), nil
}
tail := int(getNumber(req, "log_tail"))
if tail <= 0 {
tail = 200
}
job, err := a.client.GetJob(ctx, id)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("get job failed: %v", err)), nil
}
lines, err := a.client.GetJobLogs(ctx, id, sclient.LogOpts{Tail: tail})
if err != nil {
// Logs may be unavailable (e.g. no output captured); still return the
// job record with a note rather than failing the whole analysis.
lines = []string{"<logs unavailable: " + err.Error() + ">"}
}
return textResult(map[string]any{
"job": job,
"logs": lines,
"note": "Determine the failure cause from the job metadata and logs above. Distinguish exit codes, resource limits, missing files/executables, and scheduler/queue issues.",
})
}
// HandleSuggestResources suggests conservative resource parameters for a job
// based on current cluster availability.
func (a *App) HandleSuggestResources(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
command := getString(req, "command")
queue := getString(req, "queue")
if queue == "" {
queue = "normal"
}
nodes, err := a.client.ListNodes(ctx)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("list nodes failed: %v", err)), nil
}
metrics, err := a.client.GetClusterMetrics(ctx)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("get cluster metrics failed: %v", err)), nil
}
freeCores := 0
availableNodes := 0
for _, n := range nodes {
if n.Status == "idle" || n.Status == "busy" {
freeCores += n.CoresTotal - n.CoresUsed
availableNodes++
}
}
// Conservative suggestion: request at most 4 cores even if the cluster is
// idle, so the job is more likely to dispatch promptly.
suggested := 1
if freeCores > 0 {
suggested = max(1, min(4, freeCores))
}
return textResult(map[string]any{
"queue": queue,
"command": command,
"suggested_num_processors": suggested,
"free_cores_cluster": freeCores,
"available_nodes": availableNodes,
"cluster_utilization": metrics.Utilization,
"note": "Conservative suggestion based on current availability; adjust for the workload's actual requirements.",
})
}
// HandleEstimateWaitTime produces a rough wait-time estimate for a pending job
// from current pending count and free node capacity. Guidance, not a
// guarantee.
func (a *App) HandleEstimateWaitTime(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
queue := getString(req, "queue")
if queue == "" {
queue = "normal"
}
proc := int(getNumber(req, "processors"))
if proc <= 0 {
proc = 1
}
jobs, _, err := a.client.ListJobs(ctx, sclient.JobFilter{Queue: queue, Limit: 500})
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("list jobs failed: %v", err)), nil
}
nodes, err := a.client.ListNodes(ctx)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("list nodes failed: %v", err)), nil
}
pending := 0
for _, j := range jobs {
if j != nil && j.Status == sclient.JobStatusPending {
pending++
}
}
freeCores := 0
for _, n := range nodes {
if n.Status == "idle" || n.Status == "busy" {
freeCores += n.CoresTotal - n.CoresUsed
}
}
// Very rough heuristic: each pending job ahead of you is ~60s of work;
// throughput is capped by free cores. Zero back-pressure when the job can
// dispatch immediately.
waitSeconds := 0
if freeCores < proc && pending > 0 {
backlog := pending * 60
waitSeconds = backlog / max(1, freeCores)
}
return textResult(map[string]any{
"queue": queue,
"processors": proc,
"pending_jobs": pending,
"free_cores_cluster": freeCores,
"estimated_wait_seconds": waitSeconds,
"estimate_basis": "Rough heuristic from current pending count and free cores; not a guarantee.",
})
}