This commit is contained in:
2026-06-22 08:55:57 +08:00
parent dbb87823e8
commit 6ade6e8fa9
325 changed files with 41131 additions and 855 deletions
+10 -4
View File
@@ -5,6 +5,8 @@ import (
"net/http"
"strings"
"github.com/google/uuid"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http"
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
@@ -23,6 +25,9 @@ type AuthSession struct {
UserID string // user_id 字符串形式(避免反复 uuid.UUID -> string 转换)
Scope Scope
Issuer Issuer // system token(agent 会话持有)受额外限制,见 create_acp_session
// ACPSessionID 仅 system token 非 nil:标识持有该 token 的 agent 所在 acp session。
// request_subtask 据此把调用方映射回其编排 step。
ACPSessionID *uuid.UUID
}
// FromContext 从 ctx 取出 AuthSession,未鉴权时返 (nil, false)。
@@ -51,10 +56,11 @@ func NewAuthMiddleware(svc TokenService) func(http.Handler) http.Handler {
return
}
sess := &AuthSession{
TokenID: tok.ID.String(),
UserID: tok.UserID.String(),
Scope: tok.Scope,
Issuer: tok.Issuer,
TokenID: tok.ID.String(),
UserID: tok.UserID.String(),
Scope: tok.Scope,
Issuer: tok.Issuer,
ACPSessionID: tok.ACPSessionID,
}
ctx := context.WithValue(r.Context(), AuthContextKey, sess)
next.ServeHTTP(w, r.WithContext(ctx))
+14 -2
View File
@@ -3,8 +3,11 @@ package mcp
import (
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/yan1h/agent-coding-workflow/internal/changerequest"
"github.com/yan1h/agent-coding-workflow/internal/chat"
"github.com/yan1h/agent-coding-workflow/internal/codeindex"
"github.com/yan1h/agent-coding-workflow/internal/project"
"github.com/yan1h/agent-coding-workflow/internal/projectmemory"
"github.com/yan1h/agent-coding-workflow/internal/workspace"
)
@@ -18,12 +21,17 @@ type ServerDeps struct {
Workspaces workspace.WorkspaceService
Worktrees workspace.WorktreeService
GitOps workspace.GitOpsService
ChangeReqs changerequest.Service
Templates chat.TemplateService
Messages chat.MessageService
Conversations chat.ConversationService
Endpoints chat.EndpointService
ACP ACPBridge // acp 服务桥接(见 tools_acp.go / acp.NewMCPBridge)
Runs RunService // *run.Service(见 tools_run.go)
ACP ACPBridge // acp 服务桥接(见 tools_acp.go / acp.NewMCPBridge)
Runs RunService // *run.Service(见 tools_run.go)
Execs ExecService // *run.ExecService — 一次性 exec(run_command / run_tests)
Orchestrator OrchestratorBridge // 编排器桥接(见 tools_orchestrator.go);可为 nil(禁用时)
CodeIndex codeindex.Service // 代码索引 RAG(见 tools_codeindex.go);可为 nil(禁用时)
Memory projectmemory.Service // 项目记忆(见 tools_memory.go);可为 nil(禁用时)
}
// NewMCPServer 装配 SDK Server 实例并注册所有工具/prompts/resources。
@@ -41,8 +49,12 @@ func NewMCPServer(deps ServerDeps) *mcpsdk.Server {
registerResources(srv, deps)
// Phase H: workspace / worktree / git、ACP、run profiles
registerWorkspaceTools(srv, deps)
registerChangeRequestTools(srv, deps)
registerACPTools(srv, deps)
registerRunTools(srv, deps)
registerOrchestratorTools(srv, deps)
registerCodeIndexTools(srv, deps)
registerMemoryTools(srv, deps)
return srv
}
+240 -17
View File
@@ -8,6 +8,7 @@ import (
"net/netip"
"github.com/jackc/pgx/v5/pgtype"
"github.com/pgvector/pgvector-go"
)
type AcpAgentKind struct {
@@ -25,6 +26,11 @@ type AcpAgentKind struct {
ToolAllowlist []string `json:"tool_allowlist"`
ClientType string `json:"client_type"`
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
ModelID pgtype.UUID `json:"model_id"`
MaxCostUsd pgtype.Numeric `json:"max_cost_usd"`
MaxTokens *int64 `json:"max_tokens"`
MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"`
KeyVersion int16 `json:"key_version"`
}
type AcpAgentKindConfigFile struct {
@@ -35,6 +41,7 @@ type AcpAgentKindConfigFile struct {
UpdatedBy pgtype.UUID `json:"updated_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
KeyVersion int16 `json:"key_version"`
}
type AcpEvent struct {
@@ -64,23 +71,64 @@ type AcpPermissionRequest struct {
}
type AcpSession struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
ProjectID pgtype.UUID `json:"project_id"`
AgentKindID pgtype.UUID `json:"agent_kind_id"`
UserID pgtype.UUID `json:"user_id"`
IssueID pgtype.UUID `json:"issue_id"`
RequirementID pgtype.UUID `json:"requirement_id"`
AgentSessionID *string `json:"agent_session_id"`
Branch string `json:"branch"`
CwdPath string `json:"cwd_path"`
IsMainWorktree bool `json:"is_main_worktree"`
Status string `json:"status"`
Pid *int32 `json:"pid"`
ExitCode *int32 `json:"exit_code"`
LastError *string `json:"last_error"`
StartedAt pgtype.Timestamptz `json:"started_at"`
EndedAt pgtype.Timestamptz `json:"ended_at"`
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
ProjectID pgtype.UUID `json:"project_id"`
AgentKindID pgtype.UUID `json:"agent_kind_id"`
UserID pgtype.UUID `json:"user_id"`
IssueID pgtype.UUID `json:"issue_id"`
RequirementID pgtype.UUID `json:"requirement_id"`
AgentSessionID *string `json:"agent_session_id"`
Branch string `json:"branch"`
CwdPath string `json:"cwd_path"`
IsMainWorktree bool `json:"is_main_worktree"`
Status string `json:"status"`
Pid *int32 `json:"pid"`
ExitCode *int32 `json:"exit_code"`
LastError *string `json:"last_error"`
StartedAt pgtype.Timestamptz `json:"started_at"`
EndedAt pgtype.Timestamptz `json:"ended_at"`
LastStopReason *string `json:"last_stop_reason"`
OrchestratorStepID pgtype.UUID `json:"orchestrator_step_id"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
ThinkingTokens int64 `json:"thinking_tokens"`
TotalCostUsd pgtype.Numeric `json:"total_cost_usd"`
LastActivityAt pgtype.Timestamptz `json:"last_activity_at"`
BudgetMaxCostUsd pgtype.Numeric `json:"budget_max_cost_usd"`
BudgetMaxTokens *int64 `json:"budget_max_tokens"`
BudgetMaxWallClockSeconds *int32 `json:"budget_max_wall_clock_seconds"`
TerminatedReason *string `json:"terminated_reason"`
SandboxMode *string `json:"sandbox_mode"`
CostUsd pgtype.Numeric `json:"cost_usd"`
TokensTotal int64 `json:"tokens_total"`
}
type AcpSessionUsage struct {
ID int64 `json:"id"`
SessionID pgtype.UUID `json:"session_id"`
UserID pgtype.UUID `json:"user_id"`
ProjectID pgtype.UUID `json:"project_id"`
AgentKindID pgtype.UUID `json:"agent_kind_id"`
ModelID pgtype.UUID `json:"model_id"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
ThinkingTokens int64 `json:"thinking_tokens"`
CostUsd pgtype.Numeric `json:"cost_usd"`
SourceEventID *int64 `json:"source_event_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type AcpTurn struct {
ID int64 `json:"id"`
SessionID pgtype.UUID `json:"session_id"`
TurnIndex int32 `json:"turn_index"`
PromptRequestID *string `json:"prompt_request_id"`
Status string `json:"status"`
StopReason *string `json:"stop_reason"`
UpdateCount int32 `json:"update_count"`
StartedAt pgtype.Timestamptz `json:"started_at"`
CompletedAt pgtype.Timestamptz `json:"completed_at"`
}
type AuditLog struct {
@@ -94,6 +142,81 @@ type AuditLog struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type ChangeRequest struct {
ID pgtype.UUID `json:"id"`
ProjectID pgtype.UUID `json:"project_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
RequirementID pgtype.UUID `json:"requirement_id"`
IssueID pgtype.UUID `json:"issue_id"`
Number int32 `json:"number"`
Title string `json:"title"`
Description string `json:"description"`
SourceBranch string `json:"source_branch"`
TargetBranch string `json:"target_branch"`
State string `json:"state"`
ReviewVerdict string `json:"review_verdict"`
CiState string `json:"ci_state"`
Provider string `json:"provider"`
ExternalID *int64 `json:"external_id"`
ExternalUrl string `json:"external_url"`
MergeCommitSha string `json:"merge_commit_sha"`
ReviewedBy pgtype.UUID `json:"reviewed_by"`
ReviewedAt pgtype.Timestamptz `json:"reviewed_at"`
CreatedBy pgtype.UUID `json:"created_by"`
MergedAt pgtype.Timestamptz `json:"merged_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type CodeChunk struct {
ID pgtype.UUID `json:"id"`
RunID pgtype.UUID `json:"run_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
CommitSha string `json:"commit_sha"`
FilePath string `json:"file_path"`
Lang *string `json:"lang"`
StartLine int32 `json:"start_line"`
EndLine int32 `json:"end_line"`
Content string `json:"content"`
ContentSha []byte `json:"content_sha"`
TokenCount *int32 `json:"token_count"`
Embedding *pgvector.Vector `json:"embedding"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CodeIndexRun struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
WorktreeID pgtype.UUID `json:"worktree_id"`
Branch string `json:"branch"`
CommitSha string `json:"commit_sha"`
Status string `json:"status"`
EmbeddingEndpointID pgtype.UUID `json:"embedding_endpoint_id"`
EmbeddingModel string `json:"embedding_model"`
Dims int32 `json:"dims"`
FilesIndexed int32 `json:"files_indexed"`
ChunksIndexed int32 `json:"chunks_indexed"`
Error *string `json:"error"`
StartedAt pgtype.Timestamptz `json:"started_at"`
FinishedAt pgtype.Timestamptz `json:"finished_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CommitSummary struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
WorktreeID pgtype.UUID `json:"worktree_id"`
Branch string `json:"branch"`
CommitSha string `json:"commit_sha"`
Kind string `json:"kind"`
Title *string `json:"title"`
BodyMd string `json:"body_md"`
Model *string `json:"model"`
Status string `json:"status"`
Error *string `json:"error"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Conversation struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"`
@@ -110,6 +233,14 @@ type Conversation struct {
RequirementID pgtype.UUID `json:"requirement_id"`
}
type CryptoKey struct {
Version int32 `json:"version"`
Provider string `json:"provider"`
KeyRef *string `json:"key_ref"`
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type GitCredential struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
@@ -119,6 +250,7 @@ type GitCredential struct {
Fingerprint *string `json:"fingerprint"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
KeyVersion int16 `json:"key_version"`
}
type Issue struct {
@@ -135,6 +267,17 @@ type Issue struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
ParentID pgtype.UUID `json:"parent_id"`
Priority int32 `json:"priority"`
}
type IssueDependency struct {
ID pgtype.UUID `json:"id"`
ProjectID pgtype.UUID `json:"project_id"`
BlockedID pgtype.UUID `json:"blocked_id"`
BlockerID pgtype.UUID `json:"blocker_id"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Job struct {
@@ -162,6 +305,7 @@ type LlmEndpoint struct {
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
KeyVersion int16 `json:"key_version"`
}
type LlmModel struct {
@@ -252,6 +396,50 @@ type Notification struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type NotificationPref struct {
UserID pgtype.UUID `json:"user_id"`
EmailEnabled bool `json:"email_enabled"`
ImEnabled bool `json:"im_enabled"`
ImWebhookUrlEnc []byte `json:"im_webhook_url_enc"`
MinSeverity string `json:"min_severity"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type OrchestratorRun struct {
ID pgtype.UUID `json:"id"`
ProjectID pgtype.UUID `json:"project_id"`
RequirementID pgtype.UUID `json:"requirement_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
OwnerID pgtype.UUID `json:"owner_id"`
Status string `json:"status"`
CurrentPhase string `json:"current_phase"`
Config []byte `json:"config"`
LastError *string `json:"last_error"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
EndedAt pgtype.Timestamptz `json:"ended_at"`
}
type OrchestratorStep struct {
ID pgtype.UUID `json:"id"`
RunID pgtype.UUID `json:"run_id"`
Phase string `json:"phase"`
Attempt int32 `json:"attempt"`
ParentStepID pgtype.UUID `json:"parent_step_id"`
Depth int32 `json:"depth"`
Status string `json:"status"`
AgentKindID pgtype.UUID `json:"agent_kind_id"`
AcpSessionID pgtype.UUID `json:"acp_session_id"`
Prompt string `json:"prompt"`
StopReason *string `json:"stop_reason"`
GateResult []byte `json:"gate_result"`
LastError *string `json:"last_error"`
JobID pgtype.UUID `json:"job_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
EndedAt pgtype.Timestamptz `json:"ended_at"`
}
type Project struct {
ID pgtype.UUID `json:"id"`
Slug string `json:"slug"`
@@ -264,6 +452,30 @@ type Project struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type ProjectMember struct {
ProjectID pgtype.UUID `json:"project_id"`
UserID pgtype.UUID `json:"user_id"`
Role string `json:"role"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
AddedBy pgtype.UUID `json:"added_by"`
}
type ProjectMemory struct {
ID pgtype.UUID `json:"id"`
ProjectID pgtype.UUID `json:"project_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Kind string `json:"kind"`
Title string `json:"title"`
Body string `json:"body"`
Tags []string `json:"tags"`
Source string `json:"source"`
Embedding *pgvector.Vector `json:"embedding"`
EmbeddingModel *string `json:"embedding_model"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type PromptTemplate struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
@@ -299,6 +511,16 @@ type RequirementArtifact struct {
SourceMessageID *int64 `json:"source_message_id"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
Verdict string `json:"verdict"`
}
type RequirementPhasePointer struct {
RequirementID pgtype.UUID `json:"requirement_id"`
Phase string `json:"phase"`
ApprovedArtifactID pgtype.UUID `json:"approved_artifact_id"`
ApprovedBy pgtype.UUID `json:"approved_by"`
ApprovedAt pgtype.Timestamptz `json:"approved_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type User struct {
@@ -354,6 +576,7 @@ type WorkspaceRunProfile struct {
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
KeyVersion int16 `json:"key_version"`
}
type WorkspaceWorktree struct {
+13
View File
@@ -61,8 +61,21 @@ var defaultSystemTools = []string{
"list_projects", "list_workspaces", "list_requirements", "list_issues",
"get_issue", "get_requirement",
"create_issue", "update_issue", "close_issue", "reopen_issue", "assign_issue",
// 任务分解 / 依赖图:规划阶段 agent 据此发出子任务与依赖边(autonomy roadmap §10)。
// create_acp_session 仍对 system token 禁用(checkNotSystemToken 不放松);并行扇出由
// 服务端调度器以特权身份代办。
"create_subtask", "add_dependency", "remove_dependency", "list_dependencies", "submit_decomposition",
"create_requirement", "update_requirement", "close_requirement", "reopen_requirement", "set_requirement_phase",
// 注意:approve_requirement_artifact 故意不在默认白名单——产物审批是阶段网关,
// 与权限审批 / PR 评审一致,必须由人工(owner/admin)完成,agent 不得自审自批。
"add_requirement_artifact", "list_requirement_artifacts",
// 一次性 exec:agent 自验证(编译 / 测试)闭环。
"run_command", "run_tests",
// 只读 git 检视:让自验证闭环能查看自己改了什么(git_diff/git_status 均只读,安全)。
"git_status", "git_diff",
// 受控委派:in-session agent 可请求子任务(深度/扇出在事务内受限)。仅 system token
// 且持有 session 时可用,不破坏 checkNotSystemToken(后者禁的是「创建新 ACP 会话」)。
"request_subtask",
}
// NewTokenService 构造 service。now 用于测试注入;nil 时默认 time.Now。
+261 -1
View File
@@ -42,8 +42,26 @@ type ACPSession struct {
IsMainWorktree bool
Status string
LastError *string
LastStopReason *string
StartedAt time.Time
EndedAt *time.Time
// 成本核算(只读):运行时累计 token/cost + 有效预算上限。
PromptTokens int64
CompletionTokens int64
ThinkingTokens int64
TotalCostUSD float64
BudgetMaxCostUSD *float64
BudgetMaxTokens *int64
}
// ACPTurn 是 ACP 回合记录的 MCP 投影(只读)。
type ACPTurn struct {
TurnIndex int
Status string
StopReason *string
UpdateCount int
StartedAt time.Time
CompletedAt *time.Time
}
// ACPPermission 是待审权限请求的 MCP 投影(只读,不含原始 toolCall/options JSON)。
@@ -72,6 +90,38 @@ type ACPSessionFilter struct {
RequirementID *uuid.UUID
}
// ACPRunDashboardInput 是 run-history 汇总入参(service 在 caller scope 内归一化)。
type ACPRunDashboardInput struct {
ProjectID *uuid.UUID
RequirementID *uuid.UUID
From time.Time
To time.Time
}
// ACPRunDashboard 是 run-history 汇总(计数 + 成功率 + 成本 + 时长 + 按天序列)。
type ACPRunDashboard struct {
From time.Time
To time.Time
Total int64
Succeeded int64
Crashed int64
Active int64
SuccessRate float64
TotalCostUSD float64
TotalTokens int64
AvgDurationSeconds float64
ByDay []ACPRunDayRow
}
// ACPRunDayRow 是 run-history 按天的一行。
type ACPRunDayRow struct {
Day time.Time
Total int64
Succeeded int64
Crashed int64
TotalCostUSD float64
}
// ACPBridge 是 mcp 调用 acp 服务的桥接接口。
type ACPBridge interface {
ListAgentKinds(ctx context.Context, userID uuid.UUID, isAdmin bool) ([]ACPAgentKind, error)
@@ -81,6 +131,11 @@ type ACPBridge interface {
ListSessions(ctx context.Context, userID uuid.UUID, isAdmin bool, f ACPSessionFilter) ([]ACPSession, error)
TerminateSession(ctx context.Context, userID uuid.UUID, isAdmin bool, id uuid.UUID) error
ListPendingPermissions(ctx context.Context, userID uuid.UUID, isAdmin bool, sessionID uuid.UUID) ([]ACPPermission, error)
// ListPendingApprovals 返回调用者跨所有会话的待审权限请求(HITL inbox,read-only)。
ListPendingApprovals(ctx context.Context, userID uuid.UUID, isAdmin bool) ([]ACPPermission, error)
ListSessionTurns(ctx context.Context, userID uuid.UUID, isAdmin bool, sessionID uuid.UUID) ([]ACPTurn, error)
// GetRunDashboard 返回 run-history 汇总(owner scope;admin 全量),read-only。
GetRunDashboard(ctx context.Context, userID uuid.UUID, isAdmin bool, in ACPRunDashboardInput) (*ACPRunDashboard, error)
}
// registerACPTools 注册 ACP 相关 MCP 工具。
@@ -92,6 +147,9 @@ func registerACPTools(srv *mcpsdk.Server, deps ServerDeps) {
registerListACPSessions(srv, deps)
registerTerminateACPSession(srv, deps)
registerListPendingPermissions(srv, deps)
registerListPendingApprovals(srv, deps)
registerGetACPSessionTurns(srv, deps)
registerGetRunDashboard(srv, deps)
}
// ===== DTO =====
@@ -124,8 +182,16 @@ type acpSessionDetail struct {
IsMainWorktree bool `json:"is_main_worktree"`
Status string `json:"status"`
LastError string `json:"last_error,omitempty"`
LastStopReason string `json:"last_stop_reason,omitempty"`
StartedAt string `json:"started_at"`
EndedAt string `json:"ended_at,omitempty"`
// 成本核算(只读)。
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
ThinkingTokens int64 `json:"thinking_tokens"`
TotalCostUSD float64 `json:"total_cost_usd"`
BudgetMaxCostUSD *float64 `json:"budget_max_cost_usd,omitempty"`
BudgetMaxTokens *int64 `json:"budget_max_tokens,omitempty"`
}
func acpSessionDetailFrom(s *ACPSession) acpSessionDetail {
@@ -134,7 +200,13 @@ func acpSessionDetailFrom(s *ACPSession) acpSessionDetail {
ProjectID: s.ProjectID.String(), AgentKindID: s.AgentKindID.String(),
UserID: s.UserID.String(), Branch: s.Branch,
IsMainWorktree: s.IsMainWorktree, Status: s.Status,
StartedAt: s.StartedAt.Format("2006-01-02T15:04:05Z07:00"),
StartedAt: s.StartedAt.Format("2006-01-02T15:04:05Z07:00"),
PromptTokens: s.PromptTokens,
CompletionTokens: s.CompletionTokens,
ThinkingTokens: s.ThinkingTokens,
TotalCostUSD: s.TotalCostUSD,
BudgetMaxCostUSD: s.BudgetMaxCostUSD,
BudgetMaxTokens: s.BudgetMaxTokens,
}
if s.IssueID != nil {
d.IssueID = s.IssueID.String()
@@ -145,6 +217,9 @@ func acpSessionDetailFrom(s *ACPSession) acpSessionDetail {
if s.LastError != nil {
d.LastError = *s.LastError
}
if s.LastStopReason != nil {
d.LastStopReason = *s.LastStopReason
}
if s.EndedAt != nil {
d.EndedAt = s.EndedAt.Format("2006-01-02T15:04:05Z07:00")
}
@@ -432,3 +507,188 @@ func registerListPendingPermissions(srv *mcpsdk.Server, deps ServerDeps) {
return nil, out, nil
})
}
// ===== list_pending_approvals (cross-session HITL inbox) =====
func registerListPendingApprovals(srv *mcpsdk.Server, deps ServerDeps) {
mcpsdk.AddTool(srv,
&mcpsdk.Tool{Name: "list_pending_approvals", Description: "List the caller's pending tool-permission requests across all their ACP sessions (read-only HITL inbox; approval stays with humans in the web console)."},
func(ctx context.Context, _ *mcpsdk.CallToolRequest, _ struct{}) (*mcpsdk.CallToolResult, listPendingPermissionsOut, error) {
if err := CheckToolFromCtx(ctx, "list_pending_approvals"); err != nil {
return nil, listPendingPermissionsOut{}, err
}
c, err := deps.Caller.Resolve(ctx)
if err != nil {
return nil, listPendingPermissionsOut{}, err
}
ps, err := deps.ACP.ListPendingApprovals(ctx, c.UserID, c.IsAdmin)
if err != nil {
return nil, listPendingPermissionsOut{}, err
}
out := listPendingPermissionsOut{Permissions: make([]permissionDetail, 0, len(ps))}
for _, p := range ps {
out.Permissions = append(out.Permissions, permissionDetail{
ID: p.ID.String(), SessionID: p.SessionID.String(),
AgentRequestID: p.AgentRequestID, ToolName: p.ToolName,
Status: p.Status,
CreatedAt: p.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
})
}
return nil, out, nil
})
}
// ===== get_acp_session_turns =====
type acpTurnDetail struct {
TurnIndex int `json:"turn_index"`
Status string `json:"status"`
StopReason string `json:"stop_reason,omitempty"`
UpdateCount int `json:"update_count"`
StartedAt string `json:"started_at"`
CompletedAt string `json:"completed_at,omitempty"`
}
type getACPSessionTurnsOut struct {
Turns []acpTurnDetail `json:"turns"`
}
func registerGetACPSessionTurns(srv *mcpsdk.Server, deps ServerDeps) {
mcpsdk.AddTool(srv,
&mcpsdk.Tool{Name: "get_acp_session_turns", Description: "List the agent turns of an ACP session (read-only; turn_index, status, stop_reason, update_count, timestamps)."},
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in getACPSessionIn) (*mcpsdk.CallToolResult, getACPSessionTurnsOut, error) {
if err := CheckToolFromCtx(ctx, "get_acp_session_turns"); err != nil {
return nil, getACPSessionTurnsOut{}, err
}
c, err := deps.Caller.Resolve(ctx)
if err != nil {
return nil, getACPSessionTurnsOut{}, err
}
s, err := scopedACPSession(ctx, deps, c.UserID, c.IsAdmin, in.SessionID)
if err != nil {
return nil, getACPSessionTurnsOut{}, err
}
ts, err := deps.ACP.ListSessionTurns(ctx, c.UserID, c.IsAdmin, s.ID)
if err != nil {
return nil, getACPSessionTurnsOut{}, err
}
out := getACPSessionTurnsOut{Turns: make([]acpTurnDetail, 0, len(ts))}
for _, t := range ts {
d := acpTurnDetail{
TurnIndex: t.TurnIndex,
Status: t.Status,
UpdateCount: t.UpdateCount,
StartedAt: t.StartedAt.Format("2006-01-02T15:04:05Z07:00"),
}
if t.StopReason != nil {
d.StopReason = *t.StopReason
}
if t.CompletedAt != nil {
d.CompletedAt = t.CompletedAt.Format("2006-01-02T15:04:05Z07:00")
}
out.Turns = append(out.Turns, d)
}
return nil, out, nil
})
}
// ===== get_run_dashboard (run-history rollup) =====
type getRunDashboardIn struct {
// 可选过滤;留空表示不按该维度过滤。from/to 为 RFC3339;留空走默认 30 天窗口。
ProjectID string `json:"project_id,omitempty"`
RequirementID string `json:"requirement_id,omitempty"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
}
type runDayDetail struct {
Day string `json:"day"`
Total int64 `json:"total"`
Succeeded int64 `json:"succeeded"`
Crashed int64 `json:"crashed"`
TotalCostUSD float64 `json:"total_cost_usd"`
}
type getRunDashboardOut struct {
From string `json:"from"`
To string `json:"to"`
Total int64 `json:"total"`
Succeeded int64 `json:"succeeded"`
Crashed int64 `json:"crashed"`
Active int64 `json:"active"`
SuccessRate float64 `json:"success_rate"`
TotalCostUSD float64 `json:"total_cost_usd"`
TotalTokens int64 `json:"total_tokens"`
AvgDurationSeconds float64 `json:"avg_duration_seconds"`
ByDay []runDayDetail `json:"by_day"`
}
func registerGetRunDashboard(srv *mcpsdk.Server, deps ServerDeps) {
mcpsdk.AddTool(srv,
&mcpsdk.Tool{Name: "get_run_dashboard", Description: "Get a run-history rollup of ACP sessions (read-only): counts, success rate, total cost/tokens, avg duration, and a per-day series. Scoped to the caller's own sessions (admins see all). Optional filters: project_id, requirement_id, from/to (RFC3339)."},
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in getRunDashboardIn) (*mcpsdk.CallToolResult, getRunDashboardOut, error) {
if err := CheckToolFromCtx(ctx, "get_run_dashboard"); err != nil {
return nil, getRunDashboardOut{}, err
}
c, err := deps.Caller.Resolve(ctx)
if err != nil {
return nil, getRunDashboardOut{}, err
}
var di ACPRunDashboardInput
if in.ProjectID != "" {
id, perr := uuid.Parse(in.ProjectID)
if perr != nil {
return nil, getRunDashboardOut{}, errs.Wrap(perr, errs.CodeInvalidInput, "project_id parse")
}
di.ProjectID = &id
}
if in.RequirementID != "" {
id, perr := uuid.Parse(in.RequirementID)
if perr != nil {
return nil, getRunDashboardOut{}, errs.Wrap(perr, errs.CodeInvalidInput, "requirement_id parse")
}
di.RequirementID = &id
}
if in.From != "" {
t, terr := time.Parse(time.RFC3339, in.From)
if terr != nil {
return nil, getRunDashboardOut{}, errs.Wrap(terr, errs.CodeInvalidInput, "from parse")
}
di.From = t
}
if in.To != "" {
t, terr := time.Parse(time.RFC3339, in.To)
if terr != nil {
return nil, getRunDashboardOut{}, errs.Wrap(terr, errs.CodeInvalidInput, "to parse")
}
di.To = t
}
d, err := deps.ACP.GetRunDashboard(ctx, c.UserID, c.IsAdmin, di)
if err != nil {
return nil, getRunDashboardOut{}, err
}
out := getRunDashboardOut{
From: d.From.Format("2006-01-02T15:04:05Z07:00"),
To: d.To.Format("2006-01-02T15:04:05Z07:00"),
Total: d.Total,
Succeeded: d.Succeeded,
Crashed: d.Crashed,
Active: d.Active,
SuccessRate: d.SuccessRate,
TotalCostUSD: d.TotalCostUSD,
TotalTokens: d.TotalTokens,
AvgDurationSeconds: d.AvgDurationSeconds,
ByDay: make([]runDayDetail, 0, len(d.ByDay)),
}
for _, r := range d.ByDay {
out.ByDay = append(out.ByDay, runDayDetail{
Day: r.Day.Format("2006-01-02T15:04:05Z07:00"),
Total: r.Total,
Succeeded: r.Succeeded,
Crashed: r.Crashed,
TotalCostUSD: r.TotalCostUSD,
})
}
return nil, out, nil
})
}
+167
View File
@@ -21,6 +21,9 @@ type fakeACPBridge struct {
sessions []ACPSession
perms []ACPPermission
terminated []uuid.UUID
turns map[uuid.UUID][]ACPTurn
dashboard *ACPRunDashboard
dashIn ACPRunDashboardInput
}
func (f *fakeACPBridge) ListAgentKinds(_ context.Context, _ uuid.UUID, _ bool) ([]ACPAgentKind, error) {
@@ -70,6 +73,21 @@ func (f *fakeACPBridge) ListPendingPermissions(_ context.Context, _ uuid.UUID, _
}
return out, nil
}
func (f *fakeACPBridge) ListPendingApprovals(_ context.Context, _ uuid.UUID, _ bool) ([]ACPPermission, error) {
out := make([]ACPPermission, 0, len(f.perms))
out = append(out, f.perms...)
return out, nil
}
func (f *fakeACPBridge) ListSessionTurns(_ context.Context, _ uuid.UUID, _ bool, sessionID uuid.UUID) ([]ACPTurn, error) {
return f.turns[sessionID], nil
}
func (f *fakeACPBridge) GetRunDashboard(_ context.Context, _ uuid.UUID, _ bool, in ACPRunDashboardInput) (*ACPRunDashboard, error) {
f.dashIn = in
if f.dashboard != nil {
return f.dashboard, nil
}
return &ACPRunDashboard{From: time.Now().Add(-24 * time.Hour), To: time.Now()}, nil
}
func acpTestDeps() (ServerDeps, *fakeACPBridge) {
deps := testDeps()
@@ -216,3 +234,152 @@ func TestListPendingPermissions(t *testing.T) {
assert.Equal(t, "fs/write", out.Permissions[0].ToolName)
assert.Equal(t, "pending", out.Permissions[0].Status)
}
func TestListPendingApprovals(t *testing.T) {
deps, bridge := acpTestDeps()
sidA := uuid.New()
sidB := uuid.New()
bridge.perms = []ACPPermission{
{ID: uuid.New(), SessionID: sidA, AgentRequestID: "r1", ToolName: "fs/write", Status: "pending", CreatedAt: time.Now()},
{ID: uuid.New(), SessionID: sidB, AgentRequestID: "r2", ToolName: "shell/exec", Status: "pending", CreatedAt: time.Now()},
}
result, err := callTool(ctxWithAuth(Scope{}), deps, "list_pending_approvals", map[string]any{})
require.NoError(t, err)
require.False(t, result.IsError)
var out listPendingPermissionsOut
unmarshalStructured(t, result, &out)
// Cross-session: both pending requests returned regardless of session.
require.Len(t, out.Permissions, 2)
}
func TestGetACPSession_IncludesLastStopReason(t *testing.T) {
deps, bridge := acpTestDeps()
sid := uuid.New()
reason := "end_turn"
bridge.sessions = []ACPSession{{
ID: sid, WorkspaceID: testWSID, ProjectID: testPID,
AgentKindID: uuid.New(), UserID: testUID, Status: "running",
LastStopReason: &reason, StartedAt: time.Now(),
}}
result, err := callTool(ctxWithAuth(Scope{}), deps, "get_acp_session", map[string]any{
"session_id": sid.String(),
})
require.NoError(t, err)
require.False(t, result.IsError)
var out acpSessionDetail
unmarshalStructured(t, result, &out)
assert.Equal(t, "end_turn", out.LastStopReason)
}
func TestGetACPSessionTurns(t *testing.T) {
deps, bridge := acpTestDeps()
sid := uuid.New()
bridge.sessions = []ACPSession{{
ID: sid, WorkspaceID: testWSID, ProjectID: testPID,
AgentKindID: uuid.New(), UserID: testUID, Status: "running", StartedAt: time.Now(),
}}
stop := "end_turn"
completed := time.Now()
bridge.turns = map[uuid.UUID][]ACPTurn{
sid: {
{TurnIndex: 0, Status: "completed", StopReason: &stop, UpdateCount: 3, StartedAt: time.Now(), CompletedAt: &completed},
{TurnIndex: 1, Status: "in_progress", UpdateCount: 0, StartedAt: time.Now()},
},
}
result, err := callTool(ctxWithAuth(Scope{}), deps, "get_acp_session_turns", map[string]any{
"session_id": sid.String(),
})
require.NoError(t, err)
require.False(t, result.IsError)
var out getACPSessionTurnsOut
unmarshalStructured(t, result, &out)
require.Len(t, out.Turns, 2)
assert.Equal(t, 0, out.Turns[0].TurnIndex)
assert.Equal(t, "completed", out.Turns[0].Status)
assert.Equal(t, "end_turn", out.Turns[0].StopReason)
assert.Equal(t, 3, out.Turns[0].UpdateCount)
assert.Equal(t, "in_progress", out.Turns[1].Status)
assert.Empty(t, out.Turns[1].StopReason)
}
func TestGetACPSessionTurns_CrossProjectDenied(t *testing.T) {
deps, bridge := acpTestDeps()
sid := uuid.New()
otherProject := uuid.New()
bridge.sessions = []ACPSession{{
ID: sid, WorkspaceID: testWSID, ProjectID: otherProject,
AgentKindID: uuid.New(), UserID: testUID, Status: "running", StartedAt: time.Now(),
}}
// caller scope 限定到 testPID,session 属于 otherProject → 拒绝
result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{testPID}}), deps,
"get_acp_session_turns", map[string]any{"session_id": sid.String()})
require.NoError(t, err)
assert.True(t, result.IsError, "cross-project access must be denied")
}
func TestGetRunDashboard(t *testing.T) {
deps, bridge := acpTestDeps()
day := time.Now().UTC()
bridge.dashboard = &ACPRunDashboard{
From: day.Add(-24 * time.Hour), To: day,
Total: 6, Succeeded: 4, Crashed: 2, Active: 0,
SuccessRate: 4.0 / 6.0, TotalCostUSD: 2.25, TotalTokens: 1500,
AvgDurationSeconds: 42.5,
ByDay: []ACPRunDayRow{
{Day: day, Total: 6, Succeeded: 4, Crashed: 2, TotalCostUSD: 2.25},
},
}
result, err := callTool(ctxWithAuth(Scope{}), deps, "get_run_dashboard", map[string]any{})
require.NoError(t, err)
require.False(t, result.IsError, "body=%v", result)
var out getRunDashboardOut
unmarshalStructured(t, result, &out)
assert.Equal(t, int64(6), out.Total)
assert.Equal(t, int64(4), out.Succeeded)
assert.Equal(t, int64(2), out.Crashed)
assert.InDelta(t, 4.0/6.0, out.SuccessRate, 1e-9)
assert.InDelta(t, 2.25, out.TotalCostUSD, 1e-9)
assert.Equal(t, int64(1500), out.TotalTokens)
assert.InDelta(t, 42.5, out.AvgDurationSeconds, 1e-9)
require.Len(t, out.ByDay, 1)
assert.Equal(t, int64(6), out.ByDay[0].Total)
}
func TestGetRunDashboard_PassesFilters(t *testing.T) {
deps, bridge := acpTestDeps()
pid := uuid.New()
reqID := uuid.New()
from := time.Now().Add(-72 * time.Hour).UTC().Truncate(time.Second)
to := time.Now().UTC().Truncate(time.Second)
result, err := callTool(ctxWithAuth(Scope{}), deps, "get_run_dashboard", map[string]any{
"project_id": pid.String(),
"requirement_id": reqID.String(),
"from": from.Format(time.RFC3339),
"to": to.Format(time.RFC3339),
})
require.NoError(t, err)
require.False(t, result.IsError, "body=%v", result)
require.NotNil(t, bridge.dashIn.ProjectID)
assert.Equal(t, pid, *bridge.dashIn.ProjectID)
require.NotNil(t, bridge.dashIn.RequirementID)
assert.Equal(t, reqID, *bridge.dashIn.RequirementID)
assert.True(t, from.Equal(bridge.dashIn.From))
assert.True(t, to.Equal(bridge.dashIn.To))
}
func TestGetRunDashboard_BadFilter(t *testing.T) {
deps, _ := acpTestDeps()
result, err := callTool(ctxWithAuth(Scope{}), deps, "get_run_dashboard", map[string]any{
"project_id": "not-a-uuid",
})
require.NoError(t, err)
assert.True(t, result.IsError, "invalid project_id must error")
}
+234
View File
@@ -0,0 +1,234 @@
package mcp
import (
"context"
"github.com/google/uuid"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/yan1h/agent-coding-workflow/internal/changerequest"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
)
// registerChangeRequestTools registers the change-request (PR) tools.
//
// Gate boundary (spec decision): create_pull_request / merge_pull_request /
// get_pr_status / list_pull_requests are exposed, but review approval is NOT an
// MCP tool — it is HTTP/web-only so an agent cannot self-approve its own PR and
// bypass the merge gate. merge_pull_request calls ChangeReqs.Merge, which
// hard-blocks unless review_verdict=='approved' (surfaced as a FailedPrecondition
// MCP error). Operators may further exclude create/merge from agent system-token
// scopes via the token whitelist.
func registerChangeRequestTools(srv *mcpsdk.Server, deps ServerDeps) {
registerCreatePullRequest(srv, deps)
registerGetPRStatus(srv, deps)
registerMergePullRequest(srv, deps)
registerListPullRequests(srv, deps)
}
// ===== shared DTO =====
type changeRequestDetail struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
WorkspaceID string `json:"workspace_id"`
Number int `json:"number"`
Title string `json:"title"`
SourceBranch string `json:"source_branch"`
TargetBranch string `json:"target_branch"`
State string `json:"state"`
ReviewVerdict string `json:"review_verdict"`
CIState string `json:"ci_state"`
ExternalID int64 `json:"external_id,omitempty"`
ExternalURL string `json:"external_url,omitempty"`
MergeSHA string `json:"merge_commit_sha,omitempty"`
}
func changeRequestDetailFrom(cr *changerequest.ChangeRequest) changeRequestDetail {
d := changeRequestDetail{
ID: cr.ID.String(), ProjectID: cr.ProjectID.String(), WorkspaceID: cr.WorkspaceID.String(),
Number: cr.Number, Title: cr.Title,
SourceBranch: cr.SourceBranch, TargetBranch: cr.TargetBranch,
State: string(cr.State), ReviewVerdict: string(cr.ReviewVerdict), CIState: string(cr.CIState),
ExternalURL: cr.ExternalURL, MergeSHA: cr.MergeCommitSHA,
}
if cr.ExternalID != nil {
d.ExternalID = *cr.ExternalID
}
return d
}
// resolvePRByNumber scopes the workspace (CheckToolFromCtx-style project gate)
// and finds the change request whose per-project number matches within that
// workspace. Returns NotFound when no such CR exists in scope.
func resolvePRByNumber(ctx context.Context, deps ServerDeps, workspaceID string, number int) (*changerequest.ChangeRequest, error) {
c, err := deps.Caller.Resolve(ctx)
if err != nil {
return nil, err
}
w, _, err := scopedWorkspace(ctx, deps, c, workspaceID)
if err != nil {
return nil, err
}
list, err := deps.ChangeReqs.ListByWorkspace(ctx, changerequest.Caller{UserID: c.UserID, IsAdmin: c.IsAdmin}, w.ID)
if err != nil {
return nil, err
}
for _, cr := range list {
if cr.Number == number {
return cr, nil
}
}
return nil, errs.New(errs.CodeNotFound, "change request not found in workspace")
}
// ===== create_pull_request =====
type createPRIn struct {
WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"`
SourceBranch string `json:"source_branch" jsonschema:"branch to merge from (non-empty)"`
TargetBranch string `json:"target_branch,omitempty" jsonschema:"branch to merge into (defaults to workspace default branch)"`
Title string `json:"title" jsonschema:"PR title (non-empty)"`
Description string `json:"description,omitempty"`
RequirementID string `json:"requirement_id,omitempty" jsonschema:"optional linked requirement UUID"`
IssueID string `json:"issue_id,omitempty" jsonschema:"optional linked issue UUID"`
}
type changeRequestOut struct {
OK bool `json:"ok"`
ChangeRequest changeRequestDetail `json:"change_request"`
}
func registerCreatePullRequest(srv *mcpsdk.Server, deps ServerDeps) {
mcpsdk.AddTool(srv,
&mcpsdk.Tool{Name: "create_pull_request", Description: "Open a pull request on the git host from a workspace branch and persist it as a change request. The PR must be approved by a human (web only) before it can be merged."},
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in createPRIn) (*mcpsdk.CallToolResult, changeRequestOut, error) {
if err := CheckToolFromCtx(ctx, "create_pull_request"); err != nil {
return nil, changeRequestOut{}, err
}
c, err := deps.Caller.Resolve(ctx)
if err != nil {
return nil, changeRequestOut{}, err
}
w, _, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID)
if err != nil {
return nil, changeRequestOut{}, err
}
input := changerequest.CreateInput{
SourceBranch: in.SourceBranch, TargetBranch: in.TargetBranch,
Title: in.Title, Description: in.Description,
}
if in.RequirementID != "" {
id, perr := uuid.Parse(in.RequirementID)
if perr != nil {
return nil, changeRequestOut{}, errs.Wrap(perr, errs.CodeInvalidInput, "requirement_id parse")
}
input.RequirementID = &id
}
if in.IssueID != "" {
id, perr := uuid.Parse(in.IssueID)
if perr != nil {
return nil, changeRequestOut{}, errs.Wrap(perr, errs.CodeInvalidInput, "issue_id parse")
}
input.IssueID = &id
}
cr, err := deps.ChangeReqs.Create(ctx, changerequest.Caller{UserID: c.UserID, IsAdmin: c.IsAdmin}, w.ID, input)
if err != nil {
return nil, changeRequestOut{}, err
}
return nil, changeRequestOut{OK: true, ChangeRequest: changeRequestDetailFrom(cr)}, nil
})
}
// ===== get_pr_status =====
type prNumberIn struct {
WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"`
Number int `json:"number" jsonschema:"per-project change-request number"`
}
func registerGetPRStatus(srv *mcpsdk.Server, deps ServerDeps) {
mcpsdk.AddTool(srv,
&mcpsdk.Tool{Name: "get_pr_status", Description: "Get a change request by number, refreshing its CI/merge state from the git host."},
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in prNumberIn) (*mcpsdk.CallToolResult, changeRequestOut, error) {
if err := CheckToolFromCtx(ctx, "get_pr_status"); err != nil {
return nil, changeRequestOut{}, err
}
cr, err := resolvePRByNumber(ctx, deps, in.WorkspaceID, in.Number)
if err != nil {
return nil, changeRequestOut{}, err
}
c, err := deps.Caller.Resolve(ctx)
if err != nil {
return nil, changeRequestOut{}, err
}
cr, err = deps.ChangeReqs.SyncStatus(ctx, changerequest.Caller{UserID: c.UserID, IsAdmin: c.IsAdmin}, cr.ID)
if err != nil {
return nil, changeRequestOut{}, err
}
return nil, changeRequestOut{OK: true, ChangeRequest: changeRequestDetailFrom(cr)}, nil
})
}
// ===== merge_pull_request (HARD-GATED) =====
type mergePRIn struct {
WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"`
Number int `json:"number" jsonschema:"per-project change-request number"`
Method string `json:"method,omitempty" jsonschema:"merge method: merge|squash|rebase"`
}
func registerMergePullRequest(srv *mcpsdk.Server, deps ServerDeps) {
mcpsdk.AddTool(srv,
&mcpsdk.Tool{Name: "merge_pull_request", Description: "Merge a change request on the git host. HARD-BLOCKED unless review_verdict=='approved' (approval is human/web-only); returns a failed_precondition error otherwise."},
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in mergePRIn) (*mcpsdk.CallToolResult, changeRequestOut, error) {
if err := CheckToolFromCtx(ctx, "merge_pull_request"); err != nil {
return nil, changeRequestOut{}, err
}
cr, err := resolvePRByNumber(ctx, deps, in.WorkspaceID, in.Number)
if err != nil {
return nil, changeRequestOut{}, err
}
c, err := deps.Caller.Resolve(ctx)
if err != nil {
return nil, changeRequestOut{}, err
}
cr, err = deps.ChangeReqs.Merge(ctx, changerequest.Caller{UserID: c.UserID, IsAdmin: c.IsAdmin}, cr.ID, in.Method)
if err != nil {
return nil, changeRequestOut{}, err
}
return nil, changeRequestOut{OK: true, ChangeRequest: changeRequestDetailFrom(cr)}, nil
})
}
// ===== list_pull_requests =====
type listPRsOut struct {
ChangeRequests []changeRequestDetail `json:"change_requests"`
}
func registerListPullRequests(srv *mcpsdk.Server, deps ServerDeps) {
mcpsdk.AddTool(srv,
&mcpsdk.Tool{Name: "list_pull_requests", Description: "List change requests (PRs) of a workspace."},
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in getWorkspaceIn) (*mcpsdk.CallToolResult, listPRsOut, error) {
if err := CheckToolFromCtx(ctx, "list_pull_requests"); err != nil {
return nil, listPRsOut{}, err
}
c, err := deps.Caller.Resolve(ctx)
if err != nil {
return nil, listPRsOut{}, err
}
w, _, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID)
if err != nil {
return nil, listPRsOut{}, err
}
out, err := deps.ChangeReqs.ListByWorkspace(ctx, changerequest.Caller{UserID: c.UserID, IsAdmin: c.IsAdmin}, w.ID)
if err != nil {
return nil, listPRsOut{}, err
}
res := listPRsOut{ChangeRequests: make([]changeRequestDetail, 0, len(out))}
for _, cr := range out {
res.ChangeRequests = append(res.ChangeRequests, changeRequestDetailFrom(cr))
}
return nil, res, nil
})
}
+200
View File
@@ -0,0 +1,200 @@
package mcp
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/changerequest"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
"github.com/yan1h/agent-coding-workflow/internal/workspace"
)
// fakeChangeReqSvc implements changerequest.Service.
type fakeChangeReqSvc struct {
items []*changerequest.ChangeRequest
mergeErr error
syncState changerequest.CIState
}
func (f *fakeChangeReqSvc) Create(_ context.Context, c changerequest.Caller, wsID uuid.UUID, in changerequest.CreateInput) (*changerequest.ChangeRequest, error) {
ext := int64(len(f.items) + 1)
cr := &changerequest.ChangeRequest{
ID: uuid.New(), ProjectID: testPID, WorkspaceID: wsID, Number: len(f.items) + 1,
Title: in.Title, SourceBranch: in.SourceBranch, TargetBranch: firstNonEmpty(in.TargetBranch, "main"),
State: changerequest.StateOpen, ReviewVerdict: changerequest.VerdictPending, CIState: changerequest.CIUnknown,
Provider: "gitea", ExternalID: &ext, ExternalURL: "http://h/pr", CreatedBy: c.UserID,
}
f.items = append(f.items, cr)
return cr, nil
}
func (f *fakeChangeReqSvc) Get(_ context.Context, _ changerequest.Caller, id uuid.UUID) (*changerequest.ChangeRequest, error) {
for _, cr := range f.items {
if cr.ID == id {
return cr, nil
}
}
return nil, errs.New(errs.CodeNotFound, "not found")
}
func (f *fakeChangeReqSvc) GetByNumber(_ context.Context, _ changerequest.Caller, _ string, n int) (*changerequest.ChangeRequest, error) {
for _, cr := range f.items {
if cr.Number == n {
return cr, nil
}
}
return nil, errs.New(errs.CodeNotFound, "not found")
}
func (f *fakeChangeReqSvc) ListByWorkspace(_ context.Context, _ changerequest.Caller, wsID uuid.UUID) ([]*changerequest.ChangeRequest, error) {
out := make([]*changerequest.ChangeRequest, 0, len(f.items))
for _, cr := range f.items {
if cr.WorkspaceID == wsID {
out = append(out, cr)
}
}
return out, nil
}
func (f *fakeChangeReqSvc) Review(_ context.Context, _ changerequest.Caller, id uuid.UUID, v changerequest.Verdict, _ string) (*changerequest.ChangeRequest, error) {
cr, err := f.Get(context.Background(), changerequest.Caller{}, id)
if err != nil {
return nil, err
}
cr.ReviewVerdict = v
return cr, nil
}
func (f *fakeChangeReqSvc) Merge(_ context.Context, _ changerequest.Caller, id uuid.UUID, _ string) (*changerequest.ChangeRequest, error) {
if f.mergeErr != nil {
return nil, f.mergeErr
}
cr, err := f.Get(context.Background(), changerequest.Caller{}, id)
if err != nil {
return nil, err
}
cr.State = changerequest.StateMerged
cr.MergeCommitSHA = "merged-sha"
return cr, nil
}
func (f *fakeChangeReqSvc) SyncStatus(_ context.Context, _ changerequest.Caller, id uuid.UUID) (*changerequest.ChangeRequest, error) {
cr, err := f.Get(context.Background(), changerequest.Caller{}, id)
if err != nil {
return nil, err
}
if f.syncState != "" {
cr.CIState = f.syncState
}
return cr, nil
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
// crTestDeps mirrors wsTestDeps but with a change-request service attached.
func crTestDeps() (ServerDeps, *fakeChangeReqSvc) {
deps := testDeps()
deps.Workspaces = &fakeWorkspaceSvc{items: []*workspace.Workspace{{
ID: testWSID, ProjectID: testPID, Slug: "ws-1", Name: "Workspace 1",
DefaultBranch: "main", SyncStatus: workspace.SyncStatusIdle, CreatedAt: time.Now(),
}}}
deps.Worktrees = &fakeWorktreeSvc{}
deps.GitOps = &fakeGitOpsSvc{}
crSvc := &fakeChangeReqSvc{}
deps.ChangeReqs = crSvc
return deps, crSvc
}
func TestCreatePullRequest_HappyPath(t *testing.T) {
deps, crSvc := crTestDeps()
result, err := callTool(ctxWithAuth(Scope{}), deps, "create_pull_request", map[string]any{
"workspace_id": testWSID.String(), "source_branch": "feature", "title": "Add X",
})
require.NoError(t, err)
require.False(t, result.IsError)
var out changeRequestOut
unmarshalStructured(t, result, &out)
assert.True(t, out.OK)
assert.Equal(t, "feature", out.ChangeRequest.SourceBranch)
require.Len(t, crSvc.items, 1)
}
func TestCreatePullRequest_ScopeDenied(t *testing.T) {
deps, _ := crTestDeps()
result, err := callTool(ctxWithAuth(Scope{Tools: []string{"git_diff"}}), deps, "create_pull_request", map[string]any{
"workspace_id": testWSID.String(), "source_branch": "feature", "title": "X",
})
require.NoError(t, err)
assert.True(t, result.IsError, "token scope without create_pull_request must deny")
}
func TestGetPRStatus(t *testing.T) {
deps, crSvc := crTestDeps()
crSvc.syncState = changerequest.CISuccess
// seed via create
_, err := callTool(ctxWithAuth(Scope{}), deps, "create_pull_request", map[string]any{
"workspace_id": testWSID.String(), "source_branch": "feature", "title": "X",
})
require.NoError(t, err)
result, err := callTool(ctxWithAuth(Scope{}), deps, "get_pr_status", map[string]any{
"workspace_id": testWSID.String(), "number": 1,
})
require.NoError(t, err)
require.False(t, result.IsError)
var out changeRequestOut
unmarshalStructured(t, result, &out)
assert.Equal(t, string(changerequest.CISuccess), out.ChangeRequest.CIState)
}
func TestMergePullRequest_GateError(t *testing.T) {
deps, crSvc := crTestDeps()
crSvc.mergeErr = errs.New(errs.CodeFailedPrecondition, "merge blocked: review not approved")
_, err := callTool(ctxWithAuth(Scope{}), deps, "create_pull_request", map[string]any{
"workspace_id": testWSID.String(), "source_branch": "feature", "title": "X",
})
require.NoError(t, err)
result, err := callTool(ctxWithAuth(Scope{}), deps, "merge_pull_request", map[string]any{
"workspace_id": testWSID.String(), "number": 1,
})
require.NoError(t, err)
assert.True(t, result.IsError, "gated merge must surface as an MCP error")
}
func TestMergePullRequest_Allowed(t *testing.T) {
deps, _ := crTestDeps()
_, err := callTool(ctxWithAuth(Scope{}), deps, "create_pull_request", map[string]any{
"workspace_id": testWSID.String(), "source_branch": "feature", "title": "X",
})
require.NoError(t, err)
result, err := callTool(ctxWithAuth(Scope{}), deps, "merge_pull_request", map[string]any{
"workspace_id": testWSID.String(), "number": 1, "method": "squash",
})
require.NoError(t, err)
require.False(t, result.IsError)
var out changeRequestOut
unmarshalStructured(t, result, &out)
assert.Equal(t, string(changerequest.StateMerged), out.ChangeRequest.State)
}
func TestListPullRequests(t *testing.T) {
deps, _ := crTestDeps()
_, err := callTool(ctxWithAuth(Scope{}), deps, "create_pull_request", map[string]any{
"workspace_id": testWSID.String(), "source_branch": "feature", "title": "X",
})
require.NoError(t, err)
result, err := callTool(ctxWithAuth(Scope{}), deps, "list_pull_requests", map[string]any{
"workspace_id": testWSID.String(),
})
require.NoError(t, err)
var out listPRsOut
unmarshalStructured(t, result, &out)
require.Len(t, out.ChangeRequests, 1)
}
+109
View File
@@ -0,0 +1,109 @@
package mcp
import (
"context"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/yan1h/agent-coding-workflow/internal/codeindex"
)
// registerCodeIndexTools registers the pgvector code-search tools. When the
// CodeIndex service is not configured (no embedding endpoint), the tools are not
// registered at all so they don't appear in the agent's tool list.
func registerCodeIndexTools(srv *mcpsdk.Server, deps ServerDeps) {
if deps.CodeIndex == nil {
return
}
registerSearchCode(srv, deps)
registerReindexWorkspace(srv, deps)
}
// ===== search_code =====
type searchCodeIn struct {
WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"`
Query string `json:"query" jsonschema:"natural-language or code query"`
TopK int `json:"top_k,omitempty" jsonschema:"max results (capped by server)"`
PathPrefix string `json:"path_prefix,omitempty" jsonschema:"restrict to a worktree-relative path prefix"`
Lang string `json:"lang,omitempty" jsonschema:"restrict to a language label (e.g. go, typescript)"`
}
type codeSnippet struct {
File string `json:"file"`
StartLine int `json:"start_line"`
EndLine int `json:"end_line"`
Snippet string `json:"snippet"`
Score float32 `json:"score"`
CommitSHA string `json:"commit_sha"`
}
type searchCodeOut struct {
Results []codeSnippet `json:"results"`
}
func registerSearchCode(srv *mcpsdk.Server, deps ServerDeps) {
mcpsdk.AddTool(srv,
&mcpsdk.Tool{Name: "search_code", Description: "Semantic code search over the workspace's indexed files; returns ranked file:line snippets."},
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in searchCodeIn) (*mcpsdk.CallToolResult, searchCodeOut, error) {
if err := CheckToolFromCtx(ctx, "search_code"); err != nil {
return nil, searchCodeOut{}, err
}
c, err := deps.Caller.Resolve(ctx)
if err != nil {
return nil, searchCodeOut{}, err
}
w, _, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID)
if err != nil {
return nil, searchCodeOut{}, err
}
snippets, err := deps.CodeIndex.Search(ctx, w.ID, codeindex.SearchQuery{
Text: in.Query, TopK: in.TopK, PathPrefix: in.PathPrefix, Lang: in.Lang,
})
if err != nil {
return nil, searchCodeOut{}, err
}
out := searchCodeOut{Results: make([]codeSnippet, 0, len(snippets))}
for _, s := range snippets {
out.Results = append(out.Results, codeSnippet{
File: s.FilePath, StartLine: s.StartLine, EndLine: s.EndLine,
Snippet: s.Content, Score: s.Score, CommitSHA: s.CommitSHA,
})
}
return nil, out, nil
})
}
// ===== reindex_workspace =====
type reindexWorkspaceIn struct {
WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"`
}
type reindexWorkspaceOut struct {
OK bool `json:"ok"`
RunID string `json:"run_id"`
}
func registerReindexWorkspace(srv *mcpsdk.Server, deps ServerDeps) {
mcpsdk.AddTool(srv,
&mcpsdk.Tool{Name: "reindex_workspace", Description: "Enqueue a fresh code index build at the workspace's current main HEAD."},
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in reindexWorkspaceIn) (*mcpsdk.CallToolResult, reindexWorkspaceOut, error) {
if err := CheckToolFromCtx(ctx, "reindex_workspace"); err != nil {
return nil, reindexWorkspaceOut{}, err
}
c, err := deps.Caller.Resolve(ctx)
if err != nil {
return nil, reindexWorkspaceOut{}, err
}
w, _, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID)
if err != nil {
return nil, reindexWorkspaceOut{}, err
}
run, err := deps.CodeIndex.EnqueueBuild(ctx, w.ID, nil, "")
if err != nil {
return nil, reindexWorkspaceOut{}, err
}
return nil, reindexWorkspaceOut{OK: true, RunID: run.ID.String()}, nil
})
}
+219
View File
@@ -0,0 +1,219 @@
package mcp
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/codeindex"
"github.com/yan1h/agent-coding-workflow/internal/projectmemory"
"github.com/yan1h/agent-coding-workflow/internal/workspace"
)
// ===== fakes =====
type fakeCodeIndexSvc struct {
snippets []codeindex.Snippet
lastWS uuid.UUID
lastQuery codeindex.SearchQuery
enqueuedWS uuid.UUID
}
func (f *fakeCodeIndexSvc) EnqueueBuild(_ context.Context, wsID uuid.UUID, _ *uuid.UUID, _ string) (*codeindex.Run, error) {
f.enqueuedWS = wsID
return &codeindex.Run{ID: uuid.New(), WorkspaceID: wsID}, nil
}
func (f *fakeCodeIndexSvc) Search(_ context.Context, wsID uuid.UUID, q codeindex.SearchQuery) ([]codeindex.Snippet, error) {
f.lastWS = wsID
f.lastQuery = q
return f.snippets, nil
}
func (f *fakeCodeIndexSvc) MarkStale(context.Context, uuid.UUID) error { return nil }
type fakeMemorySvc struct {
written *projectmemory.WriteInput
searched *projectmemory.Query
entries []projectmemory.Entry
}
func (f *fakeMemorySvc) Write(_ context.Context, in projectmemory.WriteInput) (*projectmemory.Entry, error) {
f.written = &in
return &projectmemory.Entry{ID: uuid.New(), ProjectID: in.ProjectID, Kind: in.Kind, Title: in.Title}, nil
}
func (f *fakeMemorySvc) Search(_ context.Context, q projectmemory.Query) ([]projectmemory.Entry, error) {
f.searched = &q
return f.entries, nil
}
func (f *fakeMemorySvc) List(_ context.Context, _ uuid.UUID, _ string) ([]projectmemory.Entry, error) {
return f.entries, nil
}
func (f *fakeMemorySvc) Delete(context.Context, uuid.UUID) error { return nil }
func (f *fakeMemorySvc) RenderAgentsMD(context.Context, uuid.UUID, *uuid.UUID) (string, error) {
return "", nil
}
// depsWithRAG builds testDeps plus a workspace + the code index/memory services.
func depsWithRAG(ci codeindex.Service, mem projectmemory.Service) ServerDeps {
deps := testDeps()
ws := &workspace.Workspace{
ID: testWSID, ProjectID: testPID, Slug: "ws-1", Name: "WS",
DefaultBranch: "main", SyncStatus: workspace.SyncStatusIdle,
}
deps.Workspaces = &fakeWorkspaceSvc{items: []*workspace.Workspace{ws}}
deps.CodeIndex = ci
deps.Memory = mem
return deps
}
// ===== search_code =====
func TestSearchCode_HappyPath(t *testing.T) {
ci := &fakeCodeIndexSvc{snippets: []codeindex.Snippet{
{FilePath: "a.go", StartLine: 1, EndLine: 10, Content: "func A", Score: 0.9, CommitSHA: "abc"},
}}
deps := depsWithRAG(ci, &fakeMemorySvc{})
result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{testPID}}), deps,
"search_code", map[string]any{"workspace_id": testWSID.String(), "query": "find A", "top_k": 5})
require.NoError(t, err)
require.False(t, result.IsError)
var out searchCodeOut
unmarshalStructured(t, result, &out)
require.Len(t, out.Results, 1)
assert.Equal(t, "a.go", out.Results[0].File)
assert.Equal(t, "find A", ci.lastQuery.Text)
assert.Equal(t, 5, ci.lastQuery.TopK)
assert.Equal(t, testWSID, ci.lastWS)
}
func TestSearchCode_CrossTenantRejected(t *testing.T) {
deps := depsWithRAG(&fakeCodeIndexSvc{}, &fakeMemorySvc{})
// Scope allows only a different project → project scope check must reject.
result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{uuid.New()}}), deps,
"search_code", map[string]any{"workspace_id": testWSID.String(), "query": "q"})
if err == nil {
require.True(t, result.IsError, "cross-tenant workspace must be rejected")
}
}
func TestSearchCode_ToolScopeDenied(t *testing.T) {
deps := depsWithRAG(&fakeCodeIndexSvc{}, &fakeMemorySvc{})
// Token scope only allows memory_list → search_code denied.
result, err := callTool(ctxWithAuth(Scope{Tools: []string{"memory_list"}}), deps,
"search_code", map[string]any{"workspace_id": testWSID.String(), "query": "q"})
if err == nil {
require.True(t, result.IsError)
}
}
func TestReindexWorkspace_HappyPath(t *testing.T) {
ci := &fakeCodeIndexSvc{}
deps := depsWithRAG(ci, &fakeMemorySvc{})
result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{testPID}}), deps,
"reindex_workspace", map[string]any{"workspace_id": testWSID.String()})
require.NoError(t, err)
require.False(t, result.IsError)
var out reindexWorkspaceOut
unmarshalStructured(t, result, &out)
assert.True(t, out.OK)
assert.NotEmpty(t, out.RunID)
assert.Equal(t, testWSID, ci.enqueuedWS)
}
// When CodeIndex is nil the tool is not registered → call errors (unknown tool).
func TestSearchCode_NotRegisteredWhenDisabled(t *testing.T) {
deps := testDeps()
deps.Memory = &fakeMemorySvc{} // memory present, code index nil
result, err := callTool(ctxWithAuth(Scope{}), deps,
"search_code", map[string]any{"workspace_id": testWSID.String(), "query": "q"})
if err == nil {
require.True(t, result.IsError, "disabled tool must not succeed")
}
}
// ===== memory tools =====
func TestMemoryWrite_HappyPath(t *testing.T) {
mem := &fakeMemorySvc{}
deps := depsWithRAG(&fakeCodeIndexSvc{}, mem)
result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{testPID}}), deps,
"memory_write", map[string]any{
"project_slug": "test-project", "kind": "decision",
"title": "Use X", "body": "because Y", "tags": []string{"a", "b"},
})
require.NoError(t, err)
require.False(t, result.IsError)
var out memoryWriteOut
unmarshalStructured(t, result, &out)
assert.True(t, out.OK)
require.NotNil(t, mem.written)
assert.Equal(t, testPID, mem.written.ProjectID)
assert.Equal(t, "decision", mem.written.Kind)
assert.Equal(t, []string{"a", "b"}, mem.written.Tags)
}
func TestMemoryWrite_ByWorkspace(t *testing.T) {
mem := &fakeMemorySvc{}
deps := depsWithRAG(&fakeCodeIndexSvc{}, mem)
result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{testPID}}), deps,
"memory_write", map[string]any{
"workspace_id": testWSID.String(), "kind": "gotcha",
"title": "T", "body": "B",
})
require.NoError(t, err)
require.False(t, result.IsError)
require.NotNil(t, mem.written)
assert.Equal(t, testPID, mem.written.ProjectID)
require.NotNil(t, mem.written.WorkspaceID)
assert.Equal(t, testWSID, *mem.written.WorkspaceID)
}
func TestMemorySearch_HappyPath(t *testing.T) {
mem := &fakeMemorySvc{entries: []projectmemory.Entry{
{ID: uuid.New(), Kind: "decision", Title: "hit", Body: "x", Tags: []string{}},
}}
deps := depsWithRAG(&fakeCodeIndexSvc{}, mem)
result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{testPID}}), deps,
"memory_search", map[string]any{"project_slug": "test-project", "query": "hit"})
require.NoError(t, err)
require.False(t, result.IsError)
var out memorySearchOut
unmarshalStructured(t, result, &out)
require.Len(t, out.Results, 1)
assert.Equal(t, "hit", out.Results[0].Title)
require.NotNil(t, mem.searched)
assert.Equal(t, testPID, mem.searched.ProjectID)
}
func TestMemoryList_HappyPath(t *testing.T) {
mem := &fakeMemorySvc{entries: []projectmemory.Entry{
{ID: uuid.New(), Kind: "convention", Title: "c", Tags: []string{}},
}}
deps := depsWithRAG(&fakeCodeIndexSvc{}, mem)
result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{testPID}}), deps,
"memory_list", map[string]any{"project_slug": "test-project"})
require.NoError(t, err)
require.False(t, result.IsError)
var out memoryListOut
unmarshalStructured(t, result, &out)
require.Len(t, out.Entries, 1)
}
func TestMemoryWrite_CrossTenantRejected(t *testing.T) {
deps := depsWithRAG(&fakeCodeIndexSvc{}, &fakeMemorySvc{})
result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{uuid.New()}}), deps,
"memory_write", map[string]any{
"project_slug": "test-project", "kind": "decision", "title": "T", "body": "B",
})
if err == nil {
require.True(t, result.IsError, "cross-tenant project must be rejected")
}
}
var (
_ codeindex.Service = (*fakeCodeIndexSvc)(nil)
_ projectmemory.Service = (*fakeMemorySvc)(nil)
)
+211
View File
@@ -0,0 +1,211 @@
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/project"
"github.com/yan1h/agent-coding-workflow/internal/projectmemory"
)
// registerMemoryTools registers the project_memory tools. When the Memory service
// is nil the tools are not registered.
func registerMemoryTools(srv *mcpsdk.Server, deps ServerDeps) {
if deps.Memory == nil {
return
}
registerMemoryWrite(srv, deps)
registerMemorySearch(srv, deps)
registerMemoryList(srv, deps)
}
// resolveMemoryScope resolves (projectID, workspaceID?) from either a
// workspace_id or a project_slug, enforcing project scope. Exactly one of the two
// identifiers must be supplied for write/search; list requires project_slug.
func resolveMemoryScope(ctx context.Context, deps ServerDeps, c project.Caller, workspaceID, projectSlug string) (projectID uuid.UUID, wsID *uuid.UUID, err error) {
switch {
case workspaceID != "":
w, _, werr := scopedWorkspace(ctx, deps, c, workspaceID)
if werr != nil {
return uuid.Nil, nil, werr
}
id := w.ID
return w.ProjectID, &id, nil
case projectSlug != "":
p, perr := deps.Projects.Get(ctx, c, projectSlug)
if perr != nil {
return uuid.Nil, nil, perr
}
if cerr := CheckProjectFromCtx(ctx, p.ID); cerr != nil {
return uuid.Nil, nil, cerr
}
return p.ID, nil, nil
default:
return uuid.Nil, nil, errs.New(errs.CodeInvalidInput, "project_slug or workspace_id required")
}
}
type memoryEntryDTO struct {
ID string `json:"id"`
Kind string `json:"kind"`
Title string `json:"title"`
Body string `json:"body"`
Tags []string `json:"tags"`
Source string `json:"source"`
WorkspaceID string `json:"workspace_id,omitempty"`
CreatedAt string `json:"created_at"`
}
func memoryEntryDTOFrom(e projectmemory.Entry) memoryEntryDTO {
d := memoryEntryDTO{
ID: e.ID.String(), Kind: e.Kind, Title: e.Title, Body: e.Body,
Tags: e.Tags, Source: e.Source,
CreatedAt: e.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
if e.WorkspaceID != nil {
d.WorkspaceID = e.WorkspaceID.String()
}
if d.Tags == nil {
d.Tags = []string{}
}
return d
}
// ===== memory_write =====
type memoryWriteIn struct {
ProjectSlug string `json:"project_slug,omitempty" jsonschema:"project slug (or supply workspace_id)"`
WorkspaceID string `json:"workspace_id,omitempty" jsonschema:"workspace UUID (scopes the entry to this workspace)"`
Kind string `json:"kind" jsonschema:"one of: decision, convention, file_map, gotcha"`
Title string `json:"title"`
Body string `json:"body"`
Tags []string `json:"tags,omitempty"`
}
type memoryWriteOut struct {
OK bool `json:"ok"`
ID string `json:"id"`
}
func registerMemoryWrite(srv *mcpsdk.Server, deps ServerDeps) {
mcpsdk.AddTool(srv,
&mcpsdk.Tool{Name: "memory_write", Description: "Record a typed project memory entry (decision/convention/file_map/gotcha)."},
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in memoryWriteIn) (*mcpsdk.CallToolResult, memoryWriteOut, error) {
if err := CheckToolFromCtx(ctx, "memory_write"); err != nil {
return nil, memoryWriteOut{}, err
}
c, err := deps.Caller.Resolve(ctx)
if err != nil {
return nil, memoryWriteOut{}, err
}
projectID, wsID, err := resolveMemoryScope(ctx, deps, c, in.WorkspaceID, in.ProjectSlug)
if err != nil {
return nil, memoryWriteOut{}, err
}
uid := c.UserID
entry, err := deps.Memory.Write(ctx, projectmemory.WriteInput{
ProjectID: projectID,
WorkspaceID: wsID,
Kind: in.Kind,
Title: in.Title,
Body: in.Body,
Tags: in.Tags,
Source: projectmemory.SourceAgent,
CreatedBy: &uid,
})
if err != nil {
return nil, memoryWriteOut{}, err
}
return nil, memoryWriteOut{OK: true, ID: entry.ID.String()}, nil
})
}
// ===== memory_search =====
type memorySearchIn struct {
ProjectSlug string `json:"project_slug,omitempty" jsonschema:"project slug (or supply workspace_id)"`
WorkspaceID string `json:"workspace_id,omitempty" jsonschema:"workspace UUID"`
Query string `json:"query" jsonschema:"search text"`
Kind string `json:"kind,omitempty" jsonschema:"restrict to a kind"`
TopK int `json:"top_k,omitempty"`
}
type memorySearchOut struct {
Results []memoryEntryDTO `json:"results"`
}
func registerMemorySearch(srv *mcpsdk.Server, deps ServerDeps) {
mcpsdk.AddTool(srv,
&mcpsdk.Tool{Name: "memory_search", Description: "Search project memory (vector when embeddings available, keyword/tag fallback otherwise)."},
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in memorySearchIn) (*mcpsdk.CallToolResult, memorySearchOut, error) {
if err := CheckToolFromCtx(ctx, "memory_search"); err != nil {
return nil, memorySearchOut{}, err
}
c, err := deps.Caller.Resolve(ctx)
if err != nil {
return nil, memorySearchOut{}, err
}
projectID, _, err := resolveMemoryScope(ctx, deps, c, in.WorkspaceID, in.ProjectSlug)
if err != nil {
return nil, memorySearchOut{}, err
}
entries, err := deps.Memory.Search(ctx, projectmemory.Query{
ProjectID: projectID, Text: in.Query, Kind: in.Kind, TopK: in.TopK,
})
if err != nil {
return nil, memorySearchOut{}, err
}
out := memorySearchOut{Results: make([]memoryEntryDTO, 0, len(entries))}
for _, e := range entries {
out.Results = append(out.Results, memoryEntryDTOFrom(e))
}
return nil, out, nil
})
}
// ===== memory_list =====
type memoryListIn struct {
ProjectSlug string `json:"project_slug" jsonschema:"project slug"`
Kind string `json:"kind,omitempty" jsonschema:"restrict to a kind"`
}
type memoryListOut struct {
Entries []memoryEntryDTO `json:"entries"`
}
func registerMemoryList(srv *mcpsdk.Server, deps ServerDeps) {
mcpsdk.AddTool(srv,
&mcpsdk.Tool{Name: "memory_list", Description: "List project memory entries, optionally filtered by kind."},
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in memoryListIn) (*mcpsdk.CallToolResult, memoryListOut, error) {
if err := CheckToolFromCtx(ctx, "memory_list"); err != nil {
return nil, memoryListOut{}, err
}
c, err := deps.Caller.Resolve(ctx)
if err != nil {
return nil, memoryListOut{}, err
}
if in.ProjectSlug == "" {
return nil, memoryListOut{}, errs.New(errs.CodeInvalidInput, "project_slug required")
}
p, err := deps.Projects.Get(ctx, c, in.ProjectSlug)
if err != nil {
return nil, memoryListOut{}, err
}
if err := CheckProjectFromCtx(ctx, p.ID); err != nil {
return nil, memoryListOut{}, err
}
entries, err := deps.Memory.List(ctx, p.ID, in.Kind)
if err != nil {
return nil, memoryListOut{}, err
}
out := memoryListOut{Entries: make([]memoryEntryDTO, 0, len(entries))}
for _, e := range entries {
out.Entries = append(out.Entries, memoryEntryDTOFrom(e))
}
return nil, out, nil
})
}
+85
View File
@@ -0,0 +1,85 @@
package mcp
import (
"context"
"github.com/google/uuid"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
)
// OrchestratorBridge 是 mcp 调用 orchestrator 服务的窄桥接接口(orchestrator →
// mcp 的既有依赖边由 orchestrator 实现该接口,mcp 不反向 import orchestrator,避免循环)。
type OrchestratorBridge interface {
// RequestSubtask 让运行中的 agent 为其当前编排 step 派生一个子 step。
// parentACPSessionID 是调用方所在的 acp session(从 AuthSession 解析);
// 返回新建子 step 的 id。
RequestSubtask(ctx context.Context, parentACPSessionID uuid.UUID, phase string, prompt string, agentKindID *uuid.UUID) (subtaskID string, err error)
}
// registerOrchestratorTools 注册编排器相关 MCP 工具。
func registerOrchestratorTools(srv *mcpsdk.Server, deps ServerDeps) {
registerRequestSubtask(srv, deps)
}
// ===== request_subtask =====
type requestSubtaskIn struct {
Phase string `json:"phase,omitempty" jsonschema:"target phase for the subtask (defaults to the parent step phase)"`
Prompt string `json:"prompt" jsonschema:"prompt for the child agent"`
AgentKindID string `json:"agent_kind_id,omitempty" jsonschema:"optional agent kind UUID for the subtask"`
}
type requestSubtaskOut struct {
OK bool `json:"ok"`
StepID string `json:"step_id"`
}
func registerRequestSubtask(srv *mcpsdk.Server, deps ServerDeps) {
mcpsdk.AddTool(srv,
&mcpsdk.Tool{
Name: "request_subtask",
Description: "Ask the orchestrator to spawn a child step (sub-agent) for the current run, subject to depth and fan-out limits. Only callable from within an active agent session.",
},
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in requestSubtaskIn) (*mcpsdk.CallToolResult, requestSubtaskOut, error) {
if err := CheckToolFromCtx(ctx, "request_subtask"); err != nil {
return nil, requestSubtaskOut{}, err
}
if deps.Orchestrator == nil {
return nil, requestSubtaskOut{}, errs.New(errs.CodeNotFound, "orchestrator not enabled")
}
// request_subtask 是 create_acp_session 的逆向闸门:只有在 session 内的
// agent(system token)可调,普通用户/admin token 不可(它们走 StartRun)。
sess, ok := FromContext(ctx)
if !ok {
return nil, requestSubtaskOut{}, errs.New(errs.CodeInternal, "mcp: AuthSession missing")
}
if sess.Issuer != IssuerSystem {
return nil, requestSubtaskOut{}, errs.New(errs.CodeForbidden,
"request_subtask is only callable from within an agent session")
}
if sess.ACPSessionID == nil || *sess.ACPSessionID == uuid.Nil {
return nil, requestSubtaskOut{}, errs.New(errs.CodeForbidden,
"calling token is not bound to an acp session")
}
if in.Prompt == "" {
return nil, requestSubtaskOut{}, errs.New(errs.CodeInvalidInput, "prompt is required")
}
var akID *uuid.UUID
if in.AgentKindID != "" {
id, perr := uuid.Parse(in.AgentKindID)
if perr != nil {
return nil, requestSubtaskOut{}, errs.Wrap(perr, errs.CodeInvalidInput, "agent_kind_id parse")
}
akID = &id
}
stepID, err := deps.Orchestrator.RequestSubtask(ctx, *sess.ACPSessionID, in.Phase, in.Prompt, akID)
if err != nil {
return nil, requestSubtaskOut{}, err
}
return nil, requestSubtaskOut{OK: true, StepID: stepID}, nil
})
}
+108
View File
@@ -0,0 +1,108 @@
package mcp
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// fakeOrchBridge implements OrchestratorBridge.
type fakeOrchBridge struct {
lastParent uuid.UUID
lastPhase string
lastPrompt string
stepID string
err error
}
func (f *fakeOrchBridge) RequestSubtask(_ context.Context, parentACPSessionID uuid.UUID, phase, prompt string, _ *uuid.UUID) (string, error) {
if f.err != nil {
return "", f.err
}
f.lastParent = parentACPSessionID
f.lastPhase = phase
f.lastPrompt = prompt
if f.stepID == "" {
f.stepID = uuid.NewString()
}
return f.stepID, nil
}
func orchTestDeps() (ServerDeps, *fakeOrchBridge) {
deps := testDeps()
bridge := &fakeOrchBridge{}
deps.Orchestrator = bridge
return deps, bridge
}
// ctxWithSystemToken 构造一个 system token 上下文(绑定 acp session)。
func ctxWithSystemToken(sessionID uuid.UUID) context.Context {
return context.WithValue(context.Background(), AuthContextKey, &AuthSession{
UserID: testUID.String(),
TokenID: uuid.NewString(),
Issuer: IssuerSystem,
ACPSessionID: &sessionID,
})
}
func TestRequestSubtask_SystemTokenHappyPath(t *testing.T) {
deps, bridge := orchTestDeps()
sessionID := uuid.New()
result, err := callTool(ctxWithSystemToken(sessionID), deps, "request_subtask", map[string]any{
"phase": "implementing",
"prompt": "do the subtask",
})
require.NoError(t, err)
require.False(t, result.IsError, "expected success; got %+v", result)
assert.Equal(t, sessionID, bridge.lastParent)
assert.Equal(t, "implementing", bridge.lastPhase)
assert.Equal(t, "do the subtask", bridge.lastPrompt)
}
func TestRequestSubtask_RejectsAdminToken(t *testing.T) {
deps, bridge := orchTestDeps()
// admin token(非 system)→ 拒绝(request_subtask 只允许 in-session agent)。
ctx := context.WithValue(context.Background(), AuthContextKey, &AuthSession{
UserID: testUID.String(),
TokenID: uuid.NewString(),
Issuer: IssuerAdmin,
})
result, err := callTool(ctx, deps, "request_subtask", map[string]any{
"phase": "implementing",
"prompt": "x",
})
require.NoError(t, err)
assert.True(t, result.IsError, "admin token must be rejected")
assert.Empty(t, bridge.lastPrompt)
}
func TestRequestSubtask_RejectsSystemTokenWithoutSession(t *testing.T) {
deps, _ := orchTestDeps()
// system token 但未绑定 acp session → 拒绝。
ctx := context.WithValue(context.Background(), AuthContextKey, &AuthSession{
UserID: testUID.String(),
TokenID: uuid.NewString(),
Issuer: IssuerSystem,
})
result, err := callTool(ctx, deps, "request_subtask", map[string]any{
"phase": "implementing",
"prompt": "x",
})
require.NoError(t, err)
assert.True(t, result.IsError, "system token without acp session must be rejected")
}
func TestRequestSubtask_RequiresPrompt(t *testing.T) {
deps, _ := orchTestDeps()
result, err := callTool(ctxWithSystemToken(uuid.New()), deps, "request_subtask", map[string]any{
"phase": "implementing",
})
require.NoError(t, err)
assert.True(t, result.IsError, "missing prompt must error")
}
+366 -12
View File
@@ -24,6 +24,11 @@ func registerPMTools(srv *mcpsdk.Server, deps ServerDeps) {
registerGetIssue(srv, deps)
registerGetRequirement(srv, deps)
registerCreateIssue(srv, deps)
registerCreateSubtask(srv, deps)
registerAddDependency(srv, deps)
registerRemoveDependency(srv, deps)
registerListDependencies(srv, deps)
registerSubmitDecomposition(srv, deps)
registerUpdateIssue(srv, deps)
registerCloseIssue(srv, deps)
registerReopenIssue(srv, deps)
@@ -34,6 +39,7 @@ func registerPMTools(srv *mcpsdk.Server, deps ServerDeps) {
registerReopenRequirement(srv, deps)
registerSetRequirementPhase(srv, deps)
registerAddRequirementArtifact(srv, deps)
registerApproveRequirementArtifact(srv, deps)
registerListRequirementArtifacts(srv, deps)
}
@@ -380,6 +386,312 @@ func registerCreateIssue(srv *mcpsdk.Server, deps ServerDeps) {
})
}
// ===== create_subtask =====
type createSubtaskIn struct {
ProjectSlug string `json:"project_slug" jsonschema:"required field"`
ParentNumber int `json:"parent_number" jsonschema:"parent issue number (>=1)"`
Title string `json:"title" jsonschema:"title (non-empty)"`
Description string `json:"description,omitempty"`
Priority int `json:"priority,omitempty" jsonschema:"scheduling priority 0..3 (higher = sooner)"`
}
func registerCreateSubtask(srv *mcpsdk.Server, deps ServerDeps) {
mcpsdk.AddTool(srv,
&mcpsdk.Tool{Name: "create_subtask", Description: "Create a subtask under a parent issue (task decomposition). Sets parent_id + priority."},
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in createSubtaskIn) (*mcpsdk.CallToolResult, issueCreateOut, error) {
if err := CheckToolFromCtx(ctx, "create_subtask"); err != nil {
return nil, issueCreateOut{}, err
}
c, err := deps.Caller.Resolve(ctx)
if err != nil {
return nil, issueCreateOut{}, err
}
p, err := deps.Projects.Get(ctx, c, in.ProjectSlug)
if err != nil {
return nil, issueCreateOut{}, err
}
if err := CheckProjectFromCtx(ctx, p.ID); err != nil {
return nil, issueCreateOut{}, err
}
prio := in.Priority
i, err := deps.Issues.CreateSubtask(ctx, c, in.ProjectSlug, in.ParentNumber, project.CreateIssueInput{
Title: in.Title, Description: in.Description, Priority: &prio,
})
if err != nil {
return nil, issueCreateOut{}, err
}
return nil, issueCreateOut{OK: true, Issue: issueDetail{
Number: i.Number, Title: i.Title, Description: i.Description,
Status: string(i.Status), CreatedAt: i.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
}}, nil
})
}
// ===== add_dependency =====
type addDependencyIn struct {
ProjectSlug string `json:"project_slug" jsonschema:"required field"`
BlockedNumber int `json:"blocked_number" jsonschema:"issue blocked-by blocker_number"`
BlockerNumber int `json:"blocker_number" jsonschema:"blocking issue number"`
}
type dependencyOut struct {
OK bool `json:"ok"`
BlockedNumber int `json:"blocked_number"`
BlockerNumber int `json:"blocker_number"`
}
func registerAddDependency(srv *mcpsdk.Server, deps ServerDeps) {
mcpsdk.AddTool(srv,
&mcpsdk.Tool{Name: "add_dependency", Description: "Add a dependency edge: blocked_number is blocked-by blocker_number. Rejects cycles and cross-project edges."},
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in addDependencyIn) (*mcpsdk.CallToolResult, dependencyOut, error) {
if err := CheckToolFromCtx(ctx, "add_dependency"); err != nil {
return nil, dependencyOut{}, err
}
c, err := deps.Caller.Resolve(ctx)
if err != nil {
return nil, dependencyOut{}, err
}
p, err := deps.Projects.Get(ctx, c, in.ProjectSlug)
if err != nil {
return nil, dependencyOut{}, err
}
if err := CheckProjectFromCtx(ctx, p.ID); err != nil {
return nil, dependencyOut{}, err
}
if _, err := deps.Issues.AddDependency(ctx, c, in.ProjectSlug, project.AddDependencyInput{
BlockedNumber: in.BlockedNumber, BlockerNumber: in.BlockerNumber,
}); err != nil {
return nil, dependencyOut{}, err
}
return nil, dependencyOut{OK: true, BlockedNumber: in.BlockedNumber, BlockerNumber: in.BlockerNumber}, nil
})
}
// ===== remove_dependency =====
func registerRemoveDependency(srv *mcpsdk.Server, deps ServerDeps) {
mcpsdk.AddTool(srv,
&mcpsdk.Tool{Name: "remove_dependency", Description: "Remove a dependency edge (plan correction)."},
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in addDependencyIn) (*mcpsdk.CallToolResult, dependencyOut, error) {
if err := CheckToolFromCtx(ctx, "remove_dependency"); err != nil {
return nil, dependencyOut{}, err
}
c, err := deps.Caller.Resolve(ctx)
if err != nil {
return nil, dependencyOut{}, err
}
p, err := deps.Projects.Get(ctx, c, in.ProjectSlug)
if err != nil {
return nil, dependencyOut{}, err
}
if err := CheckProjectFromCtx(ctx, p.ID); err != nil {
return nil, dependencyOut{}, err
}
if err := deps.Issues.RemoveDependency(ctx, c, in.ProjectSlug, in.BlockedNumber, in.BlockerNumber); err != nil {
return nil, dependencyOut{}, err
}
return nil, dependencyOut{OK: true, BlockedNumber: in.BlockedNumber, BlockerNumber: in.BlockerNumber}, nil
})
}
// ===== list_dependencies =====
type listDependenciesIn struct {
ProjectSlug string `json:"project_slug" jsonschema:"required field"`
Number int `json:"number" jsonschema:"issue number (>=1)"`
}
type listDependenciesOut struct {
BlockedBy []issueSummary `json:"blocked_by"`
Blocks []issueSummary `json:"blocks"`
}
func registerListDependencies(srv *mcpsdk.Server, deps ServerDeps) {
mcpsdk.AddTool(srv,
&mcpsdk.Tool{Name: "list_dependencies", Description: "List an issue's dependencies: {blocked_by:[...], blocks:[...]}."},
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in listDependenciesIn) (*mcpsdk.CallToolResult, listDependenciesOut, error) {
if err := CheckToolFromCtx(ctx, "list_dependencies"); err != nil {
return nil, listDependenciesOut{}, err
}
c, err := deps.Caller.Resolve(ctx)
if err != nil {
return nil, listDependenciesOut{}, err
}
p, err := deps.Projects.Get(ctx, c, in.ProjectSlug)
if err != nil {
return nil, listDependenciesOut{}, err
}
if err := CheckProjectFromCtx(ctx, p.ID); err != nil {
return nil, listDependenciesOut{}, err
}
blockedBy, blocks, err := deps.Issues.ListDependencies(ctx, c, in.ProjectSlug, in.Number)
if err != nil {
return nil, listDependenciesOut{}, err
}
out := listDependenciesOut{
BlockedBy: make([]issueSummary, 0, len(blockedBy)),
Blocks: make([]issueSummary, 0, len(blocks)),
}
for _, i := range blockedBy {
out.BlockedBy = append(out.BlockedBy, issueSummary{Number: i.Number, Title: i.Title, Status: string(i.Status)})
}
for _, i := range blocks {
out.Blocks = append(out.Blocks, issueSummary{Number: i.Number, Title: i.Title, Status: string(i.Status)})
}
return nil, out, nil
})
}
// ===== submit_decomposition =====
//
// 一次原子调用提交整张任务分解图(子任务 + 依赖边)。先做 in-batch DAG 校验,全部
// 通过后再建子任务、建边。ref 为 batch 内本地引用键,落库后映射为 issue number。
type decompSubtaskIn struct {
Ref string `json:"ref" jsonschema:"local reference key used by edges"`
Title string `json:"title" jsonschema:"title (non-empty)"`
Description string `json:"description,omitempty"`
Priority int `json:"priority,omitempty" jsonschema:"0..3 (higher = sooner)"`
}
type decompEdgeIn struct {
BlockedRef string `json:"blocked_ref" jsonschema:"ref of the blocked subtask"`
BlockerRef string `json:"blocker_ref" jsonschema:"ref of the blocking subtask"`
}
type submitDecompositionIn struct {
ProjectSlug string `json:"project_slug" jsonschema:"required field"`
ParentNumber *int `json:"parent_number,omitempty" jsonschema:"optional anchor issue number; subtasks become its children"`
Subtasks []decompSubtaskIn `json:"subtasks"`
Edges []decompEdgeIn `json:"edges,omitempty"`
}
type submitDecompositionOut struct {
OK bool `json:"ok"`
RefToNumber map[string]int `json:"ref_to_number"`
}
func registerSubmitDecomposition(srv *mcpsdk.Server, deps ServerDeps) {
mcpsdk.AddTool(srv,
&mcpsdk.Tool{Name: "submit_decomposition", Description: "Submit a whole task decomposition (subtasks + blocks/blocked-by edges) atomically. Validates acyclicity before any write; maps refs to created issue numbers."},
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in submitDecompositionIn) (*mcpsdk.CallToolResult, submitDecompositionOut, error) {
if err := CheckToolFromCtx(ctx, "submit_decomposition"); err != nil {
return nil, submitDecompositionOut{}, err
}
c, err := deps.Caller.Resolve(ctx)
if err != nil {
return nil, submitDecompositionOut{}, err
}
p, err := deps.Projects.Get(ctx, c, in.ProjectSlug)
if err != nil {
return nil, submitDecompositionOut{}, err
}
if err := CheckProjectFromCtx(ctx, p.ID); err != nil {
return nil, submitDecompositionOut{}, err
}
res, err := applyDecomposition(ctx, deps.Issues, c, in)
if err != nil {
return nil, submitDecompositionOut{}, err
}
return nil, submitDecompositionOut{OK: true, RefToNumber: res}, nil
})
}
// applyDecomposition 在 mcp 包内实现分解落库(不引入 orchestrator 以避免 import cycle)。
// 先 ref 去重 + 边端点校验 + DAG 校验,再建子任务、建边。
func applyDecomposition(ctx context.Context, issues project.IssueService, c project.Caller, in submitDecompositionIn) (map[string]int, error) {
if len(in.Subtasks) == 0 {
return nil, errs.New(errs.CodeInvalidInput, "decomposition 至少需要一个子任务")
}
refSet := make(map[string]struct{}, len(in.Subtasks))
for _, st := range in.Subtasks {
if st.Ref == "" {
return nil, errs.New(errs.CodeInvalidInput, "子任务 ref 不能为空")
}
if st.Title == "" {
return nil, errs.New(errs.CodeInvalidInput, "子任务 title 不能为空")
}
if _, dup := refSet[st.Ref]; dup {
return nil, errs.New(errs.CodeInvalidInput, "子任务 ref 重复: "+st.Ref)
}
if st.Priority < 0 || st.Priority > 3 {
return nil, errs.New(errs.CodeInvalidInput, "子任务 priority 必须在 0..3")
}
refSet[st.Ref] = struct{}{}
}
for _, e := range in.Edges {
if _, ok := refSet[e.BlockedRef]; !ok {
return nil, errs.New(errs.CodeInvalidInput, "边引用了未知 blocked ref: "+e.BlockedRef)
}
if _, ok := refSet[e.BlockerRef]; !ok {
return nil, errs.New(errs.CodeInvalidInput, "边引用了未知 blocker ref: "+e.BlockerRef)
}
if e.BlockedRef == e.BlockerRef {
return nil, errs.New(errs.CodeInvalidInput, "边不能自指: "+e.BlockedRef)
}
}
if err := decompositionIsDAG(in.Subtasks, in.Edges); err != nil {
return nil, err
}
refToNum := make(map[string]int, len(in.Subtasks))
for _, st := range in.Subtasks {
prio := st.Priority
ci := project.CreateIssueInput{Title: st.Title, Description: st.Description, Priority: &prio}
var iss *project.Issue
var err error
if in.ParentNumber != nil {
iss, err = issues.CreateSubtask(ctx, c, in.ProjectSlug, *in.ParentNumber, ci)
} else {
iss, err = issues.Create(ctx, c, in.ProjectSlug, ci)
}
if err != nil {
return nil, err
}
refToNum[st.Ref] = iss.Number
}
for _, e := range in.Edges {
if _, err := issues.AddDependency(ctx, c, in.ProjectSlug, project.AddDependencyInput{
BlockedNumber: refToNum[e.BlockedRef], BlockerNumber: refToNum[e.BlockerRef],
}); err != nil {
return nil, err
}
}
return refToNum, nil
}
// decompositionIsDAG 对 (子任务, 边) 做 Kahn 拓扑排序,存在环返回 CodeInvalidInput。
func decompositionIsDAG(subtasks []decompSubtaskIn, edges []decompEdgeIn) error {
indeg := make(map[string]int, len(subtasks))
for _, st := range subtasks {
indeg[st.Ref] = 0
}
adj := make(map[string][]string, len(subtasks))
for _, e := range edges {
adj[e.BlockerRef] = append(adj[e.BlockerRef], e.BlockedRef)
indeg[e.BlockedRef]++
}
queue := make([]string, 0, len(subtasks))
for ref, d := range indeg {
if d == 0 {
queue = append(queue, ref)
}
}
visited := 0
for len(queue) > 0 {
cur := queue[0]
queue = queue[1:]
visited++
for _, next := range adj[cur] {
indeg[next]--
if indeg[next] == 0 {
queue = append(queue, next)
}
}
}
if visited != len(subtasks) {
return errs.New(errs.CodeInvalidInput, "decomposition 边集合存在环")
}
return nil
}
// ===== update_issue =====
type updateIssueIn struct {
@@ -687,7 +999,7 @@ type setRequirementPhaseIn struct {
func registerSetRequirementPhase(srv *mcpsdk.Server, deps ServerDeps) {
mcpsdk.AddTool(srv,
&mcpsdk.Tool{Name: "set_requirement_phase", Description: "Change a requirement's phase."},
&mcpsdk.Tool{Name: "set_requirement_phase", Description: "Change a requirement's phase. Entry gates are enforced: e.g. moving to 'implementing' requires an approved (verdict=pass) auditing artifact; moving to 'done' requires the workspace PR to be merged. A blocked transition returns phase_gate_failed."},
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in setRequirementPhaseIn) (*mcpsdk.CallToolResult, requirementCreateOut, error) {
if err := CheckToolFromCtx(ctx, "set_requirement_phase"); err != nil {
return nil, requirementCreateOut{}, err
@@ -720,7 +1032,7 @@ func registerSetRequirementPhase(srv *mcpsdk.Server, deps ServerDeps) {
type addRequirementArtifactIn struct {
ProjectSlug string `json:"project_slug" jsonschema:"required field"`
Number int `json:"number" jsonschema:"requirement number (>=1)"`
Phase string `json:"phase" jsonschema:"artifact phase: planning|prototyping|auditing"`
Phase string `json:"phase" jsonschema:"artifact phase: planning|prototyping|auditing|implementing|reviewing"`
Content string `json:"content" jsonschema:"artifact content (non-empty)"`
Note string `json:"note,omitempty"`
}
@@ -730,7 +1042,19 @@ type artifactDetail struct {
Content string `json:"content"`
Note string `json:"note,omitempty"`
CreatedAt string `json:"created_at"`
Verdict string `json:"verdict"`
Approved bool `json:"approved"`
}
func artifactDetailFrom(a *project.Artifact) artifactDetail {
return artifactDetail{
Phase: string(a.Phase), Version: a.Version, Content: a.Content, Note: a.Note,
CreatedAt: a.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
Verdict: string(a.Verdict),
Approved: a.Verdict == project.VerdictPass,
}
}
type artifactCreateOut struct {
OK bool `json:"ok"`
Artifact artifactDetail `json:"artifact"`
@@ -738,7 +1062,7 @@ type artifactCreateOut struct {
func registerAddRequirementArtifact(srv *mcpsdk.Server, deps ServerDeps) {
mcpsdk.AddTool(srv,
&mcpsdk.Tool{Name: "add_requirement_artifact", Description: "Add a new phase artifact version (planning/prototyping/auditing) to a requirement."},
&mcpsdk.Tool{Name: "add_requirement_artifact", Description: "Add a new phase artifact version (planning/prototyping/auditing/implementing/reviewing) to a requirement."},
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in addRequirementArtifactIn) (*mcpsdk.CallToolResult, artifactCreateOut, error) {
if err := CheckToolFromCtx(ctx, "add_requirement_artifact"); err != nil {
return nil, artifactCreateOut{}, err
@@ -760,10 +1084,43 @@ func registerAddRequirementArtifact(srv *mcpsdk.Server, deps ServerDeps) {
if err != nil {
return nil, artifactCreateOut{}, err
}
return nil, artifactCreateOut{OK: true, Artifact: artifactDetail{
Phase: string(art.Phase), Version: art.Version, Content: art.Content, Note: art.Note,
CreatedAt: art.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
}}, nil
return nil, artifactCreateOut{OK: true, Artifact: artifactDetailFrom(art)}, nil
})
}
// ===== approve_requirement_artifact =====
type approveRequirementArtifactIn struct {
ProjectSlug string `json:"project_slug" jsonschema:"required field"`
Number int `json:"number" jsonschema:"requirement number (>=1)"`
Phase string `json:"phase" jsonschema:"artifact phase: planning|prototyping|auditing|implementing|reviewing"`
Version int `json:"version" jsonschema:"artifact version (>=1)"`
Verdict string `json:"verdict" jsonschema:"review verdict: pass|fail"`
}
func registerApproveRequirementArtifact(srv *mcpsdk.Server, deps ServerDeps) {
mcpsdk.AddTool(srv,
&mcpsdk.Tool{Name: "approve_requirement_artifact", Description: "Set the review verdict (pass/fail) on a requirement artifact version. A 'pass' verdict marks the phase's approved/current artifact, which downstream phase gates require (e.g. an approved auditing artifact unblocks the move to 'implementing')."},
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in approveRequirementArtifactIn) (*mcpsdk.CallToolResult, artifactCreateOut, error) {
if err := CheckToolFromCtx(ctx, "approve_requirement_artifact"); err != nil {
return nil, artifactCreateOut{}, err
}
c, err := deps.Caller.Resolve(ctx)
if err != nil {
return nil, artifactCreateOut{}, err
}
p, err := deps.Projects.Get(ctx, c, in.ProjectSlug)
if err != nil {
return nil, artifactCreateOut{}, err
}
if err := CheckProjectFromCtx(ctx, p.ID); err != nil {
return nil, artifactCreateOut{}, err
}
art, err := deps.Artifacts.Approve(ctx, c, in.ProjectSlug, in.Number, project.Phase(in.Phase), in.Version, project.Verdict(in.Verdict))
if err != nil {
return nil, artifactCreateOut{}, err
}
return nil, artifactCreateOut{OK: true, Artifact: artifactDetailFrom(art)}, nil
})
}
@@ -772,7 +1129,7 @@ func registerAddRequirementArtifact(srv *mcpsdk.Server, deps ServerDeps) {
type listRequirementArtifactsIn struct {
ProjectSlug string `json:"project_slug" jsonschema:"required field"`
Number int `json:"number" jsonschema:"requirement number (>=1)"`
Phase string `json:"phase,omitempty" jsonschema:"filter by phase: planning|prototyping|auditing; empty = all"`
Phase string `json:"phase,omitempty" jsonschema:"filter by phase: planning|prototyping|auditing|implementing|reviewing; empty = all"`
}
type listRequirementArtifactsOut struct {
Artifacts []artifactDetail `json:"artifacts"`
@@ -802,10 +1159,7 @@ func registerListRequirementArtifacts(srv *mcpsdk.Server, deps ServerDeps) {
}
out := listRequirementArtifactsOut{Artifacts: make([]artifactDetail, 0, len(arts))}
for _, art := range arts {
out.Artifacts = append(out.Artifacts, artifactDetail{
Phase: string(art.Phase), Version: art.Version, Content: art.Content, Note: art.Note,
CreatedAt: art.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
})
out.Artifacts = append(out.Artifacts, artifactDetailFrom(art))
}
return nil, out, nil
})
+153
View File
@@ -0,0 +1,153 @@
package mcp
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateSubtaskTool(t *testing.T) {
deps := testDeps()
// parent
_, err := callTool(ctxWithAuth(Scope{}), deps, "create_issue", map[string]any{
"project_slug": "test-project", "title": "Parent",
})
require.NoError(t, err)
result, err := callTool(ctxWithAuth(Scope{}), deps, "create_subtask", map[string]any{
"project_slug": "test-project",
"parent_number": float64(1),
"title": "Child",
"priority": float64(2),
})
require.NoError(t, err)
assert.False(t, result.IsError)
var out issueCreateOut
unmarshalStructured(t, result, &out)
assert.True(t, out.OK)
assert.Equal(t, "Child", out.Issue.Title)
}
func TestAddListRemoveDependencyTool(t *testing.T) {
deps := testDeps()
for _, title := range []string{"A", "B"} {
_, err := callTool(ctxWithAuth(Scope{}), deps, "create_issue", map[string]any{
"project_slug": "test-project", "title": title,
})
require.NoError(t, err)
}
// A(#1) blocked-by B(#2)
result, err := callTool(ctxWithAuth(Scope{}), deps, "add_dependency", map[string]any{
"project_slug": "test-project",
"blocked_number": float64(1),
"blocker_number": float64(2),
})
require.NoError(t, err)
assert.False(t, result.IsError)
var addOut dependencyOut
unmarshalStructured(t, result, &addOut)
assert.True(t, addOut.OK)
// list_dependencies on A → blocked_by contains B
result, err = callTool(ctxWithAuth(Scope{}), deps, "list_dependencies", map[string]any{
"project_slug": "test-project", "number": float64(1),
})
require.NoError(t, err)
var listOut listDependenciesOut
unmarshalStructured(t, result, &listOut)
require.Len(t, listOut.BlockedBy, 1)
assert.Equal(t, 2, listOut.BlockedBy[0].Number)
// remove
result, err = callTool(ctxWithAuth(Scope{}), deps, "remove_dependency", map[string]any{
"project_slug": "test-project",
"blocked_number": float64(1),
"blocker_number": float64(2),
})
require.NoError(t, err)
assert.False(t, result.IsError)
result, err = callTool(ctxWithAuth(Scope{}), deps, "list_dependencies", map[string]any{
"project_slug": "test-project", "number": float64(1),
})
require.NoError(t, err)
unmarshalStructured(t, result, &listOut)
assert.Empty(t, listOut.BlockedBy)
}
func TestAddDependencyTool_RejectsSelf(t *testing.T) {
deps := testDeps()
_, err := callTool(ctxWithAuth(Scope{}), deps, "create_issue", map[string]any{
"project_slug": "test-project", "title": "A",
})
require.NoError(t, err)
result, err := callTool(ctxWithAuth(Scope{}), deps, "add_dependency", map[string]any{
"project_slug": "test-project",
"blocked_number": float64(1),
"blocker_number": float64(1),
})
require.NoError(t, err)
assert.True(t, result.IsError, "自依赖应被工具拒绝")
}
func TestSubmitDecompositionTool(t *testing.T) {
deps := testDeps()
// anchor parent
_, err := callTool(ctxWithAuth(Scope{}), deps, "create_issue", map[string]any{
"project_slug": "test-project", "title": "Anchor",
})
require.NoError(t, err)
result, err := callTool(ctxWithAuth(Scope{}), deps, "submit_decomposition", map[string]any{
"project_slug": "test-project",
"parent_number": float64(1),
"subtasks": []any{
map[string]any{"ref": "a", "title": "Task A", "priority": float64(2)},
map[string]any{"ref": "b", "title": "Task B"},
},
"edges": []any{
map[string]any{"blocked_ref": "a", "blocker_ref": "b"},
},
})
require.NoError(t, err)
assert.False(t, result.IsError)
var out submitDecompositionOut
unmarshalStructured(t, result, &out)
assert.True(t, out.OK)
require.Contains(t, out.RefToNumber, "a")
require.Contains(t, out.RefToNumber, "b")
}
func TestSubmitDecompositionTool_RejectsCycle(t *testing.T) {
deps := testDeps()
result, err := callTool(ctxWithAuth(Scope{}), deps, "submit_decomposition", map[string]any{
"project_slug": "test-project",
"subtasks": []any{
map[string]any{"ref": "a", "title": "A"},
map[string]any{"ref": "b", "title": "B"},
},
"edges": []any{
map[string]any{"blocked_ref": "a", "blocker_ref": "b"},
map[string]any{"blocked_ref": "b", "blocker_ref": "a"},
},
})
require.NoError(t, err)
assert.True(t, result.IsError, "环应被拒绝")
}
func TestDecompositionToolsInSystemScope(t *testing.T) {
// 新工具必须在 system token 默认白名单内,规划阶段 agent 才能调用。
set := map[string]bool{}
for _, name := range defaultSystemTools {
set[name] = true
}
for _, name := range []string{
"create_subtask", "add_dependency", "remove_dependency",
"list_dependencies", "submit_decomposition",
} {
assert.True(t, set[name], "%s 应在 defaultSystemTools", name)
}
// create_acp_session 仍对 system token 禁用(不应在白名单)。
assert.False(t, set["create_acp_session"], "create_acp_session 不应在 system 白名单")
}
+166
View File
@@ -138,6 +138,18 @@ func (f *fakeProjectSvc) Unarchive(ctx context.Context, c project.Caller, slug s
p.ArchivedAt = nil
return nil
}
func (f *fakeProjectSvc) ListMembers(_ context.Context, _ project.Caller, _ string) ([]*project.Member, error) {
return nil, nil
}
func (f *fakeProjectSvc) AddMember(_ context.Context, _ project.Caller, _ string, _ project.AddMemberInput) (*project.Member, error) {
return nil, nil
}
func (f *fakeProjectSvc) RemoveMember(_ context.Context, _ project.Caller, _ string, _ uuid.UUID) error {
return nil
}
func (f *fakeProjectSvc) MemberRole(_ context.Context, _, _ uuid.UUID) (project.Role, error) {
return project.RoleNone, nil
}
// fakeRequirementSvc implements project.RequirementService.
type fakeRequirementSvc struct {
@@ -203,16 +215,63 @@ func (f *fakeRequirementSvc) Reopen(_ context.Context, _ project.Caller, slug st
return nil
}
// fakeArtifactSvc implements project.ArtifactService.
type fakeArtifactSvc struct {
items []*project.Artifact
}
func (f *fakeArtifactSvc) Create(_ context.Context, c project.Caller, _ string, reqNumber int, in project.CreateArtifactInput) (*project.Artifact, error) {
a := &project.Artifact{
ID: uuid.New(), Phase: in.Phase, Version: len(f.items) + 1,
Content: in.Content, Note: in.Note, CreatedBy: c.UserID,
CreatedAt: time.Now(), Verdict: project.VerdictNone,
}
f.items = append(f.items, a)
return a, nil
}
func (f *fakeArtifactSvc) List(_ context.Context, _ project.Caller, _ string, _ int, phase project.Phase) ([]*project.Artifact, error) {
out := make([]*project.Artifact, 0, len(f.items))
for _, a := range f.items {
if phase == "" || a.Phase == phase {
out = append(out, a)
}
}
return out, nil
}
func (f *fakeArtifactSvc) GetByVersion(_ context.Context, _ project.Caller, _ string, _ int, phase project.Phase, version int) (*project.Artifact, error) {
for _, a := range f.items {
if a.Phase == phase && a.Version == version {
return a, nil
}
}
return nil, errs.New(errs.CodeNotFound, "artifact")
}
func (f *fakeArtifactSvc) Approve(_ context.Context, _ project.Caller, _ string, _ int, phase project.Phase, version int, verdict project.Verdict) (*project.Artifact, error) {
for _, a := range f.items {
if a.Phase == phase && a.Version == version {
a.Verdict = verdict
return a, nil
}
}
return nil, errs.New(errs.CodeNotFound, "artifact")
}
// fakeIssueSvc implements project.IssueService.
type fakeIssueSvc struct {
items []*project.Issue
deps []*project.Dependency
}
func (f *fakeIssueSvc) Create(_ context.Context, _ project.Caller, _ string, in project.CreateIssueInput) (*project.Issue, error) {
prio := 0
if in.Priority != nil {
prio = *in.Priority
}
i := &project.Issue{
ID: uuid.New(), Number: len(f.items) + 1,
Title: in.Title, Description: in.Description,
Status: project.StatusOpen,
Priority: prio,
CreatedAt: time.Now(),
}
f.items = append(f.items, i)
@@ -266,6 +325,80 @@ func (f *fakeIssueSvc) Reopen(_ context.Context, _ project.Caller, slug string,
i.Status = project.StatusOpen
return nil
}
func (f *fakeIssueSvc) CreateSubtask(ctx context.Context, c project.Caller, slug string, parentNumber int, in project.CreateIssueInput) (*project.Issue, error) {
parent, err := f.Get(ctx, c, slug, parentNumber)
if err != nil {
return nil, err
}
i, err := f.Create(ctx, c, slug, in)
if err != nil {
return nil, err
}
pid := parent.ID
i.ParentID = &pid
return i, nil
}
func (f *fakeIssueSvc) AddDependency(ctx context.Context, c project.Caller, slug string, in project.AddDependencyInput) (*project.Dependency, error) {
if in.BlockedNumber == in.BlockerNumber {
return nil, errs.New(errs.CodeInvalidInput, "issue 不能依赖自身")
}
blocked, err := f.Get(ctx, c, slug, in.BlockedNumber)
if err != nil {
return nil, err
}
blocker, err := f.Get(ctx, c, slug, in.BlockerNumber)
if err != nil {
return nil, err
}
d := &project.Dependency{ID: uuid.New(), BlockedID: blocked.ID, BlockerID: blocker.ID, CreatedAt: time.Now()}
f.deps = append(f.deps, d)
return d, nil
}
func (f *fakeIssueSvc) RemoveDependency(ctx context.Context, c project.Caller, slug string, blockedNumber, blockerNumber int) error {
blocked, err := f.Get(ctx, c, slug, blockedNumber)
if err != nil {
return err
}
blocker, err := f.Get(ctx, c, slug, blockerNumber)
if err != nil {
return err
}
for idx, d := range f.deps {
if d.BlockedID == blocked.ID && d.BlockerID == blocker.ID {
f.deps = append(f.deps[:idx], f.deps[idx+1:]...)
return nil
}
}
return errs.New(errs.CodeNotFound, "依赖不存在")
}
func (f *fakeIssueSvc) ListDependencies(ctx context.Context, c project.Caller, slug string, number int) ([]*project.Issue, []*project.Issue, error) {
iss, err := f.Get(ctx, c, slug, number)
if err != nil {
return nil, nil, err
}
byID := func(id uuid.UUID) *project.Issue {
for _, x := range f.items {
if x.ID == id {
return x
}
}
return nil
}
var blockedBy, blocks []*project.Issue
for _, d := range f.deps {
if d.BlockedID == iss.ID {
if b := byID(d.BlockerID); b != nil {
blockedBy = append(blockedBy, b)
}
}
if d.BlockerID == iss.ID {
if b := byID(d.BlockedID); b != nil {
blocks = append(blocks, b)
}
}
}
return blockedBy, blocks, nil
}
// fakeWorkspaceSvc implements workspace.WorkspaceService.
type fakeWorkspaceSvc struct {
@@ -359,6 +492,7 @@ func testDeps() ServerDeps {
Projects: newFakeProjectSvc(proj),
Reqs: &fakeRequirementSvc{},
Issues: &fakeIssueSvc{},
Artifacts: &fakeArtifactSvc{},
Workspaces: &fakeWorkspaceSvc{},
}
}
@@ -607,6 +741,38 @@ func TestSetRequirementPhase(t *testing.T) {
assert.Equal(t, "auditing", out.Requirement.Phase)
}
func TestApproveRequirementArtifact(t *testing.T) {
deps := testDeps()
_, err := callTool(ctxWithAuth(Scope{}), deps, "add_requirement_artifact", map[string]any{
"project_slug": "test-project", "number": float64(1), "phase": "auditing", "content": "# audit",
})
require.NoError(t, err)
result, err := callTool(ctxWithAuth(Scope{}), deps, "approve_requirement_artifact", map[string]any{
"project_slug": "test-project", "number": float64(1), "phase": "auditing",
"version": float64(1), "verdict": "pass",
})
require.NoError(t, err)
var out artifactCreateOut
unmarshalStructured(t, result, &out)
assert.True(t, out.OK)
assert.Equal(t, "pass", out.Artifact.Verdict)
assert.True(t, out.Artifact.Approved)
}
func TestApproveRequirementArtifact_ScopeGated(t *testing.T) {
deps := testDeps()
// scope 不含 approve_requirement_artifact → CheckToolFromCtx 拒绝(IsError)。
result, err := callTool(ctxWithAuth(Scope{Tools: []string{"add_requirement_artifact"}}), deps,
"approve_requirement_artifact", map[string]any{
"project_slug": "test-project", "number": float64(1), "phase": "auditing",
"version": float64(1), "verdict": "pass",
})
require.NoError(t, err)
assert.True(t, result.IsError, "token scope without approve_requirement_artifact must deny")
}
func TestListRequirements(t *testing.T) {
deps := testDeps()
+102
View File
@@ -25,6 +25,12 @@ type RunService interface {
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 暴露。
//
@@ -40,6 +46,8 @@ func registerRunTools(srv *mcpsdk.Server, deps ServerDeps) {
registerRestartRunProfile(srv, deps)
registerGetRunStatus(srv, deps)
registerGetRunLogs(srv, deps)
registerRunCommand(srv, deps)
registerRunTests(srv, deps)
}
// runProfileInWorkspace 校验 profile 归属于给定 workspace 并返回其 UUID。
@@ -324,3 +332,97 @@ func registerGetRunLogs(srv *mcpsdk.Server, deps ServerDeps) {
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
})
}
+54
View File
@@ -35,6 +35,7 @@ func registerWorkspaceTools(srv *mcpsdk.Server, deps ServerDeps) {
registerGitStatus(srv, deps)
registerGitCommit(srv, deps)
registerGitPush(srv, deps)
registerGitDiff(srv, deps)
}
// ===== 共用 DTO / helper =====
@@ -581,6 +582,59 @@ func registerGitCommit(srv *mcpsdk.Server, deps ServerDeps) {
})
}
// ===== git_diff (read-only) =====
type gitDiffIn struct {
WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"`
WorktreeID string `json:"worktree_id,omitempty" jsonschema:"optional worktree UUID; omit to target the main worktree"`
Ref string `json:"ref,omitempty" jsonschema:"base ref; empty diffs working tree against HEAD"`
Against string `json:"against,omitempty" jsonschema:"second ref; when set with ref produces ref..against"`
Staged bool `json:"staged,omitempty" jsonschema:"diff the staged index (--cached) instead of the working tree"`
Paths []string `json:"paths,omitempty" jsonschema:"optional pathspecs to restrict the diff"`
ContextLines int `json:"context_lines,omitempty" jsonschema:"unified context lines (git default 3 when omitted)"`
}
type gitDiffOut struct {
Diff string `json:"diff"`
Truncated bool `json:"truncated"`
}
func registerGitDiff(srv *mcpsdk.Server, deps ServerDeps) {
mcpsdk.AddTool(srv,
&mcpsdk.Tool{Name: "git_diff", Description: "Show the unified git diff of the main worktree or a branch worktree. Returns raw diff text (capped at 1 MiB)."},
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in gitDiffIn) (*mcpsdk.CallToolResult, gitDiffOut, error) {
if err := CheckToolFromCtx(ctx, "git_diff"); err != nil {
return nil, gitDiffOut{}, err
}
c, err := deps.Caller.Resolve(ctx)
if err != nil {
return nil, gitDiffOut{}, err
}
w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID)
if err != nil {
return nil, gitDiffOut{}, err
}
opts := git.DiffOptions{
Ref: in.Ref, Against: in.Against, Staged: in.Staged,
Paths: in.Paths, ContextLines: in.ContextLines,
}
var res git.DiffResult
if in.WorktreeID == "" {
res, err = deps.GitOps.DiffOnMain(ctx, wc, w.ID, opts)
} else {
var wt *workspace.Worktree
wt, err = worktreeInWorkspace(ctx, deps, wc, w.ID, in.WorktreeID)
if err != nil {
return nil, gitDiffOut{}, err
}
res, err = deps.GitOps.DiffOnWorktree(ctx, wc, wt.ID, opts)
}
if err != nil {
return nil, gitDiffOut{}, err
}
return nil, gitDiffOut{Diff: res.Diff, Truncated: res.Truncated}, nil
})
}
func registerGitPush(srv *mcpsdk.Server, deps ServerDeps) {
mcpsdk.AddTool(srv,
&mcpsdk.Tool{Name: "git_push", Description: "Push the main worktree or a branch worktree to the remote."},
+31
View File
@@ -77,6 +77,7 @@ type fakeGitOpsSvc struct {
statusFiles []git.FileStatus
lastTarget string // "main:<wsID>" / "worktree:<wtID>"
lastCommit workspace.CommitInput
diffText string
}
func (f *fakeGitOpsSvc) StatusOnMain(_ context.Context, _ workspace.Caller, wsID uuid.UUID) ([]git.FileStatus, error) {
@@ -113,6 +114,14 @@ func (f *fakeGitOpsSvc) LogOnWorktree(_ context.Context, _ workspace.Caller, wtI
f.lastTarget = "worktree:" + wtID.String()
return nil, nil
}
func (f *fakeGitOpsSvc) DiffOnMain(_ context.Context, _ workspace.Caller, wsID uuid.UUID, _ git.DiffOptions) (git.DiffResult, error) {
f.lastTarget = "main:" + wsID.String()
return git.DiffResult{Diff: f.diffText}, nil
}
func (f *fakeGitOpsSvc) DiffOnWorktree(_ context.Context, _ workspace.Caller, wtID uuid.UUID, _ git.DiffOptions) (git.DiffResult, error) {
f.lastTarget = "worktree:" + wtID.String()
return git.DiffResult{Diff: f.diffText}, nil
}
// wsTestDeps 在 testDeps 基础上挂一个已存在的 workspace + worktree 服务。
func wsTestDeps() (ServerDeps, *fakeWorkspaceSvc, *fakeWorktreeSvc, *fakeGitOpsSvc) {
@@ -318,3 +327,25 @@ func TestGitCommit_Worktree(t *testing.T) {
assert.False(t, gitSvc.lastCommit.AddAll)
assert.True(t, gitSvc.lastCommit.Push)
}
func TestGitDiff_MainAndScope(t *testing.T) {
deps, _, _, gitSvc := wsTestDeps()
gitSvc.diffText = "diff --git a/x b/x\n+added\n"
result, err := callTool(ctxWithAuth(Scope{}), deps, "git_diff", map[string]any{
"workspace_id": testWSID.String(),
})
require.NoError(t, err)
require.False(t, result.IsError)
var out gitDiffOut
unmarshalStructured(t, result, &out)
assert.Contains(t, out.Diff, "+added")
assert.Equal(t, "main:"+testWSID.String(), gitSvc.lastTarget)
// scope to a different project => denied
result, err = callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{uuid.New()}}), deps, "git_diff", map[string]any{
"workspace_id": testWSID.String(),
})
require.NoError(t, err)
assert.True(t, result.IsError)
}