完善 MCP 工具

This commit is contained in:
2026-06-11 08:21:09 +08:00
parent c0f15050d0
commit 1415d3b43b
20 changed files with 3722 additions and 61 deletions
+326
View File
@@ -0,0 +1,326 @@
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)
}
// 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)
}
// 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
})
}