You've already forked agentic-coding-workflow
429 lines
17 KiB
Go
429 lines
17 KiB
Go
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
|
|
})
|
|
}
|