Phase 0: scheduleragent + mcp-server read-only agent framework

Complete the natural-language scheduler agent's Phase 0 read-only pipeline:

Task #2 (API Key auth mechanism):
- New store/apikey.go: APIKey store (hash, validate, expiry)
- controllers/apikey.go: GenerateAPIKey finally persists + registers RBAC role
- controllers/auth.go: GetUsernameFromToken recognizes xce_ak_ prefix
- middleware/validatehandler.go: isValidToken handles API Key
- config/rbac.yaml: mcp_readonly role (view-only permissions)
- 6 new test files covering unit + integration full-chain

Task #3 (schedulerclient Go library):
- types.go: URM types (URMJob/URMQueue/URMNode/JobStatus/Metrics)
- client.go: read-only Client interface (6 methods)
- httpclient.go: resty-based HTTP implementation
- statusmap.go + _test.go: TDD status mapping (33 tests)

Task #4 (mcp-server binary):
- cmd/main.go: entry, reads XCE_API_URL/XCE_API_KEY, registers 6 MCP tools
- internal/app.go: tool handlers wiring schedulerclient to MCP stdio
- Uses github.com/mark3labs/mcp-go@v0.57.0 for stdio transport

All: go vet clean, go build clean, schedulerclient 33/33 tests pass.
mcp-server binary ~11MB arm64, CGO_ENABLED=0.

Architecture:
  Claude Code --stdio--> mcp-server --HTTP--> apiserver REST API --cgo--> C backend

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Lei Chen 2026-07-30 11:16:36 +08:00
parent 98066db68d
commit e4af0cac73
27 changed files with 1760 additions and 7 deletions

View file

@ -0,0 +1,201 @@
// Package internal provides the scheduler-agent MCP tool handlers.
package internal
import (
"context"
"encoding/json"
"fmt"
"strconv"
"github.com/mark3labs/mcp-go/mcp"
sclient "schedulerclient"
)
// App wires the schedulerclient to the MCP tool handlers.
type App struct {
client sclient.Client
}
// NewApp creates a new App with a schedulerclient connected to the given base URL.
func NewApp(baseURL, token string) (*App, error) {
hc := sclient.NewClient(baseURL, token)
return &App{client: hc}, nil
}
// ── helpers ─────────────────────────────────────────────────────────────────
// textResult returns an MCP tool result containing the JSON representation of v.
func textResult(v any) (*mcp.CallToolResult, error) {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("marshal error: %v", err)), nil
}
return mcp.NewToolResultText(string(b)), nil
}
// textResultStr returns an MCP tool result wrapping a plain string.
func textResultStr(s string) *mcp.CallToolResult {
return mcp.NewToolResultText(s)
}
// getString extracts a string parameter from an MCP tool request.
func getString(req mcp.CallToolRequest, key string) string {
args := req.GetArguments()
raw, ok := args[key]
if !ok {
return ""
}
s, _ := raw.(string)
return s
}
// getNumber extracts a float64 parameter from an MCP tool request.
func getNumber(req mcp.CallToolRequest, key string) float64 {
args := req.GetArguments()
raw, ok := args[key]
if !ok {
return 0
}
n, _ := raw.(float64)
return n
}
// ── tool handlers ───────────────────────────────────────────────────────────
func (a *App) HandleListJobs(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
filter := sclient.JobFilter{
Queue: getString(req, "queue"),
Owner: getString(req, "owner"),
PageToken: getString(req, "page_token"),
}
if n := getNumber(req, "limit"); n > 0 {
filter.Limit = int(n)
}
if s := getString(req, "status"); s != "" {
filter.Statuses = parseStatuses(s)
}
jobs, page, err := a.client.ListJobs(ctx, filter)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("list jobs failed: %v", err)), nil
}
resp := map[string]any{
"jobs": jobs,
"page": page,
}
return textResult(resp)
}
func (a *App) HandleGetJob(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
id := getString(req, "id")
if id == "" {
return mcp.NewToolResultError("job id is required"), nil
}
job, err := a.client.GetJob(ctx, id)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("get job failed: %v", err)), nil
}
return textResult(job)
}
func (a *App) HandleListQueues(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
queues, err := a.client.ListQueues(ctx)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("list queues failed: %v", err)), nil
}
return textResult(queues)
}
func (a *App) HandleListNodes(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
nodes, err := a.client.ListNodes(ctx)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("list nodes failed: %v", err)), nil
}
return textResult(nodes)
}
func (a *App) HandleJobLogs(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
id := getString(req, "id")
if id == "" {
return mcp.NewToolResultError("job id is required"), nil
}
opts := sclient.LogOpts{
Tail: int(getNumber(req, "tail")),
OffsetBytes: int64(getNumber(req, "offset_bytes")),
}
lines, err := a.client.GetJobLogs(ctx, id, opts)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("get job logs failed: %v", err)), nil
}
return textResultStr(strconv.Itoa(len(lines)) + " lines\n" + joinLines(lines)), nil
}
func (a *App) HandleClusterMetrics(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
metrics, err := a.client.GetClusterMetrics(ctx)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("get cluster metrics failed: %v", err)), nil
}
return textResult(metrics)
}
// ── helpers ─────────────────────────────────────────────────────────────────
// parseStatuses parses a comma-separated list of URM status strings.
func parseStatuses(s string) []sclient.JobStatus {
if s == "" {
return nil
}
parts := splitComma(s)
statuses := make([]sclient.JobStatus, 0, len(parts))
for _, p := range parts {
statuses = append(statuses, sclient.JobStatus(p))
}
return statuses
}
// joinLines joins log lines into a single string.
func joinLines(lines []string) string {
if len(lines) == 0 {
return ""
}
if len(lines) == 1 {
return lines[0]
}
// Estimate total length for capacity
total := 0
for _, l := range lines {
total += len(l) + 1
}
b := make([]byte, 0, total)
for i, l := range lines {
if i > 0 {
b = append(b, '\n')
}
b = append(b, []byte(l)...)
}
return string(b)
}
// splitComma splits a comma-separated string, trimming whitespace.
func splitComma(s string) []string {
if s == "" {
return nil
}
var result []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == ',' {
if i > start {
result = append(result, s[start:i])
}
start = i + 1
}
}
if start < len(s) {
result = append(result, s[start:])
}
return result
}