package mcp import ( "context" "github.com/google/uuid" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" "github.com/yan1h/agent-coding-workflow/internal/run" "github.com/yan1h/agent-coding-workflow/internal/workspace" ) // RunService 是 run profile 工具依赖的窄接口,*run.Service 直接满足; // 接口化便于单测注入 fake。 type RunService interface { List(ctx context.Context, c workspace.Caller, wsID uuid.UUID) ([]run.RunProfileResp, error) Create(ctx context.Context, c workspace.Caller, wsID uuid.UUID, req run.CreateRunProfileReq) (run.RunProfileResp, error) Update(ctx context.Context, c workspace.Caller, profileID uuid.UUID, req run.UpdateRunProfileReq) (run.RunProfileResp, error) Delete(ctx context.Context, c workspace.Caller, profileID uuid.UUID) error Start(ctx context.Context, c workspace.Caller, profileID uuid.UUID) (run.RunStatusResp, error) Stop(ctx context.Context, c workspace.Caller, profileID uuid.UUID) (run.RunStatusResp, error) Restart(ctx context.Context, c workspace.Caller, profileID uuid.UUID) (run.RunStatusResp, error) Status(ctx context.Context, c workspace.Caller, profileID uuid.UUID) (run.RunStatusResp, error) Logs(ctx context.Context, c workspace.Caller, profileID uuid.UUID, since int64, limit int) ([]run.LogLineResp, error) } // ExecService 是一次性 exec 工具依赖的窄接口,*run.ExecService 直接满足。 type ExecService interface { RunCommand(ctx context.Context, c workspace.Caller, req run.ExecCommandReq) (run.ExecCommandResp, error) RunTests(ctx context.Context, c workspace.Caller, req run.ExecTestsReq) (run.ExecTestsResp, error) } // registerRunTools 注册 run profile 工具。日志只提供快照式 get_run_logs, // 流式订阅(WebSocket)不经 MCP 暴露。 // // 项目 scope 校验策略与 worktree 工具一致:profile 级操作要求同时给 // workspace_id,先按 workspace 做 scope 校验,再用 List 校验 profile 归属。 func registerRunTools(srv *mcpsdk.Server, deps ServerDeps) { registerListRunProfiles(srv, deps) registerCreateRunProfile(srv, deps) registerUpdateRunProfile(srv, deps) registerDeleteRunProfile(srv, deps) registerStartRunProfile(srv, deps) registerStopRunProfile(srv, deps) registerRestartRunProfile(srv, deps) registerGetRunStatus(srv, deps) registerGetRunLogs(srv, deps) registerRunCommand(srv, deps) registerRunTests(srv, deps) } // runProfileInWorkspace 校验 profile 归属于给定 workspace 并返回其 UUID。 func runProfileInWorkspace(ctx context.Context, deps ServerDeps, wc workspace.Caller, wsID uuid.UUID, profileID string) (uuid.UUID, error) { pid, err := uuid.Parse(profileID) if err != nil { return uuid.Nil, errs.Wrap(err, errs.CodeInvalidInput, "profile_id parse") } ps, err := deps.Runs.List(ctx, wc, wsID) if err != nil { return uuid.Nil, err } for _, p := range ps { if p.ID == pid.String() { return pid, nil } } return uuid.Nil, errs.New(errs.CodeNotFound, "run profile not found in workspace") } // ===== list_run_profiles ===== type listRunProfilesIn struct { WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"` } type listRunProfilesOut struct { Profiles []run.RunProfileResp `json:"profiles"` } func registerListRunProfiles(srv *mcpsdk.Server, deps ServerDeps) { mcpsdk.AddTool(srv, &mcpsdk.Tool{Name: "list_run_profiles", Description: "List run profiles of a workspace."}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in listRunProfilesIn) (*mcpsdk.CallToolResult, listRunProfilesOut, error) { if err := CheckToolFromCtx(ctx, "list_run_profiles"); err != nil { return nil, listRunProfilesOut{}, err } c, err := deps.Caller.Resolve(ctx) if err != nil { return nil, listRunProfilesOut{}, err } w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) if err != nil { return nil, listRunProfilesOut{}, err } ps, err := deps.Runs.List(ctx, wc, w.ID) if err != nil { return nil, listRunProfilesOut{}, err } return nil, listRunProfilesOut{Profiles: ps}, nil }) } // ===== create_run_profile ===== type createRunProfileIn struct { WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"` Slug string `json:"slug" jsonschema:"profile slug (non-empty)"` Name string `json:"name" jsonschema:"display name (non-empty)"` Description string `json:"description,omitempty"` Command string `json:"command" jsonschema:"command line to run (non-empty)"` Args []string `json:"args,omitempty"` Env map[string]string `json:"env,omitempty" jsonschema:"environment overrides (encrypted at rest, never returned)"` Enabled *bool `json:"enabled,omitempty" jsonschema:"default true"` } type runProfileOut struct { OK bool `json:"ok"` Profile run.RunProfileResp `json:"profile"` } func registerCreateRunProfile(srv *mcpsdk.Server, deps ServerDeps) { mcpsdk.AddTool(srv, &mcpsdk.Tool{Name: "create_run_profile", Description: "Create a run profile in a workspace."}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in createRunProfileIn) (*mcpsdk.CallToolResult, runProfileOut, error) { if err := CheckToolFromCtx(ctx, "create_run_profile"); err != nil { return nil, runProfileOut{}, err } c, err := deps.Caller.Resolve(ctx) if err != nil { return nil, runProfileOut{}, err } w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) if err != nil { return nil, runProfileOut{}, err } p, err := deps.Runs.Create(ctx, wc, w.ID, run.CreateRunProfileReq{ Slug: in.Slug, Name: in.Name, Description: in.Description, Command: in.Command, Args: in.Args, Env: in.Env, Enabled: in.Enabled, }) if err != nil { return nil, runProfileOut{}, err } return nil, runProfileOut{OK: true, Profile: p}, nil }) } // ===== update_run_profile ===== type updateRunProfileIn struct { WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"` ProfileID string `json:"profile_id" jsonschema:"run profile UUID"` Slug *string `json:"slug,omitempty"` Name *string `json:"name,omitempty"` Description *string `json:"description,omitempty"` Command *string `json:"command,omitempty"` Args *[]string `json:"args,omitempty"` Env *map[string]string `json:"env,omitempty" jsonschema:"full replacement of env overrides"` Enabled *bool `json:"enabled,omitempty"` } func registerUpdateRunProfile(srv *mcpsdk.Server, deps ServerDeps) { mcpsdk.AddTool(srv, &mcpsdk.Tool{Name: "update_run_profile", Description: "Update a run profile (patch semantics: omitted fields keep current values)."}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in updateRunProfileIn) (*mcpsdk.CallToolResult, runProfileOut, error) { if err := CheckToolFromCtx(ctx, "update_run_profile"); err != nil { return nil, runProfileOut{}, err } c, err := deps.Caller.Resolve(ctx) if err != nil { return nil, runProfileOut{}, err } w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) if err != nil { return nil, runProfileOut{}, err } pid, err := runProfileInWorkspace(ctx, deps, wc, w.ID, in.ProfileID) if err != nil { return nil, runProfileOut{}, err } p, err := deps.Runs.Update(ctx, wc, pid, run.UpdateRunProfileReq{ Slug: in.Slug, Name: in.Name, Description: in.Description, Command: in.Command, Args: in.Args, Env: in.Env, Enabled: in.Enabled, }) if err != nil { return nil, runProfileOut{}, err } return nil, runProfileOut{OK: true, Profile: p}, nil }) } // ===== delete_run_profile ===== type runProfileTargetIn struct { WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"` ProfileID string `json:"profile_id" jsonschema:"run profile UUID"` } func registerDeleteRunProfile(srv *mcpsdk.Server, deps ServerDeps) { mcpsdk.AddTool(srv, &mcpsdk.Tool{Name: "delete_run_profile", Description: "Delete a run profile. Irreversible."}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in runProfileTargetIn) (*mcpsdk.CallToolResult, okOut, error) { if err := CheckToolFromCtx(ctx, "delete_run_profile"); err != nil { return nil, okOut{}, err } c, err := deps.Caller.Resolve(ctx) if err != nil { return nil, okOut{}, err } w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) if err != nil { return nil, okOut{}, err } pid, err := runProfileInWorkspace(ctx, deps, wc, w.ID, in.ProfileID) if err != nil { return nil, okOut{}, err } if err := deps.Runs.Delete(ctx, wc, pid); err != nil { return nil, okOut{}, err } return nil, okOut{OK: true}, nil }) } // ===== start/stop/restart/status ===== type runStatusOut struct { OK bool `json:"ok"` Status run.RunStatusResp `json:"status"` } // registerRunLifecycleTool 抽取 start/stop/restart/status 四个同构工具的注册逻辑。 func registerRunLifecycleTool(srv *mcpsdk.Server, deps ServerDeps, name, desc string, op func(context.Context, workspace.Caller, uuid.UUID) (run.RunStatusResp, error)) { mcpsdk.AddTool(srv, &mcpsdk.Tool{Name: name, Description: desc}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in runProfileTargetIn) (*mcpsdk.CallToolResult, runStatusOut, error) { if err := CheckToolFromCtx(ctx, name); err != nil { return nil, runStatusOut{}, err } c, err := deps.Caller.Resolve(ctx) if err != nil { return nil, runStatusOut{}, err } w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) if err != nil { return nil, runStatusOut{}, err } pid, err := runProfileInWorkspace(ctx, deps, wc, w.ID, in.ProfileID) if err != nil { return nil, runStatusOut{}, err } st, err := op(ctx, wc, pid) if err != nil { return nil, runStatusOut{}, err } return nil, runStatusOut{OK: true, Status: st}, nil }) } // 注意:op 不能写成 deps.Runs.Start 这类 method value——注册期对 nil 接口取 // 方法值会 panic(deps.Runs 在部分单测装配里为空),必须用闭包延迟到调用期。 func registerStartRunProfile(srv *mcpsdk.Server, deps ServerDeps) { registerRunLifecycleTool(srv, deps, "start_run_profile", "Start the process of a run profile.", func(ctx context.Context, wc workspace.Caller, pid uuid.UUID) (run.RunStatusResp, error) { return deps.Runs.Start(ctx, wc, pid) }) } func registerStopRunProfile(srv *mcpsdk.Server, deps ServerDeps) { registerRunLifecycleTool(srv, deps, "stop_run_profile", "Stop the process of a run profile.", func(ctx context.Context, wc workspace.Caller, pid uuid.UUID) (run.RunStatusResp, error) { return deps.Runs.Stop(ctx, wc, pid) }) } func registerRestartRunProfile(srv *mcpsdk.Server, deps ServerDeps) { registerRunLifecycleTool(srv, deps, "restart_run_profile", "Restart the process of a run profile.", func(ctx context.Context, wc workspace.Caller, pid uuid.UUID) (run.RunStatusResp, error) { return deps.Runs.Restart(ctx, wc, pid) }) } func registerGetRunStatus(srv *mcpsdk.Server, deps ServerDeps) { registerRunLifecycleTool(srv, deps, "get_run_status", "Get the live status of a run profile.", func(ctx context.Context, wc workspace.Caller, pid uuid.UUID) (run.RunStatusResp, error) { return deps.Runs.Status(ctx, wc, pid) }) } // ===== get_run_logs ===== type getRunLogsIn struct { WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"` ProfileID string `json:"profile_id" jsonschema:"run profile UUID"` Since int64 `json:"since,omitempty" jsonschema:"return log lines with ID > this value"` Limit int `json:"limit,omitempty" jsonschema:"max lines to return (default 200, max 1000)"` } type getRunLogsOut struct { Lines []run.LogLineResp `json:"lines"` } func registerGetRunLogs(srv *mcpsdk.Server, deps ServerDeps) { mcpsdk.AddTool(srv, &mcpsdk.Tool{Name: "get_run_logs", Description: "Get a snapshot of recent log lines of a run profile (no streaming)."}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in getRunLogsIn) (*mcpsdk.CallToolResult, getRunLogsOut, error) { if err := CheckToolFromCtx(ctx, "get_run_logs"); err != nil { return nil, getRunLogsOut{}, err } c, err := deps.Caller.Resolve(ctx) if err != nil { return nil, getRunLogsOut{}, err } w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) if err != nil { return nil, getRunLogsOut{}, err } pid, err := runProfileInWorkspace(ctx, deps, wc, w.ID, in.ProfileID) if err != nil { return nil, getRunLogsOut{}, err } limit := in.Limit if limit <= 0 { limit = 200 } if limit > 1000 { limit = 1000 } lines, err := deps.Runs.Logs(ctx, wc, pid, in.Since, limit) if err != nil { return nil, getRunLogsOut{}, err } return nil, getRunLogsOut{Lines: lines}, nil }) } // ===== run_command ===== type runCommandIn struct { WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"` WorktreeID string `json:"worktree_id,omitempty" jsonschema:"worktree UUID; empty runs in workspace main_path"` Command string `json:"command" jsonschema:"executable to run, e.g. go / npm (non-empty)"` Args []string `json:"args,omitempty" jsonschema:"command arguments"` Env map[string]string `json:"env,omitempty" jsonschema:"ephemeral env overrides (plaintext, not persisted)"` TimeoutSec int `json:"timeout_sec,omitempty" jsonschema:"timeout in seconds; clamped to server max"` } func registerRunCommand(srv *mcpsdk.Server, deps ServerDeps) { mcpsdk.AddTool(srv, &mcpsdk.Tool{Name: "run_command", Description: "Run one command to completion in the workspace (main_path or a worktree) and return exit code, stdout, stderr, duration. Sandboxed: only whitelisted env vars are passed; requires project write access."}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in runCommandIn) (*mcpsdk.CallToolResult, run.ExecCommandResp, error) { if err := CheckToolFromCtx(ctx, "run_command"); err != nil { return nil, run.ExecCommandResp{}, err } c, err := deps.Caller.Resolve(ctx) if err != nil { return nil, run.ExecCommandResp{}, err } w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) if err != nil { return nil, run.ExecCommandResp{}, err } if in.WorktreeID != "" { if _, err := worktreeInWorkspace(ctx, deps, wc, w.ID, in.WorktreeID); err != nil { return nil, run.ExecCommandResp{}, err } } resp, err := deps.Execs.RunCommand(ctx, wc, run.ExecCommandReq{ WorkspaceID: w.ID.String(), WorktreeID: in.WorktreeID, Command: in.Command, Args: in.Args, Env: in.Env, TimeoutSec: in.TimeoutSec, }) if err != nil { return nil, run.ExecCommandResp{}, err } return nil, resp, nil }) } // ===== run_tests ===== type runTestsIn struct { WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"` WorktreeID string `json:"worktree_id,omitempty" jsonschema:"worktree UUID; empty runs in workspace main_path"` Command string `json:"command" jsonschema:"test runner, e.g. go (non-empty)"` Args []string `json:"args,omitempty" jsonschema:"args, e.g. ['test','-json','./...']"` Format string `json:"format,omitempty" jsonschema:"output format: gotest|junit|vitest|auto (default auto)"` Env map[string]string `json:"env,omitempty" jsonschema:"ephemeral env overrides (plaintext, not persisted)"` TimeoutSec int `json:"timeout_sec,omitempty" jsonschema:"timeout in seconds; clamped to server max"` } func registerRunTests(srv *mcpsdk.Server, deps ServerDeps) { mcpsdk.AddTool(srv, &mcpsdk.Tool{Name: "run_tests", Description: "Run a test command and parse its output (go test -json / JUnit XML / vitest JSON) into a structured pass/fail summary. Returns raw output plus a summary; never fails on parse errors."}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in runTestsIn) (*mcpsdk.CallToolResult, run.ExecTestsResp, error) { if err := CheckToolFromCtx(ctx, "run_tests"); err != nil { return nil, run.ExecTestsResp{}, err } c, err := deps.Caller.Resolve(ctx) if err != nil { return nil, run.ExecTestsResp{}, err } w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) if err != nil { return nil, run.ExecTestsResp{}, err } if in.WorktreeID != "" { if _, err := worktreeInWorkspace(ctx, deps, wc, w.ID, in.WorktreeID); err != nil { return nil, run.ExecTestsResp{}, err } } resp, err := deps.Execs.RunTests(ctx, wc, run.ExecTestsReq{ WorkspaceID: w.ID.String(), WorktreeID: in.WorktreeID, Command: in.Command, Args: in.Args, Format: in.Format, Env: in.Env, TimeoutSec: in.TimeoutSec, }) if err != nil { return nil, run.ExecTestsResp{}, err } return nil, resp, nil }) }