You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
})
|
||||
|
||||
@@ -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 白名单")
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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."},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user