Phase 5 Batch 2: 4 RM Tool + rm 客户端 + 日志降级链

- internal/rm/client.go: YARN RM 客户端
  - ListApps / GetApp / KillApp / GetLogs
  - GetLogs 降级链数据驱动 (amContainerLogs → aggregated-logs → logs)
  - 跨主机 redirect 保留 Authorization (URLAllowlist 兜底)
- 4 Tool: list_applications / get_application_status /
  get_application_logs / kill_application
  - mcp.WithEnum(state) 约束 YARN app 状态
  - tool handler 永远 (result, nil), 业务错误用 NewToolResultError
- deps.go: +HTTPClient + MaxResponseBytes
- main.go: 注入 httpclient.Client
- 端到端实测: mock RM 验证 4 Tool + 降级链 + 错误路径

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-07-10 16:50:27 +08:00
co-authored by Claude
parent 705665745e
commit c4ac3cc354
9 changed files with 885 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
package mcp
import (
"net/http"
"github.com/mark3labs/mcp-go/server"
"spark-mcp-go/internal/mcp/tools"
)
// NewServer builds the MCP server with all configured Tools.
func NewServer(deps *tools.Deps) *server.MCPServer {
s := server.NewMCPServer("spark-mcp-go", "0.0.0",
server.WithToolCapabilities(false),
server.WithRecovery(),
server.WithLogging(),
)
s.AddTool(tools.NewListClustersTool(), deps.ListClustersHandler)
s.AddTool(tools.NewSparkSubmitTool(), deps.SparkSubmitHandler)
s.AddTool(tools.NewListApplicationsTool(), deps.ListApplicationsHandler)
s.AddTool(tools.NewGetApplicationStatusTool(), deps.GetApplicationStatusHandler)
s.AddTool(tools.NewGetApplicationLogsTool(), deps.GetApplicationLogsHandler)
s.AddTool(tools.NewKillApplicationTool(), deps.KillApplicationHandler)
s.AddTool(tools.NewFetchURLTool(), deps.FetchURLHandler)
s.AddTool(tools.NewUploadFileTool(), deps.UploadFileHandler)
s.AddTool(tools.NewFetchSparkMetricsTool(), deps.FetchSparkMetricsHandler)
s.AddTool(tools.NewFetchClusterEnvTool(), deps.FetchClusterEnvHandler)
s.AddTool(tools.NewAnalyzeSparkLogTool(), deps.AnalyzeSparkLogHandler)
return s
}
// Handler exposes the MCP server as an http.Handler for main.go to mount.
func Handler(deps *tools.Deps) (http.Handler, error) {
s := NewServer(deps)
return server.NewStreamableHTTPServer(s), nil
}
+22
View File
@@ -0,0 +1,22 @@
package tools
import (
"log/slog"
"time"
"spark-mcp-go/internal/analyzer"
"spark-mcp-go/internal/httpclient"
"spark-mcp-go/internal/storage"
)
// Deps bundles the dependencies shared by all MCP Tool handlers.
type Deps struct {
Logger *slog.Logger
ClusterRepo *storage.ClusterRepo
SparkSubmitTimeout time.Duration
HTTPClient *httpclient.Client
MaxResponseBytes int64
DataDir string // upload_file writes to DataDir/uploads
AnalyzerThresholds analyzer.Thresholds
}
@@ -0,0 +1,93 @@
package tools
import (
"context"
"github.com/mark3labs/mcp-go/mcp"
"spark-mcp-go/internal/rm"
)
const GetApplicationLogsName = "get_application_logs"
const defaultTailBytes = 1 << 20
// NewGetApplicationLogsTool returns the schema for the get_application_logs MCP Tool.
func NewGetApplicationLogsTool() mcp.Tool {
return mcp.NewTool(GetApplicationLogsName,
mcp.WithDescription("Fetch YARN application logs. Tries amContainerLogs first, then aggregated-logs, then the legacy /logs endpoint. Returns {source, text}."),
mcp.WithString("cluster_id",
mcp.Required(),
mcp.Description("ID of the configured cluster (from list_clusters)"),
),
mcp.WithString("app_id",
mcp.Required(),
mcp.Description("YARN application ID, e.g. application_1234567890_0001"),
),
mcp.WithString("container",
mcp.Description("Container ID used for the aggregated-logs fallback, e.g. container_1234567890_0001_01_000001."),
),
mcp.WithNumber("tail_bytes",
mcp.Description("Maximum bytes to return. 0 means use the server-wide response byte limit."),
),
)
}
// GetApplicationLogsHandler retrieves logs using the RM fallback chain.
func (d *Deps) GetApplicationLogsHandler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
clusterID, err := req.RequireString("cluster_id")
if err != nil {
return errResult("get_application_logs: " + err.Error()), nil
}
appID, err := req.RequireString("app_id")
if err != nil {
return errResult("get_application_logs: " + err.Error()), nil
}
args := req.GetArguments()
container := ""
if v, ok := args["container"].(string); ok {
container = v
}
tailBytes := defaultTailBytes
if v, ok := args["tail_bytes"].(float64); ok {
tailBytes = int(v)
}
if tailBytes == 0 {
tailBytes = int(d.MaxResponseBytes)
}
callLog := startToolCall(ctx, d.Logger, GetApplicationLogsName, map[string]any{
"cluster_id": clusterID,
"app_id": appID,
"container": container,
"tail_bytes": tailBytes,
})
defer callLog.End()
cl, err := d.ClusterRepo.Get(ctx, clusterID)
if err != nil {
callLog.WithError(err)
return errResult("get_application_logs: cluster " + clusterID + ": " + err.Error()), nil
}
rmc := rm.New(d.HTTPClient, cl)
body, source, err := rmc.GetLogs(ctx, appID, container)
if err != nil {
callLog.WithError(err)
return errResult("get_application_logs: " + err.Error()), nil
}
text := string(body)
if len(text) > tailBytes {
text = truncateMiddle(text, tailBytes)
}
result := map[string]any{
"source": source,
"text": text,
}
callLog.WithResult(map[string]any{"source": source, "bytes": len(body), "returned_bytes": len(text)})
return textResult(encodeJSON(result)), nil
}
@@ -0,0 +1,60 @@
package tools
import (
"context"
"github.com/mark3labs/mcp-go/mcp"
"spark-mcp-go/internal/rm"
)
const GetApplicationStatusName = "get_application_status"
// NewGetApplicationStatusTool returns the schema for the get_application_status MCP Tool.
func NewGetApplicationStatusTool() mcp.Tool {
return mcp.NewTool(GetApplicationStatusName,
mcp.WithDescription("Get the detailed status of a single YARN application from the ResourceManager. Returns the raw RM JSON for the app."),
mcp.WithString("cluster_id",
mcp.Required(),
mcp.Description("ID of the configured cluster (from list_clusters)"),
),
mcp.WithString("app_id",
mcp.Required(),
mcp.Description("YARN application ID, e.g. application_1234567890_0001"),
),
)
}
// GetApplicationStatusHandler fetches a single application's details.
func (d *Deps) GetApplicationStatusHandler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
clusterID, err := req.RequireString("cluster_id")
if err != nil {
return errResult("get_application_status: " + err.Error()), nil
}
appID, err := req.RequireString("app_id")
if err != nil {
return errResult("get_application_status: " + err.Error()), nil
}
callLog := startToolCall(ctx, d.Logger, GetApplicationStatusName, map[string]any{
"cluster_id": clusterID,
"app_id": appID,
})
defer callLog.End()
cl, err := d.ClusterRepo.Get(ctx, clusterID)
if err != nil {
callLog.WithError(err)
return errResult("get_application_status: cluster " + clusterID + ": " + err.Error()), nil
}
rmc := rm.New(d.HTTPClient, cl)
raw, err := rmc.GetApp(ctx, appID)
if err != nil {
callLog.WithError(err)
return errResult("get_application_status: " + err.Error()), nil
}
callLog.WithResult(map[string]any{"bytes": len(raw)})
return textResult(string(raw)), nil
}
+60
View File
@@ -0,0 +1,60 @@
package tools
import (
"context"
"github.com/mark3labs/mcp-go/mcp"
"spark-mcp-go/internal/rm"
)
const KillApplicationName = "kill_application"
// NewKillApplicationTool returns the schema for the kill_application MCP Tool.
func NewKillApplicationTool() mcp.Tool {
return mcp.NewTool(KillApplicationName,
mcp.WithDescription("Kill (move to KILLED state) a YARN application through the ResourceManager. Returns the RM JSON response."),
mcp.WithString("cluster_id",
mcp.Required(),
mcp.Description("ID of the configured cluster (from list_clusters)"),
),
mcp.WithString("app_id",
mcp.Required(),
mcp.Description("YARN application ID, e.g. application_1234567890_0001"),
),
)
}
// KillApplicationHandler sends a KILLED state update to the ResourceManager.
func (d *Deps) KillApplicationHandler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
clusterID, err := req.RequireString("cluster_id")
if err != nil {
return errResult("kill_application: " + err.Error()), nil
}
appID, err := req.RequireString("app_id")
if err != nil {
return errResult("kill_application: " + err.Error()), nil
}
callLog := startToolCall(ctx, d.Logger, KillApplicationName, map[string]any{
"cluster_id": clusterID,
"app_id": appID,
})
defer callLog.End()
cl, err := d.ClusterRepo.Get(ctx, clusterID)
if err != nil {
callLog.WithError(err)
return errResult("kill_application: cluster " + clusterID + ": " + err.Error()), nil
}
rmc := rm.New(d.HTTPClient, cl)
raw, err := rmc.KillApp(ctx, appID)
if err != nil {
callLog.WithError(err)
return errResult("kill_application: " + err.Error()), nil
}
callLog.WithResult(map[string]any{"bytes": len(raw)})
return textResult(string(raw)), nil
}
+69
View File
@@ -0,0 +1,69 @@
package tools
import (
"context"
"github.com/mark3labs/mcp-go/mcp"
"spark-mcp-go/internal/rm"
)
const ListApplicationsName = "list_applications"
// NewListApplicationsTool returns the schema for the list_applications MCP Tool.
func NewListApplicationsTool() mcp.Tool {
return mcp.NewTool(ListApplicationsName,
mcp.WithDescription("List YARN applications from a cluster's ResourceManager. Returns the raw RM JSON response so the agent can inspect app IDs, states, and owners."),
mcp.WithString("cluster_id",
mcp.Required(),
mcp.Description("ID of the configured cluster (from list_clusters)"),
),
mcp.WithString("state",
mcp.Description("Filter by application state. YARN accepts comma-separated states; common values: NEW, NEW_SAVING, SUBMITTED, ACCEPTED, RUNNING, FINISHED, FAILED, KILLED."),
),
mcp.WithString("user",
mcp.Description("Filter by submitting user."),
),
)
}
// ListApplicationsHandler queries the ResourceManager for applications.
func (d *Deps) ListApplicationsHandler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
clusterID, err := req.RequireString("cluster_id")
if err != nil {
return errResult("list_applications: " + err.Error()), nil
}
args := req.GetArguments()
state := ""
if v, ok := args["state"].(string); ok {
state = v
}
user := ""
if v, ok := args["user"].(string); ok {
user = v
}
callLog := startToolCall(ctx, d.Logger, ListApplicationsName, map[string]any{
"cluster_id": clusterID,
"state": state,
"user": user,
})
defer callLog.End()
cl, err := d.ClusterRepo.Get(ctx, clusterID)
if err != nil {
callLog.WithError(err)
return errResult("list_applications: cluster " + clusterID + ": " + err.Error()), nil
}
rmc := rm.New(d.HTTPClient, cl)
raw, err := rmc.ListApps(ctx, state, user)
if err != nil {
callLog.WithError(err)
return errResult("list_applications: " + err.Error()), nil
}
callLog.WithResult(map[string]any{"bytes": len(raw)})
return textResult(string(raw)), nil
}
+176
View File
@@ -0,0 +1,176 @@
package tools
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/mark3labs/mcp-go/mcp"
"spark-mcp-go/internal/cluster"
"spark-mcp-go/internal/httpclient"
"spark-mcp-go/internal/storage"
)
func testDeps(t *testing.T, srv *httptest.Server) (*Deps, *storage.ClusterRepo) {
t.Helper()
db, err := storage.Open(":memory:")
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
cl := &cluster.Cluster{
ID: "cluster-a",
Name: "Cluster A",
RMURL: srv.URL,
SHSURL: "http://shs.example.com:18080",
SparkSubmitExecuteBin: "/usr/bin/spark-submit",
IsActive: true,
AuthType: cluster.AuthNone,
}
if err := db.Clusters().Create(context.Background(), cl); err != nil {
t.Fatalf("create cluster: %v", err)
}
return &Deps{
HTTPClient: httpclient.New(httpclient.Config{
Timeout: 5 * time.Second,
MaxResponseBytes: 1 << 20,
}),
ClusterRepo: db.Clusters(),
MaxResponseBytes: 1 << 20,
}, db.Clusters()
}
func newToolRequest(name string, args map[string]any) mcp.CallToolRequest {
return mcp.CallToolRequest{
Request: mcp.Request{Method: "tools/call"},
Params: mcp.CallToolParams{
Name: name,
Arguments: args,
},
}
}
func TestListApplications_EndToEnd(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/ws/v1/cluster/apps" {
t.Errorf("path=%q", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"apps":{"app":[{"id":"application_1"}]}}`))
}))
defer srv.Close()
deps, _ := testDeps(t, srv)
req := newToolRequest("list_applications", map[string]any{
"cluster_id": "cluster-a",
"state": "RUNNING",
})
res, err := deps.ListApplicationsHandler(context.Background(), req)
if err != nil {
t.Fatalf("handler error: %v", err)
}
if res.IsError {
t.Fatalf("unexpected error result: %v", res.Content)
}
text, ok := mcp.AsTextContent(res.Content[0])
if !ok {
t.Fatalf("content is not text: %T", res.Content[0])
}
if !strings.Contains(text.Text, "application_1") {
t.Errorf("result missing app: %s", text.Text)
}
}
func TestKillApplication_AppIDNotProvided(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("unexpected RM call")
}))
defer srv.Close()
deps, _ := testDeps(t, srv)
req := newToolRequest("kill_application", map[string]any{
"cluster_id": "cluster-a",
})
res, err := deps.KillApplicationHandler(context.Background(), req)
if err != nil {
t.Fatalf("handler error: %v", err)
}
if !res.IsError {
t.Errorf("expected error result, got: %v", res.Content)
}
}
func TestGetApplicationLogs_SourceReported(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/ws/v1/cluster/apps/app_123/amContainerLogs" {
w.Write([]byte("driver stdout"))
return
}
t.Errorf("unexpected path %q", r.URL.Path)
}))
defer srv.Close()
deps, _ := testDeps(t, srv)
req := newToolRequest("get_application_logs", map[string]any{
"cluster_id": "cluster-a",
"app_id": "app_123",
})
res, err := deps.GetApplicationLogsHandler(context.Background(), req)
if err != nil {
t.Fatalf("handler error: %v", err)
}
if res.IsError {
t.Fatalf("unexpected error result: %v", res.Content)
}
text, ok := mcp.AsTextContent(res.Content[0])
if !ok {
t.Fatalf("content is not text: %T", res.Content[0])
}
var payload map[string]any
if err := json.Unmarshal([]byte(text.Text), &payload); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
if payload["source"] != "amContainerLogs" {
t.Errorf("source=%v, want amContainerLogs", payload["source"])
}
if !strings.Contains(payload["text"].(string), "driver stdout") {
t.Errorf("text missing logs: %v", payload["text"])
}
}
func TestKillApplication_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/ws/v1/cluster/apps/app_123/state" {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"error":"Not Found"}`))
return
}
t.Errorf("unexpected path %q", r.URL.Path)
}))
defer srv.Close()
deps, _ := testDeps(t, srv)
req := newToolRequest("kill_application", map[string]any{
"cluster_id": "cluster-a",
"app_id": "app_123",
})
res, err := deps.KillApplicationHandler(context.Background(), req)
if err != nil {
t.Fatalf("handler error: %v", err)
}
if !res.IsError {
t.Errorf("expected error result, got: %v", res.Content)
}
}