From 1415d3b43b91db71d4582d6f5dc8a58c412d77b3 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Thu, 11 Jun 2026 08:21:09 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=20MCP=20=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/acp/mcp_bridge.go | 139 ++++++ internal/app/app.go | 45 +- internal/chat/domain.go | 8 +- internal/chat/handler.go | 14 +- internal/chat/handler_test.go | 6 +- internal/chat/service_template.go | 24 +- internal/chat/service_template_test.go | 102 ++++ internal/mcp/auth_middleware.go | 2 + internal/mcp/server.go | 15 +- internal/mcp/tools_acp.go | 434 +++++++++++++++++ internal/mcp/tools_acp_test.go | 218 +++++++++ internal/mcp/tools_chat.go | 573 ++++++++++++++++++++++- internal/mcp/tools_chat_test.go | 407 ++++++++++++++++ internal/mcp/tools_pm.go | 175 +++++++ internal/mcp/tools_pm_project_test.go | 69 +++ internal/mcp/tools_pm_test.go | 102 +++- internal/mcp/tools_run.go | 326 +++++++++++++ internal/mcp/tools_run_test.go | 198 ++++++++ internal/mcp/tools_workspace.go | 614 +++++++++++++++++++++++++ internal/mcp/tools_workspace_test.go | 312 +++++++++++++ 20 files changed, 3722 insertions(+), 61 deletions(-) create mode 100644 internal/acp/mcp_bridge.go create mode 100644 internal/chat/service_template_test.go create mode 100644 internal/mcp/tools_acp.go create mode 100644 internal/mcp/tools_acp_test.go create mode 100644 internal/mcp/tools_chat_test.go create mode 100644 internal/mcp/tools_pm_project_test.go create mode 100644 internal/mcp/tools_run.go create mode 100644 internal/mcp/tools_run_test.go create mode 100644 internal/mcp/tools_workspace.go create mode 100644 internal/mcp/tools_workspace_test.go diff --git a/internal/acp/mcp_bridge.go b/internal/acp/mcp_bridge.go new file mode 100644 index 0000000..2e59189 --- /dev/null +++ b/internal/acp/mcp_bridge.go @@ -0,0 +1,139 @@ +// mcp_bridge.go 实现 mcp.ACPBridge:把 mcp 的 acp-free DTO 转译到本包 +// service 调用。放在 acp 包内(acp 已 import mcp,转译逻辑贴近领域类型), +// 避免 mcp → acp 的循环依赖。 +package acp + +import ( + "context" + + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/mcp" +) + +// MCPBridge 适配 AgentKind/Session/Permission 三个服务到 mcp.ACPBridge。 +type MCPBridge struct { + kinds AgentKindService + sessions SessionService + perms *PermissionService +} + +// NewMCPBridge 构造桥接适配器,由 app.go 装配 mcp.ServerDeps 时调用。 +func NewMCPBridge(kinds AgentKindService, sessions SessionService, perms *PermissionService) *MCPBridge { + return &MCPBridge{kinds: kinds, sessions: sessions, perms: perms} +} + +var _ mcp.ACPBridge = (*MCPBridge)(nil) + +func (b *MCPBridge) ListAgentKinds(ctx context.Context, userID uuid.UUID, isAdmin bool) ([]mcp.ACPAgentKind, error) { + ks, err := b.kinds.List(ctx, Caller{UserID: userID, IsAdmin: isAdmin}) + if err != nil { + return nil, err + } + out := make([]mcp.ACPAgentKind, 0, len(ks)) + for _, k := range ks { + out = append(out, agentKindToMCP(k)) + } + return out, nil +} + +func (b *MCPBridge) GetAgentKind(ctx context.Context, userID uuid.UUID, isAdmin bool, id uuid.UUID) (*mcp.ACPAgentKind, error) { + k, err := b.kinds.Get(ctx, Caller{UserID: userID, IsAdmin: isAdmin}, id) + if err != nil { + return nil, err + } + out := agentKindToMCP(k) + return &out, nil +} + +func (b *MCPBridge) CreateSession(ctx context.Context, userID uuid.UUID, isAdmin bool, in mcp.ACPCreateSessionInput) (*mcp.ACPSession, error) { + s, err := b.sessions.Create(ctx, Caller{UserID: userID, IsAdmin: isAdmin}, CreateSessionInput{ + WorkspaceID: in.WorkspaceID, + AgentKindID: in.AgentKindID, + Branch: in.Branch, + IssueNumber: in.IssueNumber, + RequirementNumber: in.RequirementNumber, + InitialPrompt: in.InitialPrompt, + }) + if err != nil { + return nil, err + } + out := sessionToMCP(s) + return &out, nil +} + +func (b *MCPBridge) GetSession(ctx context.Context, userID uuid.UUID, isAdmin bool, id uuid.UUID) (*mcp.ACPSession, error) { + s, err := b.sessions.Get(ctx, Caller{UserID: userID, IsAdmin: isAdmin}, id) + if err != nil { + return nil, err + } + out := sessionToMCP(s) + return &out, nil +} + +func (b *MCPBridge) ListSessions(ctx context.Context, userID uuid.UUID, isAdmin bool, f mcp.ACPSessionFilter) ([]mcp.ACPSession, error) { + ss, err := b.sessions.List(ctx, Caller{UserID: userID, IsAdmin: isAdmin}, SessionListFilter{ + All: f.All, + RequirementID: f.RequirementID, + }) + if err != nil { + return nil, err + } + out := make([]mcp.ACPSession, 0, len(ss)) + for _, s := range ss { + out = append(out, sessionToMCP(s)) + } + return out, nil +} + +func (b *MCPBridge) TerminateSession(ctx context.Context, userID uuid.UUID, isAdmin bool, id uuid.UUID) error { + return b.sessions.Terminate(ctx, Caller{UserID: userID, IsAdmin: isAdmin}, id) +} + +func (b *MCPBridge) ListPendingPermissions(ctx context.Context, userID uuid.UUID, isAdmin bool, sessionID uuid.UUID) ([]mcp.ACPPermission, error) { + ps, err := b.perms.ListPendingBySession(ctx, Caller{UserID: userID, IsAdmin: isAdmin}, sessionID) + if err != nil { + return nil, err + } + out := make([]mcp.ACPPermission, 0, len(ps)) + for _, p := range ps { + out = append(out, mcp.ACPPermission{ + ID: p.ID, + SessionID: p.SessionID, + AgentRequestID: p.AgentRequestID, + ToolName: p.ToolName, + Status: string(p.Status), + CreatedAt: p.CreatedAt, + }) + } + return out, nil +} + +func agentKindToMCP(k *AgentKind) mcp.ACPAgentKind { + return mcp.ACPAgentKind{ + ID: k.ID, + Name: k.Name, + DisplayName: k.DisplayName, + Description: k.Description, + Enabled: k.Enabled, + ToolAllowlist: k.ToolAllowlist, + } +} + +func sessionToMCP(s *Session) mcp.ACPSession { + return mcp.ACPSession{ + ID: s.ID, + WorkspaceID: s.WorkspaceID, + ProjectID: s.ProjectID, + AgentKindID: s.AgentKindID, + UserID: s.UserID, + IssueID: s.IssueID, + RequirementID: s.RequirementID, + Branch: s.Branch, + IsMainWorktree: s.IsMainWorktree, + Status: string(s.Status), + LastError: s.LastError, + StartedAt: s.StartedAt, + EndedAt: s.EndedAt, + } +} diff --git a/internal/app/app.go b/internal/app/app.go index 58af317..07fe761 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -251,30 +251,15 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) { chat.NewHandler(chatConvSvc, chatMsgSvc, chatTplSvc, chatEpSvc, chatUsageSvc, chatUploader, userSvc, userAdminAdapter{svc: userSvc}).Mount(r) - // ===== MCP 模块装配 ===== + // ===== MCP 模块装配(第一段:token/限流)===== + // Server 本体的装配在 ACP / run 模块之后(ServerDeps 依赖 acp bridge 与 runSvc), + // 这里只构造 ACP reaper / supervisor 需要的 token service。 mcpRepo := mcp.NewPostgresRepository(pool) mcpTokenSvc := mcp.NewTokenService(mcpRepo, auditRec, nil) // mcp 自身 audit 不需要 via_mcp 标记 mcpRateLimiter := mcp.NewRateLimiter(mcp.RateLimitConfig{ PerTokenPerMinute: cfg.MCP.RateLimit.PerTokenPerMinute, GlobalPerMinute: cfg.MCP.RateLimit.GlobalPerMinute, }, nil) - mcpDeps := mcp.ServerDeps{ - Caller: mcp.NewCallerResolver(userSvc), - Projects: projectSvc, - Reqs: requirementSvc, - Issues: issueSvc, - Artifacts: artifactSvc, - Workspaces: wsSvc, - Templates: chatTplSvc, - Messages: chatMsgSvc, - Conversations: chatConvSvc, - } - mcpServer := mcp.NewMCPServer(mcpDeps) - mcp.MountTransports(r, mcpServer, mcpTokenSvc, mcpRateLimiter) - mcp.MountAdmin(r, mcp.AdminHandlerDeps{ - Tokens: mcpTokenSvc, - Users: userSvc, - }) // ===== ACP module wiring (Part 2: full SessionService + Supervisor) ===== acpRepo := acp.NewPostgresRepository(pool) @@ -420,6 +405,30 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) { }).Mount(r) } + // ===== MCP 模块装配(第二段:Server 本体 + transports + admin CRUD)===== + mcpDeps := mcp.ServerDeps{ + Caller: mcp.NewCallerResolver(userSvc), + Projects: projectSvc, + Reqs: requirementSvc, + Issues: issueSvc, + Artifacts: artifactSvc, + Workspaces: wsSvc, + Worktrees: wtSvc, + GitOps: gopSvc, + Templates: chatTplSvc, + Messages: chatMsgSvc, + Conversations: chatConvSvc, + Endpoints: chatEpSvc, + ACP: acp.NewMCPBridge(akSvc, sessSvc, acpPermSvc), + Runs: runSvc, + } + mcpServer := mcp.NewMCPServer(mcpDeps) + mcp.MountTransports(r, mcpServer, mcpTokenSvc, mcpRateLimiter) + mcp.MountAdmin(r, mcp.AdminHandlerDeps{ + Tokens: mcpTokenSvc, + Users: userSvc, + }) + // ===== jobs module wiring ===== jobsRepo := jobs.NewPostgresRepository(pool) diff --git a/internal/chat/domain.go b/internal/chat/domain.go index 64e98d8..a7d5e16 100644 --- a/internal/chat/domain.go +++ b/internal/chat/domain.go @@ -219,11 +219,15 @@ type SSEEvent struct { } // TemplateService — prompt_templates CRUD。 +// +// 鉴权语义:user 模板仅 owner 可改/删;system 模板仅 admin 可改/删 +// (isAdmin 由调用方解析后传入;Create 的 system-scope admin 校验 +// 仍在 HTTP handler / MCP 工具层,与既有行为一致)。 type TemplateService interface { List(ctx context.Context, userID uuid.UUID) ([]PromptTemplate, error) Create(ctx context.Context, userID uuid.UUID, name, content string, scope TemplateScope) (*PromptTemplate, error) - Update(ctx context.Context, userID, id uuid.UUID, name, content string) error - Delete(ctx context.Context, userID, id uuid.UUID) error + Update(ctx context.Context, userID uuid.UUID, isAdmin bool, id uuid.UUID, name, content string) error + Delete(ctx context.Context, userID uuid.UUID, isAdmin bool, id uuid.UUID) error } // EndpointService — admin LLM endpoints / models CRUD(管理员守卫上层处理)。 diff --git a/internal/chat/handler.go b/internal/chat/handler.go index fa11af1..8b652b8 100644 --- a/internal/chat/handler.go +++ b/internal/chat/handler.go @@ -492,7 +492,12 @@ func (h *Handler) updateTemplate(w http.ResponseWriter, r *http.Request) { writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body")) return } - if err := h.tplSvc.Update(r.Context(), uid, id, req.Name, req.Content); err != nil { + isAdmin, err := h.users.IsAdmin(r.Context(), uid) + if err != nil { + writeErr(w, r, err) + return + } + if err := h.tplSvc.Update(r.Context(), uid, isAdmin, id, req.Name, req.Content); err != nil { writeErr(w, r, err) return } @@ -510,7 +515,12 @@ func (h *Handler) deleteTemplate(w http.ResponseWriter, r *http.Request) { writeErr(w, r, err) return } - if err := h.tplSvc.Delete(r.Context(), uid, id); err != nil { + isAdmin, err := h.users.IsAdmin(r.Context(), uid) + if err != nil { + writeErr(w, r, err) + return + } + if err := h.tplSvc.Delete(r.Context(), uid, isAdmin, id); err != nil { writeErr(w, r, err) return } diff --git a/internal/chat/handler_test.go b/internal/chat/handler_test.go index a4718be..f309678 100644 --- a/internal/chat/handler_test.go +++ b/internal/chat/handler_test.go @@ -118,8 +118,10 @@ func (f *fakeTplSvc) Create(ctx context.Context, userID uuid.UUID, name, content } return &PromptTemplate{ID: uuid.New(), Name: name, Content: content, Scope: scope, CreatedAt: time.Now(), UpdatedAt: time.Now()}, nil } -func (f *fakeTplSvc) Update(_ context.Context, _, _ uuid.UUID, _, _ string) error { return nil } -func (f *fakeTplSvc) Delete(_ context.Context, _, _ uuid.UUID) error { return nil } +func (f *fakeTplSvc) Update(_ context.Context, _ uuid.UUID, _ bool, _ uuid.UUID, _, _ string) error { + return nil +} +func (f *fakeTplSvc) Delete(_ context.Context, _ uuid.UUID, _ bool, _ uuid.UUID) error { return nil } // ===== Fake EndpointService ===== diff --git a/internal/chat/service_template.go b/internal/chat/service_template.go index db8c0b4..e12930d 100644 --- a/internal/chat/service_template.go +++ b/internal/chat/service_template.go @@ -54,7 +54,7 @@ func (s *templateService) Create(ctx context.Context, userID uuid.UUID, name, co return t, nil } -func (s *templateService) Update(ctx context.Context, userID, id uuid.UUID, name, content string) error { +func (s *templateService) Update(ctx context.Context, userID uuid.UUID, isAdmin bool, id uuid.UUID, name, content string) error { t, err := s.repo.GetPromptTemplate(ctx, id) if err != nil { return err @@ -62,8 +62,8 @@ func (s *templateService) Update(ctx context.Context, userID, id uuid.UUID, name if t == nil { return errs.New(errs.CodeChatTemplateNotFound, "template not found") } - if t.Scope == TemplateScopeUser && (t.OwnerID == nil || *t.OwnerID != userID) { - return errs.New(errs.CodeForbidden, "not template owner") + if err := checkTemplateWriteAccess(t, userID, isAdmin); err != nil { + return err } if err := s.repo.UpdatePromptTemplate(ctx, id, name, content); err != nil { return err @@ -77,7 +77,7 @@ func (s *templateService) Update(ctx context.Context, userID, id uuid.UUID, name return nil } -func (s *templateService) Delete(ctx context.Context, userID, id uuid.UUID) error { +func (s *templateService) Delete(ctx context.Context, userID uuid.UUID, isAdmin bool, id uuid.UUID) error { t, err := s.repo.GetPromptTemplate(ctx, id) if err != nil { return err @@ -85,8 +85,8 @@ func (s *templateService) Delete(ctx context.Context, userID, id uuid.UUID) erro if t == nil { return errs.New(errs.CodeChatTemplateNotFound, "template not found") } - if t.Scope == TemplateScopeUser && (t.OwnerID == nil || *t.OwnerID != userID) { - return errs.New(errs.CodeForbidden, "not template owner") + if err := checkTemplateWriteAccess(t, userID, isAdmin); err != nil { + return err } if err := s.repo.DeletePromptTemplate(ctx, id); err != nil { return err @@ -100,6 +100,18 @@ func (s *templateService) Delete(ctx context.Context, userID, id uuid.UUID) erro return nil } +// checkTemplateWriteAccess 是 Update/Delete 共用的写权限判定: +// user 模板仅 owner;system 模板仅 admin。 +func checkTemplateWriteAccess(t *PromptTemplate, userID uuid.UUID, isAdmin bool) error { + if t.Scope == TemplateScopeUser && (t.OwnerID == nil || *t.OwnerID != userID) { + return errs.New(errs.CodeForbidden, "not template owner") + } + if t.Scope == TemplateScopeSystem && !isAdmin { + return errs.New(errs.CodeForbidden, "admin only: system-scoped template") + } + return nil +} + func (s *templateService) tryAudit(ctx context.Context, e audit.Entry) { if err := s.auditor.Record(ctx, e); err != nil { s.log.Warn("audit record failed", "action", e.Action, "err", err) diff --git a/internal/chat/service_template_test.go b/internal/chat/service_template_test.go new file mode 100644 index 0000000..20f6f87 --- /dev/null +++ b/internal/chat/service_template_test.go @@ -0,0 +1,102 @@ +package chat + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" +) + +// tplFakeRepo embeds fakeRepo and overrides prompt template methods. +type tplFakeRepo struct { + *fakeRepo + templates map[uuid.UUID]*PromptTemplate +} + +func newTplFakeRepo() *tplFakeRepo { + return &tplFakeRepo{fakeRepo: newFakeRepo(), templates: map[uuid.UUID]*PromptTemplate{}} +} + +func (r *tplFakeRepo) GetPromptTemplate(_ context.Context, id uuid.UUID) (*PromptTemplate, error) { + return r.templates[id], nil // 未命中返回 (nil, nil),与真实 repo 语义一致 +} +func (r *tplFakeRepo) UpdatePromptTemplate(_ context.Context, id uuid.UUID, name, content string) error { + t, ok := r.templates[id] + if !ok { + return errs.New(errs.CodeChatTemplateNotFound, "template not found") + } + t.Name, t.Content = name, content + return nil +} +func (r *tplFakeRepo) DeletePromptTemplate(_ context.Context, id uuid.UUID) error { + delete(r.templates, id) + return nil +} + +func newTplSvc(repo *tplFakeRepo) TemplateService { + return NewTemplateService(repo, noopAuditor{}, discardLogger()) +} + +func addTplFixture(repo *tplFakeRepo, scope TemplateScope, owner *uuid.UUID) *PromptTemplate { + t := &PromptTemplate{ID: uuid.New(), Name: "tpl", Content: "c", Scope: scope, OwnerID: owner} + repo.templates[t.ID] = t + return t +} + +func requireForbidden(t *testing.T, err error) { + t.Helper() + ae, ok := errs.As(err) + require.True(t, ok) + assert.Equal(t, errs.CodeForbidden, ae.Code) +} + +func TestTemplateUpdate_SystemScopeRequiresAdmin(t *testing.T) { + repo := newTplFakeRepo() + svc := newTplSvc(repo) + sys := addTplFixture(repo, TemplateScopeSystem, nil) + uid := uuid.New() + + // 非 admin → 拒绝 + err := svc.Update(context.Background(), uid, false, sys.ID, "x", "y") + requireForbidden(t, err) + assert.Equal(t, "tpl", repo.templates[sys.ID].Name) + + // admin → 允许 + require.NoError(t, svc.Update(context.Background(), uid, true, sys.ID, "x", "y")) + assert.Equal(t, "x", repo.templates[sys.ID].Name) +} + +func TestTemplateDelete_SystemScopeRequiresAdmin(t *testing.T) { + repo := newTplFakeRepo() + svc := newTplSvc(repo) + sys := addTplFixture(repo, TemplateScopeSystem, nil) + uid := uuid.New() + + err := svc.Delete(context.Background(), uid, false, sys.ID) + requireForbidden(t, err) + assert.Contains(t, repo.templates, sys.ID) + + require.NoError(t, svc.Delete(context.Background(), uid, true, sys.ID)) + assert.NotContains(t, repo.templates, sys.ID) +} + +func TestTemplateUpdateDelete_UserScopeOwnerOnly(t *testing.T) { + repo := newTplFakeRepo() + svc := newTplSvc(repo) + owner := uuid.New() + tpl := addTplFixture(repo, TemplateScopeUser, &owner) + stranger := uuid.New() + + // 非 owner(即使 admin)→ 拒绝,保持既有 owner-only 语义 + requireForbidden(t, svc.Update(context.Background(), stranger, true, tpl.ID, "x", "y")) + requireForbidden(t, svc.Delete(context.Background(), stranger, true, tpl.ID)) + + // owner → 允许 + require.NoError(t, svc.Update(context.Background(), owner, false, tpl.ID, "x", "y")) + require.NoError(t, svc.Delete(context.Background(), owner, false, tpl.ID)) + assert.NotContains(t, repo.templates, tpl.ID) +} diff --git a/internal/mcp/auth_middleware.go b/internal/mcp/auth_middleware.go index afb51b2..0e7919b 100644 --- a/internal/mcp/auth_middleware.go +++ b/internal/mcp/auth_middleware.go @@ -22,6 +22,7 @@ type AuthSession struct { TokenID string // 用于审计 metadata 写入 UserID string // user_id 字符串形式(避免反复 uuid.UUID -> string 转换) Scope Scope + Issuer Issuer // system token(agent 会话持有)受额外限制,见 create_acp_session } // FromContext 从 ctx 取出 AuthSession,未鉴权时返 (nil, false)。 @@ -53,6 +54,7 @@ func NewAuthMiddleware(svc TokenService) func(http.Handler) http.Handler { TokenID: tok.ID.String(), UserID: tok.UserID.String(), Scope: tok.Scope, + Issuer: tok.Issuer, } ctx := context.WithValue(r.Context(), AuthContextKey, sess) next.ServeHTTP(w, r.WithContext(ctx)) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 6360916..e448924 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -16,9 +16,14 @@ type ServerDeps struct { Issues project.IssueService Artifacts project.ArtifactService Workspaces workspace.WorkspaceService + Worktrees workspace.WorktreeService + GitOps workspace.GitOpsService 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) } // NewMCPServer 装配 SDK Server 实例并注册所有工具/prompts/resources。 @@ -28,16 +33,16 @@ func NewMCPServer(deps ServerDeps) *mcpsdk.Server { Version: "0.1.0", }, nil) - // Phase F: 16 PM 工具 + // Phase F: PM 工具(projects / requirements / issues / artifacts) registerPMTools(srv, deps) // Phase G: chat / prompts / resources registerChatTools(srv, deps) registerPrompts(srv, deps) registerResources(srv, deps) + // Phase H: workspace / worktree / git、ACP、run profiles + registerWorkspaceTools(srv, deps) + registerACPTools(srv, deps) + registerRunTools(srv, deps) return srv } - -// registerChatTools 已在 tools_chat.go 中实现。 -// registerResources 已在 resources.go 中实现。 -// registerPMTools 已在 tools_pm.go 中实现。 diff --git a/internal/mcp/tools_acp.go b/internal/mcp/tools_acp.go new file mode 100644 index 0000000..f98bf12 --- /dev/null +++ b/internal/mcp/tools_acp.go @@ -0,0 +1,434 @@ +package mcp + +import ( + "context" + "time" + + "github.com/google/uuid" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" +) + +// ===== ACP 桥接抽象 ===== +// +// acp 包 import 了 mcp(supervisor 签发 system token),mcp 不能反向 import acp。 +// 这里定义 acp-free 的桥接接口与 DTO,由 internal/acp/mcp_bridge.go 实现适配 +// (acp → mcp 的既有依赖边,不新增循环)。 +// +// 范围边界(spec 决策):agent kind 的增删改(含加密 env)与权限 approve/deny +// 不经 MCP 暴露——前者是秘密载体,后者的设计初衷是人工把关。 + +// ACPAgentKind 是 agent kind 的 MCP 投影(不含 binary/args/env 等部署细节)。 +type ACPAgentKind struct { + ID uuid.UUID + Name string + DisplayName string + Description string + Enabled bool + ToolAllowlist []string +} + +// ACPSession 是 ACP 会话的 MCP 投影。 +type ACPSession struct { + ID uuid.UUID + WorkspaceID uuid.UUID + ProjectID uuid.UUID + AgentKindID uuid.UUID + UserID uuid.UUID + IssueID *uuid.UUID + RequirementID *uuid.UUID + Branch string + IsMainWorktree bool + Status string + LastError *string + StartedAt time.Time + EndedAt *time.Time +} + +// ACPPermission 是待审权限请求的 MCP 投影(只读,不含原始 toolCall/options JSON)。 +type ACPPermission struct { + ID uuid.UUID + SessionID uuid.UUID + AgentRequestID string + ToolName string + Status string + CreatedAt time.Time +} + +// ACPCreateSessionInput 与 acp.CreateSessionInput 字段一一对应。 +type ACPCreateSessionInput struct { + WorkspaceID uuid.UUID + AgentKindID uuid.UUID + Branch *string + IssueNumber *int + RequirementNumber *int + InitialPrompt *string +} + +// ACPSessionFilter 与 acp.SessionListFilter 字段一一对应。 +type ACPSessionFilter struct { + All bool + RequirementID *uuid.UUID +} + +// ACPBridge 是 mcp 调用 acp 服务的桥接接口。 +type ACPBridge interface { + ListAgentKinds(ctx context.Context, userID uuid.UUID, isAdmin bool) ([]ACPAgentKind, error) + GetAgentKind(ctx context.Context, userID uuid.UUID, isAdmin bool, id uuid.UUID) (*ACPAgentKind, error) + CreateSession(ctx context.Context, userID uuid.UUID, isAdmin bool, in ACPCreateSessionInput) (*ACPSession, error) + GetSession(ctx context.Context, userID uuid.UUID, isAdmin bool, id uuid.UUID) (*ACPSession, error) + 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) +} + +// registerACPTools 注册 ACP 相关 MCP 工具。 +func registerACPTools(srv *mcpsdk.Server, deps ServerDeps) { + registerListAgentKinds(srv, deps) + registerGetAgentKind(srv, deps) + registerCreateACPSession(srv, deps) + registerGetACPSession(srv, deps) + registerListACPSessions(srv, deps) + registerTerminateACPSession(srv, deps) + registerListPendingPermissions(srv, deps) +} + +// ===== DTO ===== + +type agentKindDetail struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Description string `json:"description,omitempty"` + Enabled bool `json:"enabled"` + ToolAllowlist []string `json:"tool_allowlist,omitempty"` +} + +func agentKindDetailFrom(k ACPAgentKind) agentKindDetail { + return agentKindDetail{ + ID: k.ID.String(), Name: k.Name, DisplayName: k.DisplayName, + Description: k.Description, Enabled: k.Enabled, ToolAllowlist: k.ToolAllowlist, + } +} + +type acpSessionDetail struct { + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + ProjectID string `json:"project_id"` + AgentKindID string `json:"agent_kind_id"` + UserID string `json:"user_id"` + IssueID string `json:"issue_id,omitempty"` + RequirementID string `json:"requirement_id,omitempty"` + Branch string `json:"branch,omitempty"` + IsMainWorktree bool `json:"is_main_worktree"` + Status string `json:"status"` + LastError string `json:"last_error,omitempty"` + StartedAt string `json:"started_at"` + EndedAt string `json:"ended_at,omitempty"` +} + +func acpSessionDetailFrom(s *ACPSession) acpSessionDetail { + d := acpSessionDetail{ + ID: s.ID.String(), WorkspaceID: s.WorkspaceID.String(), + 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"), + } + if s.IssueID != nil { + d.IssueID = s.IssueID.String() + } + if s.RequirementID != nil { + d.RequirementID = s.RequirementID.String() + } + if s.LastError != nil { + d.LastError = *s.LastError + } + if s.EndedAt != nil { + d.EndedAt = s.EndedAt.Format("2006-01-02T15:04:05Z07:00") + } + return d +} + +// checkNotSystemToken 拒绝 system token(agent 会话签发)调用:用于 +// create_acp_session,防止 agent 在会话内套娃创建嵌套会话。 +func checkNotSystemToken(ctx context.Context) error { + sess, ok := FromContext(ctx) + if !ok { + return errs.New(errs.CodeInternal, "mcp: AuthSession missing") + } + if sess.Issuer == IssuerSystem { + return errs.New(errs.CodeForbidden, + "system tokens (agent sessions) cannot create nested ACP sessions") + } + return nil +} + +// scopedACPSession 取 session 并做项目 scope 校验。 +func scopedACPSession(ctx context.Context, deps ServerDeps, userID uuid.UUID, isAdmin bool, sessionID string) (*ACPSession, error) { + sid, err := uuid.Parse(sessionID) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInvalidInput, "session_id parse") + } + s, err := deps.ACP.GetSession(ctx, userID, isAdmin, sid) + if err != nil { + return nil, err + } + if err := CheckProjectFromCtx(ctx, s.ProjectID); err != nil { + return nil, err + } + return s, nil +} + +// ===== list_agent_kinds / get_agent_kind ===== + +type listAgentKindsOut struct { + AgentKinds []agentKindDetail `json:"agent_kinds"` +} + +func registerListAgentKinds(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "list_agent_kinds", Description: "List ACP agent kinds available to the caller."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, _ struct{}) (*mcpsdk.CallToolResult, listAgentKindsOut, error) { + if err := CheckToolFromCtx(ctx, "list_agent_kinds"); err != nil { + return nil, listAgentKindsOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, listAgentKindsOut{}, err + } + ks, err := deps.ACP.ListAgentKinds(ctx, c.UserID, c.IsAdmin) + if err != nil { + return nil, listAgentKindsOut{}, err + } + out := listAgentKindsOut{AgentKinds: make([]agentKindDetail, 0, len(ks))} + for _, k := range ks { + out.AgentKinds = append(out.AgentKinds, agentKindDetailFrom(k)) + } + return nil, out, nil + }) +} + +type getAgentKindIn struct { + AgentKindID string `json:"agent_kind_id" jsonschema:"agent kind UUID"` +} + +func registerGetAgentKind(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "get_agent_kind", Description: "Get a single ACP agent kind by ID."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in getAgentKindIn) (*mcpsdk.CallToolResult, agentKindDetail, error) { + if err := CheckToolFromCtx(ctx, "get_agent_kind"); err != nil { + return nil, agentKindDetail{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, agentKindDetail{}, err + } + kid, err := uuid.Parse(in.AgentKindID) + if err != nil { + return nil, agentKindDetail{}, errs.Wrap(err, errs.CodeInvalidInput, "agent_kind_id parse") + } + k, err := deps.ACP.GetAgentKind(ctx, c.UserID, c.IsAdmin, kid) + if err != nil { + return nil, agentKindDetail{}, err + } + return nil, agentKindDetailFrom(*k), nil + }) +} + +// ===== create_acp_session ===== + +type createACPSessionIn struct { + WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"` + AgentKindID string `json:"agent_kind_id" jsonschema:"agent kind UUID"` + Branch string `json:"branch,omitempty" jsonschema:"work on this branch (mutually exclusive with issue/requirement number)"` + IssueNumber int `json:"issue_number,omitempty" jsonschema:"bind to an issue"` + RequirementNumber int `json:"requirement_number,omitempty" jsonschema:"bind to a requirement"` + InitialPrompt string `json:"initial_prompt,omitempty" jsonschema:"prompt sent to the agent after startup"` +} +type acpSessionCreateOut struct { + OK bool `json:"ok"` + Session acpSessionDetail `json:"session"` +} + +func registerCreateACPSession(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "create_acp_session", Description: "Start an ACP agent session on a workspace."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in createACPSessionIn) (*mcpsdk.CallToolResult, acpSessionCreateOut, error) { + if err := CheckToolFromCtx(ctx, "create_acp_session"); err != nil { + return nil, acpSessionCreateOut{}, err + } + if err := checkNotSystemToken(ctx); err != nil { + return nil, acpSessionCreateOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, acpSessionCreateOut{}, err + } + w, _, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) + if err != nil { + return nil, acpSessionCreateOut{}, err + } + kid, err := uuid.Parse(in.AgentKindID) + if err != nil { + return nil, acpSessionCreateOut{}, errs.Wrap(err, errs.CodeInvalidInput, "agent_kind_id parse") + } + input := ACPCreateSessionInput{WorkspaceID: w.ID, AgentKindID: kid} + if in.Branch != "" { + input.Branch = &in.Branch + } + if in.IssueNumber > 0 { + n := in.IssueNumber + input.IssueNumber = &n + } + if in.RequirementNumber > 0 { + n := in.RequirementNumber + input.RequirementNumber = &n + } + if in.InitialPrompt != "" { + input.InitialPrompt = &in.InitialPrompt + } + s, err := deps.ACP.CreateSession(ctx, c.UserID, c.IsAdmin, input) + if err != nil { + return nil, acpSessionCreateOut{}, err + } + return nil, acpSessionCreateOut{OK: true, Session: acpSessionDetailFrom(s)}, nil + }) +} + +// ===== get/list/terminate_acp_session ===== + +type getACPSessionIn struct { + SessionID string `json:"session_id" jsonschema:"ACP session UUID"` +} + +func registerGetACPSession(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "get_acp_session", Description: "Get a single ACP session by ID."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in getACPSessionIn) (*mcpsdk.CallToolResult, acpSessionDetail, error) { + if err := CheckToolFromCtx(ctx, "get_acp_session"); err != nil { + return nil, acpSessionDetail{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, acpSessionDetail{}, err + } + s, err := scopedACPSession(ctx, deps, c.UserID, c.IsAdmin, in.SessionID) + if err != nil { + return nil, acpSessionDetail{}, err + } + return nil, acpSessionDetailFrom(s), nil + }) +} + +type listACPSessionsIn struct { + All bool `json:"all,omitempty" jsonschema:"admin only: list sessions of all users"` + RequirementID string `json:"requirement_id,omitempty" jsonschema:"filter by requirement UUID"` +} +type listACPSessionsOut struct { + Sessions []acpSessionDetail `json:"sessions"` +} + +func registerListACPSessions(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "list_acp_sessions", Description: "List ACP sessions of the caller (all=true for admins)."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in listACPSessionsIn) (*mcpsdk.CallToolResult, listACPSessionsOut, error) { + if err := CheckToolFromCtx(ctx, "list_acp_sessions"); err != nil { + return nil, listACPSessionsOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, listACPSessionsOut{}, err + } + f := ACPSessionFilter{All: in.All} + if in.RequirementID != "" { + rid, err := uuid.Parse(in.RequirementID) + if err != nil { + return nil, listACPSessionsOut{}, errs.Wrap(err, errs.CodeInvalidInput, "requirement_id parse") + } + f.RequirementID = &rid + } + ss, err := deps.ACP.ListSessions(ctx, c.UserID, c.IsAdmin, f) + if err != nil { + return nil, listACPSessionsOut{}, err + } + out := listACPSessionsOut{Sessions: make([]acpSessionDetail, 0, len(ss))} + for i := range ss { + // 与 list_projects 一致:scope 外的条目静默跳过。 + if err := CheckProjectFromCtx(ctx, ss[i].ProjectID); err != nil { + continue + } + out.Sessions = append(out.Sessions, acpSessionDetailFrom(&ss[i])) + } + return nil, out, nil + }) +} + +func registerTerminateACPSession(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "terminate_acp_session", Description: "Terminate a running ACP session."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in getACPSessionIn) (*mcpsdk.CallToolResult, okOut, error) { + if err := CheckToolFromCtx(ctx, "terminate_acp_session"); err != nil { + return nil, okOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, okOut{}, err + } + s, err := scopedACPSession(ctx, deps, c.UserID, c.IsAdmin, in.SessionID) + if err != nil { + return nil, okOut{}, err + } + if err := deps.ACP.TerminateSession(ctx, c.UserID, c.IsAdmin, s.ID); err != nil { + return nil, okOut{}, err + } + return nil, okOut{OK: true}, nil + }) +} + +// ===== list_pending_permissions ===== + +type permissionDetail struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + AgentRequestID string `json:"agent_request_id"` + ToolName string `json:"tool_name"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` +} +type listPendingPermissionsOut struct { + Permissions []permissionDetail `json:"permissions"` +} + +func registerListPendingPermissions(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "list_pending_permissions", Description: "List pending permission requests of an ACP session (read-only; approval stays with humans in the web console)."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in getACPSessionIn) (*mcpsdk.CallToolResult, listPendingPermissionsOut, error) { + if err := CheckToolFromCtx(ctx, "list_pending_permissions"); err != nil { + return nil, listPendingPermissionsOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, listPendingPermissionsOut{}, err + } + s, err := scopedACPSession(ctx, deps, c.UserID, c.IsAdmin, in.SessionID) + if err != nil { + return nil, listPendingPermissionsOut{}, err + } + ps, err := deps.ACP.ListPendingPermissions(ctx, c.UserID, c.IsAdmin, s.ID) + 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 + }) +} diff --git a/internal/mcp/tools_acp_test.go b/internal/mcp/tools_acp_test.go new file mode 100644 index 0000000..a6951ae --- /dev/null +++ b/internal/mcp/tools_acp_test.go @@ -0,0 +1,218 @@ +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/infra/errs" + "github.com/yan1h/agent-coding-workflow/internal/workspace" +) + +// ===== fakes ===== + +// fakeACPBridge implements ACPBridge. +type fakeACPBridge struct { + kinds []ACPAgentKind + sessions []ACPSession + perms []ACPPermission + terminated []uuid.UUID +} + +func (f *fakeACPBridge) ListAgentKinds(_ context.Context, _ uuid.UUID, _ bool) ([]ACPAgentKind, error) { + return f.kinds, nil +} +func (f *fakeACPBridge) GetAgentKind(_ context.Context, _ uuid.UUID, _ bool, id uuid.UUID) (*ACPAgentKind, error) { + for i := range f.kinds { + if f.kinds[i].ID == id { + return &f.kinds[i], nil + } + } + return nil, errs.New(errs.CodeNotFound, "agent kind") +} +func (f *fakeACPBridge) CreateSession(_ context.Context, userID uuid.UUID, _ bool, in ACPCreateSessionInput) (*ACPSession, error) { + s := ACPSession{ + ID: uuid.New(), WorkspaceID: in.WorkspaceID, ProjectID: testPID, + AgentKindID: in.AgentKindID, UserID: userID, + Status: "starting", StartedAt: time.Now(), + } + if in.Branch != nil { + s.Branch = *in.Branch + } + f.sessions = append(f.sessions, s) + return &s, nil +} +func (f *fakeACPBridge) GetSession(_ context.Context, _ uuid.UUID, _ bool, id uuid.UUID) (*ACPSession, error) { + for i := range f.sessions { + if f.sessions[i].ID == id { + return &f.sessions[i], nil + } + } + return nil, errs.New(errs.CodeNotFound, "session") +} +func (f *fakeACPBridge) ListSessions(_ context.Context, _ uuid.UUID, _ bool, _ ACPSessionFilter) ([]ACPSession, error) { + return f.sessions, nil +} +func (f *fakeACPBridge) TerminateSession(_ context.Context, _ uuid.UUID, _ bool, id uuid.UUID) error { + f.terminated = append(f.terminated, id) + return nil +} +func (f *fakeACPBridge) ListPendingPermissions(_ context.Context, _ uuid.UUID, _ bool, sessionID uuid.UUID) ([]ACPPermission, error) { + out := make([]ACPPermission, 0, len(f.perms)) + for _, p := range f.perms { + if p.SessionID == sessionID { + out = append(out, p) + } + } + return out, nil +} + +func acpTestDeps() (ServerDeps, *fakeACPBridge) { + 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(), + }}} + bridge := &fakeACPBridge{} + deps.ACP = bridge + return deps, bridge +} + +// ===== tests ===== + +func TestListAndGetAgentKinds(t *testing.T) { + deps, bridge := acpTestDeps() + bridge.kinds = []ACPAgentKind{{ + ID: uuid.New(), Name: "claude_code", DisplayName: "Claude Code", + Enabled: true, ToolAllowlist: []string{"*"}, + }} + + result, err := callTool(ctxWithAuth(Scope{}), deps, "list_agent_kinds", map[string]any{}) + require.NoError(t, err) + require.False(t, result.IsError) + var listOut listAgentKindsOut + unmarshalStructured(t, result, &listOut) + require.Len(t, listOut.AgentKinds, 1) + assert.Equal(t, "claude_code", listOut.AgentKinds[0].Name) + + result, err = callTool(ctxWithAuth(Scope{}), deps, "get_agent_kind", map[string]any{ + "agent_kind_id": bridge.kinds[0].ID.String(), + }) + require.NoError(t, err) + var getOut agentKindDetail + unmarshalStructured(t, result, &getOut) + assert.Equal(t, "Claude Code", getOut.DisplayName) +} + +func TestACPSessionLifecycle(t *testing.T) { + deps, bridge := acpTestDeps() + + result, err := callTool(ctxWithAuth(Scope{}), deps, "create_acp_session", map[string]any{ + "workspace_id": testWSID.String(), "agent_kind_id": uuid.New().String(), + "branch": "feat/x", "initial_prompt": "do something", + }) + require.NoError(t, err) + require.False(t, result.IsError) + var createOut acpSessionCreateOut + unmarshalStructured(t, result, &createOut) + assert.True(t, createOut.OK) + assert.Equal(t, "feat/x", createOut.Session.Branch) + sid := createOut.Session.ID + + result, err = callTool(ctxWithAuth(Scope{}), deps, "get_acp_session", map[string]any{ + "session_id": sid, + }) + require.NoError(t, err) + var getOut acpSessionDetail + unmarshalStructured(t, result, &getOut) + assert.Equal(t, sid, getOut.ID) + + result, err = callTool(ctxWithAuth(Scope{}), deps, "list_acp_sessions", map[string]any{}) + require.NoError(t, err) + var listOut listACPSessionsOut + unmarshalStructured(t, result, &listOut) + require.Len(t, listOut.Sessions, 1) + + result, err = callTool(ctxWithAuth(Scope{}), deps, "terminate_acp_session", map[string]any{ + "session_id": sid, + }) + require.NoError(t, err) + require.False(t, result.IsError) + require.Len(t, bridge.terminated, 1) + assert.Equal(t, sid, bridge.terminated[0].String()) +} + +func TestCreateACPSession_DeniedForSystemToken(t *testing.T) { + deps, bridge := acpTestDeps() + + // system token(agent 会话签发)→ 不允许套娃创建嵌套 session + ctx := context.WithValue(context.Background(), AuthContextKey, &AuthSession{ + UserID: testUID.String(), + TokenID: uuid.NewString(), + Issuer: IssuerSystem, + }) + result, err := callTool(ctx, deps, "create_acp_session", map[string]any{ + "workspace_id": testWSID.String(), "agent_kind_id": uuid.New().String(), + }) + require.NoError(t, err) + assert.True(t, result.IsError) + assert.Empty(t, bridge.sessions) + + // admin token(Issuer admin)→ 允许 + ctx = context.WithValue(context.Background(), AuthContextKey, &AuthSession{ + UserID: testUID.String(), + TokenID: uuid.NewString(), + Issuer: IssuerAdmin, + }) + result, err = callTool(ctx, deps, "create_acp_session", map[string]any{ + "workspace_id": testWSID.String(), "agent_kind_id": uuid.New().String(), + }) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Len(t, bridge.sessions, 1) +} + +func TestListACPSessions_ScopeFiltered(t *testing.T) { + deps, bridge := acpTestDeps() + otherProject := uuid.New() + bridge.sessions = []ACPSession{ + {ID: uuid.New(), WorkspaceID: testWSID, ProjectID: testPID, AgentKindID: uuid.New(), UserID: testUID, Status: "running", StartedAt: time.Now()}, + {ID: uuid.New(), WorkspaceID: uuid.New(), ProjectID: otherProject, AgentKindID: uuid.New(), UserID: testUID, Status: "running", StartedAt: time.Now()}, + } + + result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{testPID}}), deps, + "list_acp_sessions", map[string]any{}) + require.NoError(t, err) + var out listACPSessionsOut + unmarshalStructured(t, result, &out) + require.Len(t, out.Sessions, 1, "scope 外项目的 session 应被过滤") + assert.Equal(t, testPID.String(), out.Sessions[0].ProjectID) +} + +func TestListPendingPermissions(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(), + }} + bridge.perms = []ACPPermission{{ + ID: uuid.New(), SessionID: sid, AgentRequestID: "req-1", + ToolName: "fs/write", Status: "pending", CreatedAt: time.Now(), + }} + + result, err := callTool(ctxWithAuth(Scope{}), deps, "list_pending_permissions", map[string]any{ + "session_id": sid.String(), + }) + require.NoError(t, err) + require.False(t, result.IsError) + var out listPendingPermissionsOut + unmarshalStructured(t, result, &out) + require.Len(t, out.Permissions, 1) + assert.Equal(t, "fs/write", out.Permissions[0].ToolName) + assert.Equal(t, "pending", out.Permissions[0].Status) +} diff --git a/internal/mcp/tools_chat.go b/internal/mcp/tools_chat.go index 0dae51d..9ce9fa9 100644 --- a/internal/mcp/tools_chat.go +++ b/internal/mcp/tools_chat.go @@ -6,14 +6,128 @@ import ( "github.com/google/uuid" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/yan1h/agent-coding-workflow/internal/chat" + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + "github.com/yan1h/agent-coding-workflow/internal/project" ) -// registerChatTools 注册 chat 相关 MCP 工具。 -// MVP 只有一个:list_recent_messages。 +// registerChatTools 注册 chat 相关 MCP 工具:会话 CRUD、发消息、消息查询、 +// prompt 模板 CRUD、模型列表。 +// +// 范围边界(spec 决策):LLM endpoint/model 管理、用量统计、附件上传、 +// 流式接收(SSE)与 cancel/retry(强依赖流式上下文)不经 MCP 暴露。 +// send_message 的 assistant 回复异步生成,调用方用 list_recent_messages 轮询。 func registerChatTools(srv *mcpsdk.Server, deps ServerDeps) { registerListRecentMessages(srv, deps) + registerListConversations(srv, deps) + registerGetConversation(srv, deps) + registerCreateConversation(srv, deps) + registerUpdateConversationTitle(srv, deps) + registerDeleteConversation(srv, deps) + registerSendMessage(srv, deps) + registerListPromptTemplates(srv, deps) + registerCreatePromptTemplate(srv, deps) + registerUpdatePromptTemplate(srv, deps) + registerDeletePromptTemplate(srv, deps) + registerListModels(srv, deps) } +// ===== 共用 DTO / helper ===== + +type conversationDetail struct { + ID string `json:"id"` + Title string `json:"title,omitempty"` + ModelID string `json:"model_id"` + ProjectID string `json:"project_id,omitempty"` + WorkspaceID string `json:"workspace_id,omitempty"` + IssueID string `json:"issue_id,omitempty"` + RequirementID string `json:"requirement_id,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func conversationDetailFrom(cv *chat.Conversation) conversationDetail { + d := conversationDetail{ + ID: cv.ID.String(), ModelID: cv.ModelID.String(), + CreatedAt: cv.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: cv.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + if cv.Title != nil { + d.Title = *cv.Title + } + if cv.ProjectID != nil { + d.ProjectID = cv.ProjectID.String() + } + if cv.WorkspaceID != nil { + d.WorkspaceID = cv.WorkspaceID.String() + } + if cv.IssueID != nil { + d.IssueID = cv.IssueID.String() + } + if cv.RequirementID != nil { + d.RequirementID = cv.RequirementID.String() + } + return d +} + +// mountRefs 是会话挂载维度的 UUID 集合(nil = 未挂载)。 +type mountRefs struct { + ProjectID *uuid.UUID + WorkspaceID *uuid.UUID + IssueID *uuid.UUID + RequirementID *uuid.UUID +} + +// resolveMountRefs 把 MCP 友好的挂载入参(project_slug + 编号)解析为 UUID, +// 并完成 project scope 校验。requirement/issue 编号必须随 project_slug 一起给。 +func resolveMountRefs(ctx context.Context, deps ServerDeps, c project.Caller, + projectSlug, workspaceID string, requirementNumber, issueNumber int) (mountRefs, error) { + var refs mountRefs + if projectSlug == "" && (requirementNumber > 0 || issueNumber > 0) { + return refs, errs.New(errs.CodeInvalidInput, + "project_slug is required when requirement_number/issue_number is set") + } + if projectSlug != "" { + p, err := deps.Projects.Get(ctx, c, projectSlug) + if err != nil { + return refs, err + } + if err := CheckProjectFromCtx(ctx, p.ID); err != nil { + return refs, err + } + pid := p.ID + refs.ProjectID = &pid + if requirementNumber > 0 { + r, err := deps.Reqs.Get(ctx, c, projectSlug, requirementNumber) + if err != nil { + return refs, err + } + rid := r.ID + refs.RequirementID = &rid + } + if issueNumber > 0 { + i, err := deps.Issues.Get(ctx, c, projectSlug, issueNumber) + if err != nil { + return refs, err + } + iid := i.ID + refs.IssueID = &iid + } + } + if workspaceID != "" { + w, _, err := scopedWorkspace(ctx, deps, c, workspaceID) + if err != nil { + return refs, err + } + wid := w.ID + refs.WorkspaceID = &wid + } + return refs, nil +} + +// ===== list_recent_messages ===== + type listRecentMessagesIn struct { ConversationID string `json:"conversation_id" jsonschema:"UUID of the conversation"` BeforeID *int64 `json:"before_id,omitempty" jsonschema:"cursor: return messages with ID < this value"` @@ -30,6 +144,16 @@ type listRecentMessagesOut struct { Messages []messageSummary `json:"messages"` } +func messageSummaryFrom(m chat.Message) messageSummary { + return messageSummary{ + ID: m.ID, + Role: string(m.Role), + Content: m.Content, + Status: string(m.Status), + CreatedAt: m.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + } +} + func registerListRecentMessages(srv *mcpsdk.Server, deps ServerDeps) { mcpsdk.AddTool(srv, &mcpsdk.Tool{Name: "list_recent_messages", Description: "List recent chat messages in a conversation."}, @@ -62,12 +186,445 @@ func registerListRecentMessages(srv *mcpsdk.Server, deps ServerDeps) { out := listRecentMessagesOut{Messages: make([]messageSummary, 0, len(msgs))} for _, m := range msgs { - out.Messages = append(out.Messages, messageSummary{ - ID: m.ID, - Role: string(m.Role), - Content: m.Content, - Status: string(m.Status), - CreatedAt: m.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + out.Messages = append(out.Messages, messageSummaryFrom(m)) + } + return nil, out, nil + }) +} + +// ===== list_conversations ===== + +type listConversationsIn struct { + ProjectSlug string `json:"project_slug,omitempty" jsonschema:"filter by project"` + WorkspaceID string `json:"workspace_id,omitempty" jsonschema:"filter by workspace UUID"` + RequirementNumber int `json:"requirement_number,omitempty" jsonschema:"filter by requirement (needs project_slug)"` + IssueNumber int `json:"issue_number,omitempty" jsonschema:"filter by issue (needs project_slug)"` + TitleQuery string `json:"title_query,omitempty" jsonschema:"fuzzy title filter"` + BeforeID string `json:"before_id,omitempty" jsonschema:"cursor: conversation UUID"` + Limit int `json:"limit,omitempty" jsonschema:"max conversations to return (default 50, max 200)"` +} +type listConversationsOut struct { + Conversations []conversationDetail `json:"conversations"` +} + +func registerListConversations(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "list_conversations", Description: "List the caller's chat conversations."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in listConversationsIn) (*mcpsdk.CallToolResult, listConversationsOut, error) { + if err := CheckToolFromCtx(ctx, "list_conversations"); err != nil { + return nil, listConversationsOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, listConversationsOut{}, err + } + refs, err := resolveMountRefs(ctx, deps, c, in.ProjectSlug, in.WorkspaceID, in.RequirementNumber, in.IssueNumber) + if err != nil { + return nil, listConversationsOut{}, err + } + filter := chat.ConversationFilter{ + ProjectID: refs.ProjectID, WorkspaceID: refs.WorkspaceID, + IssueID: refs.IssueID, RequirementID: refs.RequirementID, + TitleQuery: in.TitleQuery, + } + limit := in.Limit + if limit <= 0 { + limit = 50 + } + if limit > 200 { + limit = 200 + } + filter.Limit = limit + if in.BeforeID != "" { + bid, err := uuid.Parse(in.BeforeID) + if err != nil { + return nil, listConversationsOut{}, errs.Wrap(err, errs.CodeInvalidInput, "before_id parse") + } + filter.BeforeID = &bid + } + cvs, err := deps.Conversations.List(ctx, c.UserID, filter) + if err != nil { + return nil, listConversationsOut{}, err + } + out := listConversationsOut{Conversations: make([]conversationDetail, 0, len(cvs))} + for i := range cvs { + out.Conversations = append(out.Conversations, conversationDetailFrom(&cvs[i])) + } + return nil, out, nil + }) +} + +// ===== get_conversation ===== + +type getConversationIn struct { + ConversationID string `json:"conversation_id" jsonschema:"UUID of the conversation"` + Limit int `json:"limit,omitempty" jsonschema:"max recent messages to include (default 50, max 200)"` +} +type getConversationOut struct { + Conversation conversationDetail `json:"conversation"` + Messages []messageSummary `json:"messages"` +} + +func registerGetConversation(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "get_conversation", Description: "Get a conversation with its recent messages."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in getConversationIn) (*mcpsdk.CallToolResult, getConversationOut, error) { + if err := CheckToolFromCtx(ctx, "get_conversation"); err != nil { + return nil, getConversationOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, getConversationOut{}, err + } + convID, err := uuid.Parse(in.ConversationID) + if err != nil { + return nil, getConversationOut{}, errs.Wrap(err, errs.CodeInvalidInput, "conversation_id parse") + } + limit := in.Limit + if limit <= 0 { + limit = 50 + } + if limit > 200 { + limit = 200 + } + cv, msgs, err := deps.Conversations.Get(ctx, c.UserID, convID, limit) + if err != nil { + return nil, getConversationOut{}, err + } + out := getConversationOut{ + Conversation: conversationDetailFrom(cv), + Messages: make([]messageSummary, 0, len(msgs)), + } + for _, m := range msgs { + out.Messages = append(out.Messages, messageSummaryFrom(m)) + } + return nil, out, nil + }) +} + +// ===== create_conversation ===== + +type createConversationIn struct { + ModelID string `json:"model_id" jsonschema:"LLM model UUID (see list_models)"` + TemplateID string `json:"template_id,omitempty" jsonschema:"prompt template UUID"` + SystemPrompt string `json:"system_prompt,omitempty" jsonschema:"custom system prompt (overrides template)"` + ProjectSlug string `json:"project_slug,omitempty" jsonschema:"mount to a project"` + WorkspaceID string `json:"workspace_id,omitempty" jsonschema:"mount to a workspace UUID"` + RequirementNumber int `json:"requirement_number,omitempty" jsonschema:"mount to a requirement (needs project_slug)"` + IssueNumber int `json:"issue_number,omitempty" jsonschema:"mount to an issue (needs project_slug)"` +} +type conversationCreateOut struct { + OK bool `json:"ok"` + Conversation conversationDetail `json:"conversation"` +} + +func registerCreateConversation(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "create_conversation", Description: "Create a new chat conversation."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in createConversationIn) (*mcpsdk.CallToolResult, conversationCreateOut, error) { + if err := CheckToolFromCtx(ctx, "create_conversation"); err != nil { + return nil, conversationCreateOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, conversationCreateOut{}, err + } + modelID, err := uuid.Parse(in.ModelID) + if err != nil { + return nil, conversationCreateOut{}, errs.Wrap(err, errs.CodeInvalidInput, "model_id parse") + } + refs, err := resolveMountRefs(ctx, deps, c, in.ProjectSlug, in.WorkspaceID, in.RequirementNumber, in.IssueNumber) + if err != nil { + return nil, conversationCreateOut{}, err + } + input := chat.CreateConversationInput{ + ModelID: modelID, + ProjectID: refs.ProjectID, WorkspaceID: refs.WorkspaceID, + IssueID: refs.IssueID, RequirementID: refs.RequirementID, + } + if in.TemplateID != "" { + tid, err := uuid.Parse(in.TemplateID) + if err != nil { + return nil, conversationCreateOut{}, errs.Wrap(err, errs.CodeInvalidInput, "template_id parse") + } + input.TemplateID = &tid + } + if in.SystemPrompt != "" { + input.SystemPrompt = &in.SystemPrompt + } + cv, err := deps.Conversations.Create(ctx, c.UserID, input) + if err != nil { + return nil, conversationCreateOut{}, err + } + return nil, conversationCreateOut{OK: true, Conversation: conversationDetailFrom(cv)}, nil + }) +} + +// ===== update_conversation_title ===== + +type updateConversationTitleIn struct { + ConversationID string `json:"conversation_id" jsonschema:"UUID of the conversation"` + Title string `json:"title" jsonschema:"new title (non-empty)"` +} + +func registerUpdateConversationTitle(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "update_conversation_title", Description: "Rename a conversation."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in updateConversationTitleIn) (*mcpsdk.CallToolResult, okOut, error) { + if err := CheckToolFromCtx(ctx, "update_conversation_title"); err != nil { + return nil, okOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, okOut{}, err + } + convID, err := uuid.Parse(in.ConversationID) + if err != nil { + return nil, okOut{}, errs.Wrap(err, errs.CodeInvalidInput, "conversation_id parse") + } + if err := deps.Conversations.UpdateTitle(ctx, c.UserID, convID, in.Title); err != nil { + return nil, okOut{}, err + } + return nil, okOut{OK: true}, nil + }) +} + +// ===== delete_conversation ===== + +type deleteConversationIn struct { + ConversationID string `json:"conversation_id" jsonschema:"UUID of the conversation"` +} + +func registerDeleteConversation(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "delete_conversation", Description: "Soft-delete a conversation."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in deleteConversationIn) (*mcpsdk.CallToolResult, okOut, error) { + if err := CheckToolFromCtx(ctx, "delete_conversation"); err != nil { + return nil, okOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, okOut{}, err + } + convID, err := uuid.Parse(in.ConversationID) + if err != nil { + return nil, okOut{}, errs.Wrap(err, errs.CodeInvalidInput, "conversation_id parse") + } + if err := deps.Conversations.SoftDelete(ctx, c.UserID, convID); err != nil { + return nil, okOut{}, err + } + return nil, okOut{OK: true}, nil + }) +} + +// ===== send_message ===== + +type sendMessageIn struct { + ConversationID string `json:"conversation_id" jsonschema:"UUID of the conversation"` + Content string `json:"content" jsonschema:"message content (non-empty)"` +} +type sendMessageOut struct { + OK bool `json:"ok"` + UserMessageID int64 `json:"user_message_id"` + AssistantMessageID int64 `json:"assistant_message_id"` +} + +func registerSendMessage(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "send_message", Description: "Send a user message to a conversation. The assistant reply is generated asynchronously; poll list_recent_messages for the result (attachments are not supported via MCP)."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in sendMessageIn) (*mcpsdk.CallToolResult, sendMessageOut, error) { + if err := CheckToolFromCtx(ctx, "send_message"); err != nil { + return nil, sendMessageOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, sendMessageOut{}, err + } + convID, err := uuid.Parse(in.ConversationID) + if err != nil { + return nil, sendMessageOut{}, errs.Wrap(err, errs.CodeInvalidInput, "conversation_id parse") + } + userMsgID, assistantMsgID, err := deps.Messages.Send(ctx, c.UserID, convID, in.Content, nil) + if err != nil { + return nil, sendMessageOut{}, err + } + return nil, sendMessageOut{OK: true, UserMessageID: userMsgID, AssistantMessageID: assistantMsgID}, nil + }) +} + +// ===== list_prompt_templates ===== + +type templateDetail struct { + ID string `json:"id"` + Name string `json:"name"` + Scope string `json:"scope"` + Content string `json:"content"` +} +type listPromptTemplatesOut struct { + Templates []templateDetail `json:"templates"` +} + +func registerListPromptTemplates(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "list_prompt_templates", Description: "List prompt templates visible to the caller (system + own)."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, _ struct{}) (*mcpsdk.CallToolResult, listPromptTemplatesOut, error) { + if err := CheckToolFromCtx(ctx, "list_prompt_templates"); err != nil { + return nil, listPromptTemplatesOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, listPromptTemplatesOut{}, err + } + ts, err := deps.Templates.List(ctx, c.UserID) + if err != nil { + return nil, listPromptTemplatesOut{}, err + } + out := listPromptTemplatesOut{Templates: make([]templateDetail, 0, len(ts))} + for _, t := range ts { + out.Templates = append(out.Templates, templateDetail{ + ID: t.ID.String(), Name: t.Name, Scope: string(t.Scope), Content: t.Content, + }) + } + return nil, out, nil + }) +} + +// ===== create_prompt_template ===== + +type createPromptTemplateIn struct { + Name string `json:"name" jsonschema:"template name (non-empty)"` + Content string `json:"content" jsonschema:"template content (non-empty)"` + Scope string `json:"scope,omitempty" jsonschema:"system|user (default user; system requires admin)"` +} +type templateCreateOut struct { + OK bool `json:"ok"` + Template templateDetail `json:"template"` +} + +func registerCreatePromptTemplate(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "create_prompt_template", Description: "Create a prompt template. scope=system requires admin."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in createPromptTemplateIn) (*mcpsdk.CallToolResult, templateCreateOut, error) { + if err := CheckToolFromCtx(ctx, "create_prompt_template"); err != nil { + return nil, templateCreateOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, templateCreateOut{}, err + } + scope := chat.TemplateScope(in.Scope) + if scope == "" { + scope = chat.TemplateScopeUser + } + // 与 HTTP handler 对齐:system 模板仅 admin 可建(service 层不做此校验)。 + if scope == chat.TemplateScopeSystem && !c.IsAdmin { + return nil, templateCreateOut{}, errs.New(errs.CodeForbidden, "admin only: system-scoped template") + } + t, err := deps.Templates.Create(ctx, c.UserID, in.Name, in.Content, scope) + if err != nil { + return nil, templateCreateOut{}, err + } + return nil, templateCreateOut{OK: true, Template: templateDetail{ + ID: t.ID.String(), Name: t.Name, Scope: string(t.Scope), Content: t.Content, + }}, nil + }) +} + +// ===== update_prompt_template ===== + +type updatePromptTemplateIn struct { + TemplateID string `json:"template_id" jsonschema:"template UUID"` + Name string `json:"name" jsonschema:"new name (non-empty)"` + Content string `json:"content" jsonschema:"new content (non-empty)"` +} + +func registerUpdatePromptTemplate(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "update_prompt_template", Description: "Update a prompt template (owner or system template)."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in updatePromptTemplateIn) (*mcpsdk.CallToolResult, okOut, error) { + if err := CheckToolFromCtx(ctx, "update_prompt_template"); err != nil { + return nil, okOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, okOut{}, err + } + tid, err := uuid.Parse(in.TemplateID) + if err != nil { + return nil, okOut{}, errs.Wrap(err, errs.CodeInvalidInput, "template_id parse") + } + if err := deps.Templates.Update(ctx, c.UserID, c.IsAdmin, tid, in.Name, in.Content); err != nil { + return nil, okOut{}, err + } + return nil, okOut{OK: true}, nil + }) +} + +// ===== delete_prompt_template ===== + +type deletePromptTemplateIn struct { + TemplateID string `json:"template_id" jsonschema:"template UUID"` +} + +func registerDeletePromptTemplate(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "delete_prompt_template", Description: "Delete a prompt template (owner or system template)."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in deletePromptTemplateIn) (*mcpsdk.CallToolResult, okOut, error) { + if err := CheckToolFromCtx(ctx, "delete_prompt_template"); err != nil { + return nil, okOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, okOut{}, err + } + tid, err := uuid.Parse(in.TemplateID) + if err != nil { + return nil, okOut{}, errs.Wrap(err, errs.CodeInvalidInput, "template_id parse") + } + if err := deps.Templates.Delete(ctx, c.UserID, c.IsAdmin, tid); err != nil { + return nil, okOut{}, err + } + return nil, okOut{OK: true}, nil + }) +} + +// ===== list_models ===== + +type listModelsIn struct { + IncludeDisabled bool `json:"include_disabled,omitempty" jsonschema:"include disabled models (default false)"` +} +type modelSummary struct { + ID string `json:"id"` + ModelID string `json:"model_id"` + DisplayName string `json:"display_name"` + ContextWindow int `json:"context_window"` + MaxOutputTokens int `json:"max_output_tokens"` + Enabled bool `json:"enabled"` +} +type listModelsOut struct { + Models []modelSummary `json:"models"` +} + +func registerListModels(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "list_models", Description: "List available LLM models (for create_conversation)."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in listModelsIn) (*mcpsdk.CallToolResult, listModelsOut, error) { + if err := CheckToolFromCtx(ctx, "list_models"); err != nil { + return nil, listModelsOut{}, err + } + if _, err := deps.Caller.Resolve(ctx); err != nil { + return nil, listModelsOut{}, err + } + ms, err := deps.Endpoints.ListModels(ctx, !in.IncludeDisabled) + if err != nil { + return nil, listModelsOut{}, err + } + out := listModelsOut{Models: make([]modelSummary, 0, len(ms))} + for _, m := range ms { + out.Models = append(out.Models, modelSummary{ + ID: m.ID.String(), ModelID: m.ModelID, DisplayName: m.DisplayName, + ContextWindow: m.ContextWindow, MaxOutputTokens: m.MaxOutputTokens, + Enabled: m.Enabled, }) } return nil, out, nil diff --git a/internal/mcp/tools_chat_test.go b/internal/mcp/tools_chat_test.go new file mode 100644 index 0000000..c66d564 --- /dev/null +++ b/internal/mcp/tools_chat_test.go @@ -0,0 +1,407 @@ +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/chat" + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + "github.com/yan1h/agent-coding-workflow/internal/user" +) + +// ===== fakes ===== + +// fakeConvSvc implements chat.ConversationService. +type fakeConvSvc struct { + items []chat.Conversation + msgs []chat.Message +} + +func (f *fakeConvSvc) Create(_ context.Context, userID uuid.UUID, in chat.CreateConversationInput) (*chat.Conversation, error) { + cv := chat.Conversation{ + ID: uuid.New(), UserID: userID, ModelID: in.ModelID, + ProjectID: in.ProjectID, WorkspaceID: in.WorkspaceID, + IssueID: in.IssueID, RequirementID: in.RequirementID, + CreatedAt: time.Now(), UpdatedAt: time.Now(), + } + f.items = append(f.items, cv) + return &cv, nil +} +func (f *fakeConvSvc) List(_ context.Context, _ uuid.UUID, filter chat.ConversationFilter) ([]chat.Conversation, error) { + out := make([]chat.Conversation, 0, len(f.items)) + for _, cv := range f.items { + if filter.ProjectID != nil && (cv.ProjectID == nil || *cv.ProjectID != *filter.ProjectID) { + continue + } + out = append(out, cv) + } + return out, nil +} +func (f *fakeConvSvc) Get(_ context.Context, _ uuid.UUID, id uuid.UUID, _ int) (*chat.Conversation, []chat.Message, error) { + for i := range f.items { + if f.items[i].ID == id { + return &f.items[i], f.msgs, nil + } + } + return nil, nil, errs.New(errs.CodeNotFound, "conversation") +} +func (f *fakeConvSvc) UpdateTitle(_ context.Context, _ uuid.UUID, id uuid.UUID, title string) error { + for i := range f.items { + if f.items[i].ID == id { + f.items[i].Title = &title + return nil + } + } + return errs.New(errs.CodeNotFound, "conversation") +} +func (f *fakeConvSvc) SoftDelete(_ context.Context, _ uuid.UUID, id uuid.UUID) error { + kept := f.items[:0] + found := false + for _, cv := range f.items { + if cv.ID == id { + found = true + continue + } + kept = append(kept, cv) + } + f.items = kept + if !found { + return errs.New(errs.CodeNotFound, "conversation") + } + return nil +} + +// fakeMsgSvc implements chat.MessageService. +type fakeMsgSvc struct { + sentContent string + msgs []chat.Message +} + +func (f *fakeMsgSvc) Send(_ context.Context, _ uuid.UUID, _ uuid.UUID, content string, _ []uuid.UUID) (int64, int64, error) { + f.sentContent = content + return 1, 2, nil +} +func (f *fakeMsgSvc) Stream(_ context.Context, _ uuid.UUID, _ uuid.UUID, _ int64) (<-chan chat.SSEEvent, error) { + return nil, errs.New(errs.CodeInternal, "not implemented") +} +func (f *fakeMsgSvc) Cancel(_ context.Context, _ uuid.UUID, _ int64) error { return nil } +func (f *fakeMsgSvc) Retry(_ context.Context, _ uuid.UUID, _ int64) (int64, uuid.UUID, error) { + return 0, uuid.Nil, errs.New(errs.CodeInternal, "not implemented") +} +func (f *fakeMsgSvc) ListMessages(_ context.Context, _ uuid.UUID, _ uuid.UUID, _ *int64, _ int) ([]chat.Message, error) { + return f.msgs, nil +} + +// fakeTplSvc implements chat.TemplateService. +type fakeTplSvc struct { + items []chat.PromptTemplate +} + +func (f *fakeTplSvc) List(_ context.Context, _ uuid.UUID) ([]chat.PromptTemplate, error) { + return f.items, nil +} +func (f *fakeTplSvc) Create(_ context.Context, userID uuid.UUID, name, content string, scope chat.TemplateScope) (*chat.PromptTemplate, error) { + t := chat.PromptTemplate{ID: uuid.New(), Name: name, Content: content, Scope: scope} + if scope == chat.TemplateScopeUser { + uid := userID + t.OwnerID = &uid + } + f.items = append(f.items, t) + return &t, nil +} +func (f *fakeTplSvc) Update(_ context.Context, _ uuid.UUID, _ bool, id uuid.UUID, name, content string) error { + for i := range f.items { + if f.items[i].ID == id { + f.items[i].Name = name + f.items[i].Content = content + return nil + } + } + return errs.New(errs.CodeChatTemplateNotFound, "template not found") +} +func (f *fakeTplSvc) Delete(_ context.Context, _ uuid.UUID, _ bool, id uuid.UUID) error { + kept := f.items[:0] + for _, t := range f.items { + if t.ID != id { + kept = append(kept, t) + } + } + f.items = kept + return nil +} + +// fakeEndpointSvc implements chat.EndpointService(仅 ListModels 有效)。 +type fakeEndpointSvc struct { + models []chat.LLMModel +} + +func (f *fakeEndpointSvc) ListEndpoints(_ context.Context) ([]chat.LLMEndpoint, error) { + panic("not implemented") +} +func (f *fakeEndpointSvc) CreateEndpoint(_ context.Context, _ uuid.UUID, _ chat.CreateEndpointInput) (*chat.LLMEndpoint, error) { + panic("not implemented") +} +func (f *fakeEndpointSvc) UpdateEndpoint(_ context.Context, _ uuid.UUID, _ chat.UpdateEndpointInput) error { + panic("not implemented") +} +func (f *fakeEndpointSvc) DeleteEndpoint(_ context.Context, _ uuid.UUID) error { + panic("not implemented") +} +func (f *fakeEndpointSvc) TestEndpoint(_ context.Context, _ uuid.UUID) error { + panic("not implemented") +} +func (f *fakeEndpointSvc) ListModels(_ context.Context, onlyEnabled bool) ([]chat.LLMModel, error) { + if !onlyEnabled { + return f.models, nil + } + out := make([]chat.LLMModel, 0, len(f.models)) + for _, m := range f.models { + if m.Enabled { + out = append(out, m) + } + } + return out, nil +} +func (f *fakeEndpointSvc) CreateModel(_ context.Context, _ chat.CreateModelInput) (*chat.LLMModel, error) { + panic("not implemented") +} +func (f *fakeEndpointSvc) UpdateModel(_ context.Context, _ uuid.UUID, _ chat.UpdateModelInput) error { + panic("not implemented") +} +func (f *fakeEndpointSvc) DeleteModel(_ context.Context, _ uuid.UUID) error { + panic("not implemented") +} + +// ===== helpers ===== + +// testUID2 是非 admin 用户,用于权限边界测试。 +var testUID2 = uuid.MustParse("dddddddd-dddd-dddd-dddd-dddddddddddd") + +func chatTestDeps() (ServerDeps, *fakeConvSvc, *fakeMsgSvc, *fakeTplSvc) { + deps := testDeps() + deps.Caller = NewCallerResolver(&fakeUserSvc{users: map[uuid.UUID]*user.User{ + testUID: {ID: testUID, IsAdmin: true}, + testUID2: {ID: testUID2, IsAdmin: false}, + }}) + convSvc := &fakeConvSvc{} + msgSvc := &fakeMsgSvc{} + tplSvc := &fakeTplSvc{} + deps.Conversations = convSvc + deps.Messages = msgSvc + deps.Templates = tplSvc + deps.Endpoints = &fakeEndpointSvc{} + return deps, convSvc, msgSvc, tplSvc +} + +func ctxWithAuthUser(uid uuid.UUID, scope Scope) context.Context { + return context.WithValue(context.Background(), AuthContextKey, &AuthSession{ + UserID: uid.String(), + TokenID: uuid.NewString(), + Scope: scope, + }) +} + +// ===== tests ===== + +func TestCreateAndGetConversation(t *testing.T) { + deps, _, _, _ := chatTestDeps() + modelID := uuid.New() + + result, err := callTool(ctxWithAuth(Scope{}), deps, "create_conversation", map[string]any{ + "model_id": modelID.String(), "project_slug": "test-project", + }) + require.NoError(t, err) + require.False(t, result.IsError) + var createOut conversationCreateOut + unmarshalStructured(t, result, &createOut) + assert.True(t, createOut.OK) + assert.Equal(t, modelID.String(), createOut.Conversation.ModelID) + assert.Equal(t, testPID.String(), createOut.Conversation.ProjectID) + + result, err = callTool(ctxWithAuth(Scope{}), deps, "get_conversation", map[string]any{ + "conversation_id": createOut.Conversation.ID, + }) + require.NoError(t, err) + var getOut getConversationOut + unmarshalStructured(t, result, &getOut) + assert.Equal(t, createOut.Conversation.ID, getOut.Conversation.ID) +} + +func TestCreateConversation_NumberNeedsProjectSlug(t *testing.T) { + deps, _, _, _ := chatTestDeps() + result, err := callTool(ctxWithAuth(Scope{}), deps, "create_conversation", map[string]any{ + "model_id": uuid.New().String(), "issue_number": float64(1), + }) + require.NoError(t, err) + assert.True(t, result.IsError) +} + +func TestListConversations_FilterByProject(t *testing.T) { + deps, convSvc, _, _ := chatTestDeps() + pid := testPID + convSvc.items = []chat.Conversation{ + {ID: uuid.New(), ModelID: uuid.New(), ProjectID: &pid, CreatedAt: time.Now(), UpdatedAt: time.Now()}, + {ID: uuid.New(), ModelID: uuid.New(), CreatedAt: time.Now(), UpdatedAt: time.Now()}, + } + + result, err := callTool(ctxWithAuth(Scope{}), deps, "list_conversations", map[string]any{ + "project_slug": "test-project", + }) + require.NoError(t, err) + var out listConversationsOut + unmarshalStructured(t, result, &out) + require.Len(t, out.Conversations, 1) + assert.Equal(t, pid.String(), out.Conversations[0].ProjectID) +} + +func TestUpdateTitleAndDeleteConversation(t *testing.T) { + deps, convSvc, _, _ := chatTestDeps() + cv := chat.Conversation{ID: uuid.New(), ModelID: uuid.New(), CreatedAt: time.Now(), UpdatedAt: time.Now()} + convSvc.items = []chat.Conversation{cv} + + result, err := callTool(ctxWithAuth(Scope{}), deps, "update_conversation_title", map[string]any{ + "conversation_id": cv.ID.String(), "title": "New Title", + }) + require.NoError(t, err) + require.False(t, result.IsError) + require.NotNil(t, convSvc.items[0].Title) + assert.Equal(t, "New Title", *convSvc.items[0].Title) + + result, err = callTool(ctxWithAuth(Scope{}), deps, "delete_conversation", map[string]any{ + "conversation_id": cv.ID.String(), + }) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Empty(t, convSvc.items) +} + +func TestSendMessage(t *testing.T) { + deps, _, msgSvc, _ := chatTestDeps() + result, err := callTool(ctxWithAuth(Scope{}), deps, "send_message", map[string]any{ + "conversation_id": uuid.New().String(), "content": "hello", + }) + require.NoError(t, err) + require.False(t, result.IsError) + var out sendMessageOut + unmarshalStructured(t, result, &out) + assert.Equal(t, int64(1), out.UserMessageID) + assert.Equal(t, int64(2), out.AssistantMessageID) + assert.Equal(t, "hello", msgSvc.sentContent) +} + +func TestPromptTemplateCRUD(t *testing.T) { + deps, _, _, tplSvc := chatTestDeps() + + result, err := callTool(ctxWithAuth(Scope{}), deps, "create_prompt_template", map[string]any{ + "name": "tpl", "content": "You are helpful.", + }) + require.NoError(t, err) + require.False(t, result.IsError) + var createOut templateCreateOut + unmarshalStructured(t, result, &createOut) + assert.Equal(t, "user", createOut.Template.Scope, "scope defaults to user") + + result, err = callTool(ctxWithAuth(Scope{}), deps, "update_prompt_template", map[string]any{ + "template_id": createOut.Template.ID, "name": "tpl2", "content": "Updated.", + }) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Equal(t, "tpl2", tplSvc.items[0].Name) + + result, err = callTool(ctxWithAuth(Scope{}), deps, "list_prompt_templates", map[string]any{}) + require.NoError(t, err) + var listOut listPromptTemplatesOut + unmarshalStructured(t, result, &listOut) + require.Len(t, listOut.Templates, 1) + + result, err = callTool(ctxWithAuth(Scope{}), deps, "delete_prompt_template", map[string]any{ + "template_id": createOut.Template.ID, + }) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Empty(t, tplSvc.items) +} + +func TestCreateSystemTemplate_AdminOnly(t *testing.T) { + deps, _, _, tplSvc := chatTestDeps() + + // 非 admin → 拒绝 + result, err := callTool(ctxWithAuthUser(testUID2, Scope{}), deps, "create_prompt_template", map[string]any{ + "name": "sys", "content": "x", "scope": "system", + }) + require.NoError(t, err) + assert.True(t, result.IsError) + assert.Empty(t, tplSvc.items) + + // admin → 允许 + result, err = callTool(ctxWithAuth(Scope{}), deps, "create_prompt_template", map[string]any{ + "name": "sys", "content": "x", "scope": "system", + }) + require.NoError(t, err) + assert.False(t, result.IsError) + require.Len(t, tplSvc.items, 1) + assert.Equal(t, chat.TemplateScopeSystem, tplSvc.items[0].Scope) +} + +func TestUpdateDeletePromptTemplate_PassesAdminFlag(t *testing.T) { + deps, _, _, _ := chatTestDeps() + rec := &adminFlagRecordingTplSvc{} + deps.Templates = rec + tid := uuid.New() + + // admin caller(testUID)→ isAdmin=true 透传到 service + _, err := callTool(ctxWithAuth(Scope{}), deps, "update_prompt_template", map[string]any{ + "template_id": tid.String(), "name": "n", "content": "c", + }) + require.NoError(t, err) + assert.True(t, rec.lastIsAdmin) + + // 非 admin caller(testUID2)→ isAdmin=false + _, err = callTool(ctxWithAuthUser(testUID2, Scope{}), deps, "delete_prompt_template", map[string]any{ + "template_id": tid.String(), + }) + require.NoError(t, err) + assert.False(t, rec.lastIsAdmin) +} + +// adminFlagRecordingTplSvc 记录 Update/Delete 收到的 isAdmin 标志。 +type adminFlagRecordingTplSvc struct { + fakeTplSvc + lastIsAdmin bool +} + +func (f *adminFlagRecordingTplSvc) Update(_ context.Context, _ uuid.UUID, isAdmin bool, _ uuid.UUID, _, _ string) error { + f.lastIsAdmin = isAdmin + return nil +} +func (f *adminFlagRecordingTplSvc) Delete(_ context.Context, _ uuid.UUID, isAdmin bool, _ uuid.UUID) error { + f.lastIsAdmin = isAdmin + return nil +} + +func TestListModels(t *testing.T) { + deps, _, _, _ := chatTestDeps() + deps.Endpoints = &fakeEndpointSvc{models: []chat.LLMModel{ + {ID: uuid.New(), ModelID: "m-on", DisplayName: "On", Enabled: true}, + {ID: uuid.New(), ModelID: "m-off", DisplayName: "Off", Enabled: false}, + }} + + result, err := callTool(ctxWithAuth(Scope{}), deps, "list_models", map[string]any{}) + require.NoError(t, err) + var out listModelsOut + unmarshalStructured(t, result, &out) + require.Len(t, out.Models, 1) + assert.Equal(t, "m-on", out.Models[0].ModelID) + + result, err = callTool(ctxWithAuth(Scope{}), deps, "list_models", map[string]any{ + "include_disabled": true, + }) + require.NoError(t, err) + unmarshalStructured(t, result, &out) + assert.Len(t, out.Models, 2) +} diff --git a/internal/mcp/tools_pm.go b/internal/mcp/tools_pm.go index 22802f4..190790e 100644 --- a/internal/mcp/tools_pm.go +++ b/internal/mcp/tools_pm.go @@ -14,6 +14,10 @@ import ( func registerPMTools(srv *mcpsdk.Server, deps ServerDeps) { registerListProjects(srv, deps) + registerCreateProject(srv, deps) + registerUpdateProject(srv, deps) + registerArchiveProject(srv, deps) + registerUnarchiveProject(srv, deps) registerListWorkspaces(srv, deps) registerListRequirements(srv, deps) registerListIssues(srv, deps) @@ -807,5 +811,176 @@ func registerListRequirementArtifacts(srv *mcpsdk.Server, deps ServerDeps) { }) } +// ===== create_project ===== + +type createProjectIn struct { + Slug string `json:"slug" jsonschema:"project slug (non-empty)"` + Name string `json:"name" jsonschema:"display name (non-empty)"` + Description string `json:"description,omitempty"` + Visibility string `json:"visibility,omitempty" jsonschema:"private|internal (default private)"` +} +type projectDetail struct { + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Visibility string `json:"visibility"` + Archived bool `json:"archived"` + CreatedAt string `json:"created_at"` +} +type projectCreateOut struct { + OK bool `json:"ok"` + Project projectDetail `json:"project"` +} + +func projectDetailFrom(p *project.Project) projectDetail { + return projectDetail{ + Slug: p.Slug, Name: p.Name, Description: p.Description, + Visibility: string(p.Visibility), Archived: p.ArchivedAt != nil, + CreatedAt: p.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + } +} + +// checkProjectScopeUnrestricted 用于 create_project:token 若被限定到具体 +// project 列表,则不允许创建落在列表外的新项目(新项目 ID 创建前未知, +// 只能整体拒绝)。 +func checkProjectScopeUnrestricted(ctx context.Context) error { + sess, ok := FromContext(ctx) + if !ok { + return errs.New(errs.CodeInternal, "mcp: AuthSession missing") + } + if sess.Scope.ProjectIDs != nil { + return errs.New(errs.CodeMcpTokenScopeViolation, + "token is restricted to specific projects; creating new projects is not allowed") + } + return nil +} + +func registerCreateProject(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "create_project", Description: "Create a new project."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in createProjectIn) (*mcpsdk.CallToolResult, projectCreateOut, error) { + if err := CheckToolFromCtx(ctx, "create_project"); err != nil { + return nil, projectCreateOut{}, err + } + if err := checkProjectScopeUnrestricted(ctx); err != nil { + return nil, projectCreateOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, projectCreateOut{}, err + } + p, err := deps.Projects.Create(ctx, c, project.CreateProjectInput{ + Slug: in.Slug, Name: in.Name, Description: in.Description, + Visibility: project.Visibility(in.Visibility), + }) + if err != nil { + return nil, projectCreateOut{}, err + } + return nil, projectCreateOut{OK: true, Project: projectDetailFrom(p)}, nil + }) +} + +// ===== update_project ===== + +type updateProjectIn struct { + ProjectSlug string `json:"project_slug" jsonschema:"required field"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Visibility string `json:"visibility,omitempty" jsonschema:"private|internal"` +} + +func registerUpdateProject(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "update_project", Description: "Update a project (name/description/visibility)."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in updateProjectIn) (*mcpsdk.CallToolResult, projectCreateOut, error) { + if err := CheckToolFromCtx(ctx, "update_project"); err != nil { + return nil, projectCreateOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, projectCreateOut{}, err + } + p, err := deps.Projects.Get(ctx, c, in.ProjectSlug) + if err != nil { + return nil, projectCreateOut{}, err + } + if err := CheckProjectFromCtx(ctx, p.ID); err != nil { + return nil, projectCreateOut{}, err + } + input := project.UpdateProjectInput{} + if in.Name != "" { + input.Name = &in.Name + } + if in.Description != "" { + input.Description = &in.Description + } + if in.Visibility != "" { + v := project.Visibility(in.Visibility) + input.Visibility = &v + } + p, err = deps.Projects.Update(ctx, c, in.ProjectSlug, input) + if err != nil { + return nil, projectCreateOut{}, err + } + return nil, projectCreateOut{OK: true, Project: projectDetailFrom(p)}, nil + }) +} + +// ===== archive/unarchive_project ===== + +type archiveProjectIn struct { + ProjectSlug string `json:"project_slug" jsonschema:"required field"` +} + +func registerArchiveProject(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "archive_project", Description: "Archive a project (idempotent)."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in archiveProjectIn) (*mcpsdk.CallToolResult, okOut, error) { + if err := CheckToolFromCtx(ctx, "archive_project"); err != nil { + return nil, okOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, okOut{}, err + } + p, err := deps.Projects.Get(ctx, c, in.ProjectSlug) + if err != nil { + return nil, okOut{}, err + } + if err := CheckProjectFromCtx(ctx, p.ID); err != nil { + return nil, okOut{}, err + } + if err := deps.Projects.Archive(ctx, c, in.ProjectSlug); err != nil { + return nil, okOut{}, err + } + return nil, okOut{OK: true}, nil + }) +} + +func registerUnarchiveProject(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "unarchive_project", Description: "Unarchive a project (idempotent)."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in archiveProjectIn) (*mcpsdk.CallToolResult, okOut, error) { + if err := CheckToolFromCtx(ctx, "unarchive_project"); err != nil { + return nil, okOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, okOut{}, err + } + p, err := deps.Projects.Get(ctx, c, in.ProjectSlug) + if err != nil { + return nil, okOut{}, err + } + if err := CheckProjectFromCtx(ctx, p.ID); err != nil { + return nil, okOut{}, err + } + if err := deps.Projects.Unarchive(ctx, c, in.ProjectSlug); err != nil { + return nil, okOut{}, err + } + return nil, okOut{OK: true}, nil + }) +} + // 保证 fmt 引用(编译期检查) var _ = fmt.Sprintf diff --git a/internal/mcp/tools_pm_project_test.go b/internal/mcp/tools_pm_project_test.go new file mode 100644 index 0000000..0244b84 --- /dev/null +++ b/internal/mcp/tools_pm_project_test.go @@ -0,0 +1,69 @@ +package mcp + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateProject(t *testing.T) { + deps := testDeps() + + result, err := callTool(ctxWithAuth(Scope{}), deps, "create_project", map[string]any{ + "slug": "new-proj", "name": "New Project", "visibility": "internal", + }) + require.NoError(t, err) + require.False(t, result.IsError) + + var out projectCreateOut + unmarshalStructured(t, result, &out) + assert.True(t, out.OK) + assert.Equal(t, "new-proj", out.Project.Slug) + assert.Equal(t, "internal", out.Project.Visibility) +} + +func TestCreateProject_DeniedForProjectScopedToken(t *testing.T) { + deps := testDeps() + + // token 限定到具体 project 列表 → 不允许创建新项目 + result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{testPID}}), deps, + "create_project", map[string]any{"slug": "x", "name": "X"}) + require.NoError(t, err) + assert.True(t, result.IsError) +} + +func TestUpdateProject(t *testing.T) { + deps := testDeps() + + result, err := callTool(ctxWithAuth(Scope{}), deps, "update_project", map[string]any{ + "project_slug": "test-project", "name": "Renamed", "description": "new desc", + }) + require.NoError(t, err) + require.False(t, result.IsError) + + var out projectCreateOut + unmarshalStructured(t, result, &out) + assert.Equal(t, "Renamed", out.Project.Name) + assert.Equal(t, "new desc", out.Project.Description) +} + +func TestArchiveUnarchiveProject(t *testing.T) { + deps := testDeps() + + result, err := callTool(ctxWithAuth(Scope{}), deps, "archive_project", map[string]any{ + "project_slug": "test-project", + }) + require.NoError(t, err) + var out okOut + unmarshalStructured(t, result, &out) + assert.True(t, out.OK) + + result, err = callTool(ctxWithAuth(Scope{}), deps, "unarchive_project", map[string]any{ + "project_slug": "test-project", + }) + require.NoError(t, err) + unmarshalStructured(t, result, &out) + assert.True(t, out.OK) +} diff --git a/internal/mcp/tools_pm_test.go b/internal/mcp/tools_pm_test.go index a1372dd..3336d33 100644 --- a/internal/mcp/tools_pm_test.go +++ b/internal/mcp/tools_pm_test.go @@ -76,8 +76,19 @@ func newFakeProjectSvc(ps ...*project.Project) *fakeProjectSvc { } return f } -func (f *fakeProjectSvc) Create(_ context.Context, _ project.Caller, _ project.CreateProjectInput) (*project.Project, error) { - panic("not implemented") +func (f *fakeProjectSvc) Create(_ context.Context, c project.Caller, in project.CreateProjectInput) (*project.Project, error) { + v := in.Visibility + if v == "" { + v = project.VisibilityPrivate + } + p := &project.Project{ + ID: uuid.New(), Slug: in.Slug, Name: in.Name, Description: in.Description, + Visibility: v, OwnerID: c.UserID, CreatedAt: time.Now(), + } + f.projects = append(f.projects, p) + f.slugMap[p.Slug] = p + f.idMap[p.ID] = p + return p, nil } func (f *fakeProjectSvc) Get(_ context.Context, _ project.Caller, slug string) (*project.Project, error) { if p, ok := f.slugMap[slug]; ok { @@ -94,14 +105,38 @@ func (f *fakeProjectSvc) GetByID(_ context.Context, _ project.Caller, id uuid.UU func (f *fakeProjectSvc) List(_ context.Context, _ project.Caller, _ bool) ([]*project.Project, error) { return f.projects, nil } -func (f *fakeProjectSvc) Update(_ context.Context, _ project.Caller, _ string, _ project.UpdateProjectInput) (*project.Project, error) { - panic("not implemented") +func (f *fakeProjectSvc) Update(ctx context.Context, c project.Caller, slug string, in project.UpdateProjectInput) (*project.Project, error) { + p, err := f.Get(ctx, c, slug) + if err != nil { + return nil, err + } + if in.Name != nil { + p.Name = *in.Name + } + if in.Description != nil { + p.Description = *in.Description + } + if in.Visibility != nil { + p.Visibility = *in.Visibility + } + return p, nil } -func (f *fakeProjectSvc) Archive(_ context.Context, _ project.Caller, _ string) error { - panic("not implemented") +func (f *fakeProjectSvc) Archive(ctx context.Context, c project.Caller, slug string) error { + p, err := f.Get(ctx, c, slug) + if err != nil { + return err + } + now := time.Now() + p.ArchivedAt = &now + return nil } -func (f *fakeProjectSvc) Unarchive(_ context.Context, _ project.Caller, _ string) error { - panic("not implemented") +func (f *fakeProjectSvc) Unarchive(ctx context.Context, c project.Caller, slug string) error { + p, err := f.Get(ctx, c, slug) + if err != nil { + return err + } + p.ArchivedAt = nil + return nil } // fakeRequirementSvc implements project.RequirementService. @@ -234,11 +269,19 @@ func (f *fakeIssueSvc) Reopen(_ context.Context, _ project.Caller, slug string, // fakeWorkspaceSvc implements workspace.WorkspaceService. type fakeWorkspaceSvc struct { - items []*workspace.Workspace + items []*workspace.Workspace + branches []string } -func (f *fakeWorkspaceSvc) Create(_ context.Context, _ workspace.Caller, _ string, _ workspace.CreateWorkspaceInput) (*workspace.Workspace, error) { - panic("not implemented") +func (f *fakeWorkspaceSvc) Create(_ context.Context, _ workspace.Caller, _ string, in workspace.CreateWorkspaceInput) (*workspace.Workspace, error) { + w := &workspace.Workspace{ + ID: uuid.New(), ProjectID: testPID, + Slug: in.Slug, Name: in.Name, Description: in.Description, + GitRemoteURL: in.GitRemoteURL, DefaultBranch: in.DefaultBranch, + SyncStatus: workspace.SyncStatusIdle, CreatedAt: time.Now(), + } + f.items = append(f.items, w) + return w, nil } func (f *fakeWorkspaceSvc) Get(_ context.Context, _ workspace.Caller, id uuid.UUID) (*workspace.Workspace, error) { for _, w := range f.items { @@ -251,14 +294,37 @@ func (f *fakeWorkspaceSvc) Get(_ context.Context, _ workspace.Caller, id uuid.UU func (f *fakeWorkspaceSvc) List(_ context.Context, _ workspace.Caller, _ string) ([]*workspace.Workspace, error) { return f.items, nil } -func (f *fakeWorkspaceSvc) Update(_ context.Context, _ workspace.Caller, _ uuid.UUID, _ workspace.UpdateWorkspaceInput) (*workspace.Workspace, error) { - panic("not implemented") +func (f *fakeWorkspaceSvc) Update(ctx context.Context, c workspace.Caller, id uuid.UUID, in workspace.UpdateWorkspaceInput) (*workspace.Workspace, error) { + w, err := f.Get(ctx, c, id) + if err != nil { + return nil, err + } + if in.Name != nil { + w.Name = *in.Name + } + if in.Description != nil { + w.Description = *in.Description + } + if in.DefaultBranch != nil { + w.DefaultBranch = *in.DefaultBranch + } + return w, nil } -func (f *fakeWorkspaceSvc) Delete(_ context.Context, _ workspace.Caller, _ uuid.UUID) error { - panic("not implemented") +func (f *fakeWorkspaceSvc) Delete(ctx context.Context, c workspace.Caller, id uuid.UUID) error { + if _, err := f.Get(ctx, c, id); err != nil { + return err + } + kept := f.items[:0] + for _, w := range f.items { + if w.ID != id { + kept = append(kept, w) + } + } + f.items = kept + return nil } -func (f *fakeWorkspaceSvc) Sync(_ context.Context, _ workspace.Caller, _ uuid.UUID) (*workspace.Workspace, error) { - panic("not implemented") +func (f *fakeWorkspaceSvc) Sync(ctx context.Context, c workspace.Caller, id uuid.UUID) (*workspace.Workspace, error) { + return f.Get(ctx, c, id) } func (f *fakeWorkspaceSvc) UpsertCredential(_ context.Context, _ workspace.Caller, _ uuid.UUID, _ workspace.UpsertCredentialInput) (*workspace.Credential, error) { panic("not implemented") @@ -267,7 +333,7 @@ func (f *fakeWorkspaceSvc) GetCredentialMeta(_ context.Context, _ workspace.Call panic("not implemented") } func (f *fakeWorkspaceSvc) ListBranches(_ context.Context, _ workspace.Caller, _ uuid.UUID) ([]string, error) { - panic("not implemented") + return f.branches, nil } // ===== test helpers ===== diff --git a/internal/mcp/tools_run.go b/internal/mcp/tools_run.go new file mode 100644 index 0000000..14da01f --- /dev/null +++ b/internal/mcp/tools_run.go @@ -0,0 +1,326 @@ +package mcp + +import ( + "context" + + "github.com/google/uuid" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + "github.com/yan1h/agent-coding-workflow/internal/run" + "github.com/yan1h/agent-coding-workflow/internal/workspace" +) + +// RunService 是 run profile 工具依赖的窄接口,*run.Service 直接满足; +// 接口化便于单测注入 fake。 +type RunService interface { + List(ctx context.Context, c workspace.Caller, wsID uuid.UUID) ([]run.RunProfileResp, error) + Create(ctx context.Context, c workspace.Caller, wsID uuid.UUID, req run.CreateRunProfileReq) (run.RunProfileResp, error) + Update(ctx context.Context, c workspace.Caller, profileID uuid.UUID, req run.UpdateRunProfileReq) (run.RunProfileResp, error) + Delete(ctx context.Context, c workspace.Caller, profileID uuid.UUID) error + Start(ctx context.Context, c workspace.Caller, profileID uuid.UUID) (run.RunStatusResp, error) + Stop(ctx context.Context, c workspace.Caller, profileID uuid.UUID) (run.RunStatusResp, error) + Restart(ctx context.Context, c workspace.Caller, profileID uuid.UUID) (run.RunStatusResp, error) + Status(ctx context.Context, c workspace.Caller, profileID uuid.UUID) (run.RunStatusResp, error) + Logs(ctx context.Context, c workspace.Caller, profileID uuid.UUID, since int64, limit int) ([]run.LogLineResp, error) +} + +// registerRunTools 注册 run profile 工具。日志只提供快照式 get_run_logs, +// 流式订阅(WebSocket)不经 MCP 暴露。 +// +// 项目 scope 校验策略与 worktree 工具一致:profile 级操作要求同时给 +// workspace_id,先按 workspace 做 scope 校验,再用 List 校验 profile 归属。 +func registerRunTools(srv *mcpsdk.Server, deps ServerDeps) { + registerListRunProfiles(srv, deps) + registerCreateRunProfile(srv, deps) + registerUpdateRunProfile(srv, deps) + registerDeleteRunProfile(srv, deps) + registerStartRunProfile(srv, deps) + registerStopRunProfile(srv, deps) + registerRestartRunProfile(srv, deps) + registerGetRunStatus(srv, deps) + registerGetRunLogs(srv, deps) +} + +// runProfileInWorkspace 校验 profile 归属于给定 workspace 并返回其 UUID。 +func runProfileInWorkspace(ctx context.Context, deps ServerDeps, wc workspace.Caller, wsID uuid.UUID, profileID string) (uuid.UUID, error) { + pid, err := uuid.Parse(profileID) + if err != nil { + return uuid.Nil, errs.Wrap(err, errs.CodeInvalidInput, "profile_id parse") + } + ps, err := deps.Runs.List(ctx, wc, wsID) + if err != nil { + return uuid.Nil, err + } + for _, p := range ps { + if p.ID == pid.String() { + return pid, nil + } + } + return uuid.Nil, errs.New(errs.CodeNotFound, "run profile not found in workspace") +} + +// ===== list_run_profiles ===== + +type listRunProfilesIn struct { + WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"` +} +type listRunProfilesOut struct { + Profiles []run.RunProfileResp `json:"profiles"` +} + +func registerListRunProfiles(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "list_run_profiles", Description: "List run profiles of a workspace."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in listRunProfilesIn) (*mcpsdk.CallToolResult, listRunProfilesOut, error) { + if err := CheckToolFromCtx(ctx, "list_run_profiles"); err != nil { + return nil, listRunProfilesOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, listRunProfilesOut{}, err + } + w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) + if err != nil { + return nil, listRunProfilesOut{}, err + } + ps, err := deps.Runs.List(ctx, wc, w.ID) + if err != nil { + return nil, listRunProfilesOut{}, err + } + return nil, listRunProfilesOut{Profiles: ps}, nil + }) +} + +// ===== create_run_profile ===== + +type createRunProfileIn struct { + WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"` + Slug string `json:"slug" jsonschema:"profile slug (non-empty)"` + Name string `json:"name" jsonschema:"display name (non-empty)"` + Description string `json:"description,omitempty"` + Command string `json:"command" jsonschema:"command line to run (non-empty)"` + Args []string `json:"args,omitempty"` + Env map[string]string `json:"env,omitempty" jsonschema:"environment overrides (encrypted at rest, never returned)"` + Enabled *bool `json:"enabled,omitempty" jsonschema:"default true"` +} +type runProfileOut struct { + OK bool `json:"ok"` + Profile run.RunProfileResp `json:"profile"` +} + +func registerCreateRunProfile(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "create_run_profile", Description: "Create a run profile in a workspace."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in createRunProfileIn) (*mcpsdk.CallToolResult, runProfileOut, error) { + if err := CheckToolFromCtx(ctx, "create_run_profile"); err != nil { + return nil, runProfileOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, runProfileOut{}, err + } + w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) + if err != nil { + return nil, runProfileOut{}, err + } + p, err := deps.Runs.Create(ctx, wc, w.ID, run.CreateRunProfileReq{ + Slug: in.Slug, Name: in.Name, Description: in.Description, + Command: in.Command, Args: in.Args, Env: in.Env, Enabled: in.Enabled, + }) + if err != nil { + return nil, runProfileOut{}, err + } + return nil, runProfileOut{OK: true, Profile: p}, nil + }) +} + +// ===== update_run_profile ===== + +type updateRunProfileIn struct { + WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"` + ProfileID string `json:"profile_id" jsonschema:"run profile UUID"` + Slug *string `json:"slug,omitempty"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Command *string `json:"command,omitempty"` + Args *[]string `json:"args,omitempty"` + Env *map[string]string `json:"env,omitempty" jsonschema:"full replacement of env overrides"` + Enabled *bool `json:"enabled,omitempty"` +} + +func registerUpdateRunProfile(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "update_run_profile", Description: "Update a run profile (patch semantics: omitted fields keep current values)."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in updateRunProfileIn) (*mcpsdk.CallToolResult, runProfileOut, error) { + if err := CheckToolFromCtx(ctx, "update_run_profile"); err != nil { + return nil, runProfileOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, runProfileOut{}, err + } + w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) + if err != nil { + return nil, runProfileOut{}, err + } + pid, err := runProfileInWorkspace(ctx, deps, wc, w.ID, in.ProfileID) + if err != nil { + return nil, runProfileOut{}, err + } + p, err := deps.Runs.Update(ctx, wc, pid, run.UpdateRunProfileReq{ + Slug: in.Slug, Name: in.Name, Description: in.Description, + Command: in.Command, Args: in.Args, Env: in.Env, Enabled: in.Enabled, + }) + if err != nil { + return nil, runProfileOut{}, err + } + return nil, runProfileOut{OK: true, Profile: p}, nil + }) +} + +// ===== delete_run_profile ===== + +type runProfileTargetIn struct { + WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"` + ProfileID string `json:"profile_id" jsonschema:"run profile UUID"` +} + +func registerDeleteRunProfile(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "delete_run_profile", Description: "Delete a run profile. Irreversible."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in runProfileTargetIn) (*mcpsdk.CallToolResult, okOut, error) { + if err := CheckToolFromCtx(ctx, "delete_run_profile"); err != nil { + return nil, okOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, okOut{}, err + } + w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) + if err != nil { + return nil, okOut{}, err + } + pid, err := runProfileInWorkspace(ctx, deps, wc, w.ID, in.ProfileID) + if err != nil { + return nil, okOut{}, err + } + if err := deps.Runs.Delete(ctx, wc, pid); err != nil { + return nil, okOut{}, err + } + return nil, okOut{OK: true}, nil + }) +} + +// ===== start/stop/restart/status ===== + +type runStatusOut struct { + OK bool `json:"ok"` + Status run.RunStatusResp `json:"status"` +} + +// registerRunLifecycleTool 抽取 start/stop/restart/status 四个同构工具的注册逻辑。 +func registerRunLifecycleTool(srv *mcpsdk.Server, deps ServerDeps, name, desc string, + op func(context.Context, workspace.Caller, uuid.UUID) (run.RunStatusResp, error)) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: name, Description: desc}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in runProfileTargetIn) (*mcpsdk.CallToolResult, runStatusOut, error) { + if err := CheckToolFromCtx(ctx, name); err != nil { + return nil, runStatusOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, runStatusOut{}, err + } + w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) + if err != nil { + return nil, runStatusOut{}, err + } + pid, err := runProfileInWorkspace(ctx, deps, wc, w.ID, in.ProfileID) + if err != nil { + return nil, runStatusOut{}, err + } + st, err := op(ctx, wc, pid) + if err != nil { + return nil, runStatusOut{}, err + } + return nil, runStatusOut{OK: true, Status: st}, nil + }) +} + +// 注意:op 不能写成 deps.Runs.Start 这类 method value——注册期对 nil 接口取 +// 方法值会 panic(deps.Runs 在部分单测装配里为空),必须用闭包延迟到调用期。 + +func registerStartRunProfile(srv *mcpsdk.Server, deps ServerDeps) { + registerRunLifecycleTool(srv, deps, "start_run_profile", "Start the process of a run profile.", + func(ctx context.Context, wc workspace.Caller, pid uuid.UUID) (run.RunStatusResp, error) { + return deps.Runs.Start(ctx, wc, pid) + }) +} + +func registerStopRunProfile(srv *mcpsdk.Server, deps ServerDeps) { + registerRunLifecycleTool(srv, deps, "stop_run_profile", "Stop the process of a run profile.", + func(ctx context.Context, wc workspace.Caller, pid uuid.UUID) (run.RunStatusResp, error) { + return deps.Runs.Stop(ctx, wc, pid) + }) +} + +func registerRestartRunProfile(srv *mcpsdk.Server, deps ServerDeps) { + registerRunLifecycleTool(srv, deps, "restart_run_profile", "Restart the process of a run profile.", + func(ctx context.Context, wc workspace.Caller, pid uuid.UUID) (run.RunStatusResp, error) { + return deps.Runs.Restart(ctx, wc, pid) + }) +} + +func registerGetRunStatus(srv *mcpsdk.Server, deps ServerDeps) { + registerRunLifecycleTool(srv, deps, "get_run_status", "Get the live status of a run profile.", + func(ctx context.Context, wc workspace.Caller, pid uuid.UUID) (run.RunStatusResp, error) { + return deps.Runs.Status(ctx, wc, pid) + }) +} + +// ===== get_run_logs ===== + +type getRunLogsIn struct { + WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"` + ProfileID string `json:"profile_id" jsonschema:"run profile UUID"` + Since int64 `json:"since,omitempty" jsonschema:"return log lines with ID > this value"` + Limit int `json:"limit,omitempty" jsonschema:"max lines to return (default 200, max 1000)"` +} +type getRunLogsOut struct { + Lines []run.LogLineResp `json:"lines"` +} + +func registerGetRunLogs(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "get_run_logs", Description: "Get a snapshot of recent log lines of a run profile (no streaming)."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in getRunLogsIn) (*mcpsdk.CallToolResult, getRunLogsOut, error) { + if err := CheckToolFromCtx(ctx, "get_run_logs"); err != nil { + return nil, getRunLogsOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, getRunLogsOut{}, err + } + w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) + if err != nil { + return nil, getRunLogsOut{}, err + } + pid, err := runProfileInWorkspace(ctx, deps, wc, w.ID, in.ProfileID) + if err != nil { + return nil, getRunLogsOut{}, err + } + limit := in.Limit + if limit <= 0 { + limit = 200 + } + if limit > 1000 { + limit = 1000 + } + lines, err := deps.Runs.Logs(ctx, wc, pid, in.Since, limit) + if err != nil { + return nil, getRunLogsOut{}, err + } + return nil, getRunLogsOut{Lines: lines}, nil + }) +} diff --git a/internal/mcp/tools_run_test.go b/internal/mcp/tools_run_test.go new file mode 100644 index 0000000..b51abdd --- /dev/null +++ b/internal/mcp/tools_run_test.go @@ -0,0 +1,198 @@ +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/infra/errs" + "github.com/yan1h/agent-coding-workflow/internal/run" + "github.com/yan1h/agent-coding-workflow/internal/workspace" +) + +// ===== fakes ===== + +// fakeRunSvc implements RunService. +type fakeRunSvc struct { + profiles []run.RunProfileResp + lastOp string + logs []run.LogLineResp +} + +func (f *fakeRunSvc) List(_ context.Context, _ workspace.Caller, wsID uuid.UUID) ([]run.RunProfileResp, error) { + out := make([]run.RunProfileResp, 0, len(f.profiles)) + for _, p := range f.profiles { + if p.WorkspaceID == wsID.String() { + out = append(out, p) + } + } + return out, nil +} +func (f *fakeRunSvc) Create(_ context.Context, _ workspace.Caller, wsID uuid.UUID, req run.CreateRunProfileReq) (run.RunProfileResp, error) { + p := run.RunProfileResp{ + ID: uuid.NewString(), WorkspaceID: wsID.String(), + Slug: req.Slug, Name: req.Name, Command: req.Command, + Status: string(run.StatusStopped), + } + f.profiles = append(f.profiles, p) + return p, nil +} +func (f *fakeRunSvc) Update(_ context.Context, _ workspace.Caller, profileID uuid.UUID, req run.UpdateRunProfileReq) (run.RunProfileResp, error) { + for i := range f.profiles { + if f.profiles[i].ID == profileID.String() { + if req.Name != nil { + f.profiles[i].Name = *req.Name + } + return f.profiles[i], nil + } + } + return run.RunProfileResp{}, errs.New(errs.CodeNotFound, "profile") +} +func (f *fakeRunSvc) Delete(_ context.Context, _ workspace.Caller, profileID uuid.UUID) error { + kept := f.profiles[:0] + for _, p := range f.profiles { + if p.ID != profileID.String() { + kept = append(kept, p) + } + } + f.profiles = kept + return nil +} +func (f *fakeRunSvc) Start(_ context.Context, _ workspace.Caller, _ uuid.UUID) (run.RunStatusResp, error) { + f.lastOp = "start" + return run.RunStatusResp{Status: string(run.StatusRunning)}, nil +} +func (f *fakeRunSvc) Stop(_ context.Context, _ workspace.Caller, _ uuid.UUID) (run.RunStatusResp, error) { + f.lastOp = "stop" + return run.RunStatusResp{Status: string(run.StatusStopped)}, nil +} +func (f *fakeRunSvc) Restart(_ context.Context, _ workspace.Caller, _ uuid.UUID) (run.RunStatusResp, error) { + f.lastOp = "restart" + return run.RunStatusResp{Status: string(run.StatusRunning)}, nil +} +func (f *fakeRunSvc) Status(_ context.Context, _ workspace.Caller, _ uuid.UUID) (run.RunStatusResp, error) { + f.lastOp = "status" + return run.RunStatusResp{Status: string(run.StatusRunning)}, nil +} +func (f *fakeRunSvc) Logs(_ context.Context, _ workspace.Caller, _ uuid.UUID, _ int64, limit int) ([]run.LogLineResp, error) { + if len(f.logs) > limit { + return f.logs[:limit], nil + } + return f.logs, nil +} + +func runTestDeps() (ServerDeps, *fakeRunSvc) { + 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(), + }}} + runSvc := &fakeRunSvc{} + deps.Runs = runSvc + return deps, runSvc +} + +// ===== tests ===== + +func TestRunProfileCRUD(t *testing.T) { + deps, runSvc := runTestDeps() + + result, err := callTool(ctxWithAuth(Scope{}), deps, "create_run_profile", map[string]any{ + "workspace_id": testWSID.String(), "slug": "dev", "name": "Dev", "command": "pnpm dev", + }) + require.NoError(t, err) + require.False(t, result.IsError) + var createOut runProfileOut + unmarshalStructured(t, result, &createOut) + assert.True(t, createOut.OK) + assert.Equal(t, "dev", createOut.Profile.Slug) + pid := createOut.Profile.ID + + result, err = callTool(ctxWithAuth(Scope{}), deps, "list_run_profiles", map[string]any{ + "workspace_id": testWSID.String(), + }) + require.NoError(t, err) + var listOut listRunProfilesOut + unmarshalStructured(t, result, &listOut) + require.Len(t, listOut.Profiles, 1) + + result, err = callTool(ctxWithAuth(Scope{}), deps, "update_run_profile", map[string]any{ + "workspace_id": testWSID.String(), "profile_id": pid, "name": "Dev2", + }) + require.NoError(t, err) + unmarshalStructured(t, result, &createOut) + assert.Equal(t, "Dev2", createOut.Profile.Name) + + result, err = callTool(ctxWithAuth(Scope{}), deps, "delete_run_profile", map[string]any{ + "workspace_id": testWSID.String(), "profile_id": pid, + }) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Empty(t, runSvc.profiles) +} + +func TestRunProfileLifecycleOps(t *testing.T) { + deps, runSvc := runTestDeps() + p, err := runSvc.Create(context.Background(), workspace.Caller{}, testWSID, run.CreateRunProfileReq{ + Slug: "dev", Name: "Dev", Command: "pnpm dev", + }) + require.NoError(t, err) + + for _, tc := range []struct{ tool, op, wantStatus string }{ + {"start_run_profile", "start", string(run.StatusRunning)}, + {"stop_run_profile", "stop", string(run.StatusStopped)}, + {"restart_run_profile", "restart", string(run.StatusRunning)}, + {"get_run_status", "status", string(run.StatusRunning)}, + } { + result, err := callTool(ctxWithAuth(Scope{}), deps, tc.tool, map[string]any{ + "workspace_id": testWSID.String(), "profile_id": p.ID, + }) + require.NoError(t, err, tc.tool) + require.False(t, result.IsError, tc.tool) + var out runStatusOut + unmarshalStructured(t, result, &out) + assert.Equal(t, tc.op, runSvc.lastOp, tc.tool) + assert.Equal(t, tc.wantStatus, out.Status.Status, tc.tool) + } +} + +func TestRunProfileMembershipEnforced(t *testing.T) { + deps, runSvc := runTestDeps() + // profile 属于其他 workspace → 不可经本 workspace 操作 + runSvc.profiles = append(runSvc.profiles, run.RunProfileResp{ + ID: uuid.NewString(), WorkspaceID: uuid.NewString(), Slug: "alien", Name: "Alien", + }) + + result, err := callTool(ctxWithAuth(Scope{}), deps, "start_run_profile", map[string]any{ + "workspace_id": testWSID.String(), "profile_id": runSvc.profiles[0].ID, + }) + require.NoError(t, err) + assert.True(t, result.IsError) + assert.Empty(t, runSvc.lastOp) +} + +func TestGetRunLogs(t *testing.T) { + deps, runSvc := runTestDeps() + p, err := runSvc.Create(context.Background(), workspace.Caller{}, testWSID, run.CreateRunProfileReq{ + Slug: "dev", Name: "Dev", Command: "pnpm dev", + }) + require.NoError(t, err) + runSvc.logs = []run.LogLineResp{ + {ID: 1, Level: "out", Text: "listening on :3000", TS: "2026-06-10T00:00:00Z"}, + {ID: 2, Level: "err", Text: "warn: deprecated", TS: "2026-06-10T00:00:01Z"}, + } + + result, err := callTool(ctxWithAuth(Scope{}), deps, "get_run_logs", map[string]any{ + "workspace_id": testWSID.String(), "profile_id": p.ID, + }) + require.NoError(t, err) + require.False(t, result.IsError) + var out getRunLogsOut + unmarshalStructured(t, result, &out) + require.Len(t, out.Lines, 2) + assert.Equal(t, "listening on :3000", out.Lines[0].Text) +} diff --git a/internal/mcp/tools_workspace.go b/internal/mcp/tools_workspace.go new file mode 100644 index 0000000..26a9833 --- /dev/null +++ b/internal/mcp/tools_workspace.go @@ -0,0 +1,614 @@ +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/infra/git" + "github.com/yan1h/agent-coding-workflow/internal/project" + "github.com/yan1h/agent-coding-workflow/internal/workspace" +) + +// registerWorkspaceTools 注册 workspace / worktree / git 操作工具。 +// +// 范围边界(spec 决策):git 凭证(UpsertCredential / GetCredentialMeta)不经 +// MCP 暴露——秘密读写仅走 Web 后台。 +// +// 项目 scope 校验策略:workspace 级工具先 Get workspace 再按 ProjectID 做 +// CheckProjectFromCtx;worktree 级工具要求同时给 workspace_id,并通过 +// Worktrees.List 校验 worktree 归属,防止用 scope 外 worktree 绕过限定。 +func registerWorkspaceTools(srv *mcpsdk.Server, deps ServerDeps) { + registerGetWorkspace(srv, deps) + registerCreateWorkspace(srv, deps) + registerUpdateWorkspace(srv, deps) + registerDeleteWorkspace(srv, deps) + registerSyncWorkspace(srv, deps) + registerListBranches(srv, deps) + registerListWorktrees(srv, deps) + registerCreateWorktree(srv, deps) + registerDeleteWorktree(srv, deps) + registerAcquireWorktree(srv, deps) + registerReleaseWorktree(srv, deps) + registerGitStatus(srv, deps) + registerGitCommit(srv, deps) + registerGitPush(srv, deps) +} + +// ===== 共用 DTO / helper ===== + +type workspaceDetail struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + GitRemoteURL string `json:"git_remote_url"` + DefaultBranch string `json:"default_branch"` + SyncStatus string `json:"sync_status"` + LastSyncedAt string `json:"last_synced_at,omitempty"` + LastSyncError string `json:"last_sync_error,omitempty"` + CreatedAt string `json:"created_at"` +} + +func workspaceDetailFrom(w *workspace.Workspace) workspaceDetail { + d := workspaceDetail{ + ID: w.ID.String(), ProjectID: w.ProjectID.String(), + Slug: w.Slug, Name: w.Name, Description: w.Description, + GitRemoteURL: w.GitRemoteURL, DefaultBranch: w.DefaultBranch, + SyncStatus: string(w.SyncStatus), LastSyncError: w.LastSyncError, + CreatedAt: w.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + if w.LastSyncedAt != nil { + d.LastSyncedAt = w.LastSyncedAt.Format("2006-01-02T15:04:05Z07:00") + } + return d +} + +type worktreeDetail struct { + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + Branch string `json:"branch"` + Path string `json:"path"` + Status string `json:"status"` + ActiveHolder string `json:"active_holder,omitempty"` + CreatedAt string `json:"created_at"` +} + +func worktreeDetailFrom(wt *workspace.Worktree) worktreeDetail { + return worktreeDetail{ + ID: wt.ID.String(), WorkspaceID: wt.WorkspaceID.String(), + Branch: wt.Branch, Path: wt.Path, Status: string(wt.Status), + ActiveHolder: wt.ActiveHolder, + CreatedAt: wt.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + } +} + +// scopedWorkspace 解析 workspace_id、取 workspace 并做项目 scope 校验。 +// 返回的 workspace.Caller 供后续 service 调用复用。 +func scopedWorkspace(ctx context.Context, deps ServerDeps, c project.Caller, workspaceID string) (*workspace.Workspace, workspace.Caller, error) { + wsID, err := uuid.Parse(workspaceID) + if err != nil { + return nil, workspace.Caller{}, errs.Wrap(err, errs.CodeInvalidInput, "workspace_id parse") + } + wc := workspaceCallerFrom(c) + w, err := deps.Workspaces.Get(ctx, wc, wsID) + if err != nil { + return nil, wc, err + } + if err := CheckProjectFromCtx(ctx, w.ProjectID); err != nil { + return nil, wc, err + } + return w, wc, nil +} + +// worktreeInWorkspace 校验 worktree 归属于给定 workspace 并返回该 worktree。 +func worktreeInWorkspace(ctx context.Context, deps ServerDeps, wc workspace.Caller, wsID uuid.UUID, worktreeID string) (*workspace.Worktree, error) { + wtID, err := uuid.Parse(worktreeID) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInvalidInput, "worktree_id parse") + } + wts, err := deps.Worktrees.List(ctx, wc, wsID) + if err != nil { + return nil, err + } + for _, wt := range wts { + if wt.ID == wtID { + return wt, nil + } + } + return nil, errs.New(errs.CodeNotFound, "worktree not found in workspace") +} + +// ===== get_workspace ===== + +type getWorkspaceIn struct { + WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"` +} + +func registerGetWorkspace(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "get_workspace", Description: "Get a single workspace by ID."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in getWorkspaceIn) (*mcpsdk.CallToolResult, workspaceDetail, error) { + if err := CheckToolFromCtx(ctx, "get_workspace"); err != nil { + return nil, workspaceDetail{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, workspaceDetail{}, err + } + w, _, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) + if err != nil { + return nil, workspaceDetail{}, err + } + return nil, workspaceDetailFrom(w), nil + }) +} + +// ===== create_workspace ===== + +type createWorkspaceIn struct { + ProjectSlug string `json:"project_slug" jsonschema:"project slug"` + Slug string `json:"slug" jsonschema:"workspace slug (non-empty)"` + Name string `json:"name" jsonschema:"display name (non-empty)"` + Description string `json:"description,omitempty"` + GitRemoteURL string `json:"git_remote_url" jsonschema:"git remote URL to clone"` + DefaultBranch string `json:"default_branch,omitempty" jsonschema:"default branch (service default when empty)"` +} +type workspaceCreateOut struct { + OK bool `json:"ok"` + Workspace workspaceDetail `json:"workspace"` +} + +func registerCreateWorkspace(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "create_workspace", Description: "Create a workspace in a project. Git credentials cannot be set via MCP; private repos need credentials configured in the web console."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in createWorkspaceIn) (*mcpsdk.CallToolResult, workspaceCreateOut, error) { + if err := CheckToolFromCtx(ctx, "create_workspace"); err != nil { + return nil, workspaceCreateOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, workspaceCreateOut{}, err + } + p, err := deps.Projects.Get(ctx, c, in.ProjectSlug) + if err != nil { + return nil, workspaceCreateOut{}, err + } + if err := CheckProjectFromCtx(ctx, p.ID); err != nil { + return nil, workspaceCreateOut{}, err + } + w, err := deps.Workspaces.Create(ctx, workspaceCallerFrom(c), in.ProjectSlug, workspace.CreateWorkspaceInput{ + Slug: in.Slug, Name: in.Name, Description: in.Description, + GitRemoteURL: in.GitRemoteURL, DefaultBranch: in.DefaultBranch, + Credential: workspace.UpsertCredentialInput{Kind: workspace.CredKindNone}, + }) + if err != nil { + return nil, workspaceCreateOut{}, err + } + return nil, workspaceCreateOut{OK: true, Workspace: workspaceDetailFrom(w)}, nil + }) +} + +// ===== update_workspace ===== + +type updateWorkspaceIn struct { + WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + DefaultBranch string `json:"default_branch,omitempty"` +} + +func registerUpdateWorkspace(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "update_workspace", Description: "Update a workspace (name/description/default_branch)."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in updateWorkspaceIn) (*mcpsdk.CallToolResult, workspaceCreateOut, error) { + if err := CheckToolFromCtx(ctx, "update_workspace"); err != nil { + return nil, workspaceCreateOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, workspaceCreateOut{}, err + } + w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) + if err != nil { + return nil, workspaceCreateOut{}, err + } + input := workspace.UpdateWorkspaceInput{} + if in.Name != "" { + input.Name = &in.Name + } + if in.Description != "" { + input.Description = &in.Description + } + if in.DefaultBranch != "" { + input.DefaultBranch = &in.DefaultBranch + } + w, err = deps.Workspaces.Update(ctx, wc, w.ID, input) + if err != nil { + return nil, workspaceCreateOut{}, err + } + return nil, workspaceCreateOut{OK: true, Workspace: workspaceDetailFrom(w)}, nil + }) +} + +// ===== delete_workspace ===== + +type deleteWorkspaceIn struct { + WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"` +} + +func registerDeleteWorkspace(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "delete_workspace", Description: "Delete a workspace and its local clone. Irreversible."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in deleteWorkspaceIn) (*mcpsdk.CallToolResult, okOut, error) { + if err := CheckToolFromCtx(ctx, "delete_workspace"); err != nil { + return nil, okOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, okOut{}, err + } + w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) + if err != nil { + return nil, okOut{}, err + } + if err := deps.Workspaces.Delete(ctx, wc, w.ID); err != nil { + return nil, okOut{}, err + } + return nil, okOut{OK: true}, nil + }) +} + +// ===== sync_workspace ===== + +func registerSyncWorkspace(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "sync_workspace", Description: "Trigger a git clone/fetch sync of the workspace."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in getWorkspaceIn) (*mcpsdk.CallToolResult, workspaceCreateOut, error) { + if err := CheckToolFromCtx(ctx, "sync_workspace"); err != nil { + return nil, workspaceCreateOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, workspaceCreateOut{}, err + } + w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) + if err != nil { + return nil, workspaceCreateOut{}, err + } + w, err = deps.Workspaces.Sync(ctx, wc, w.ID) + if err != nil { + return nil, workspaceCreateOut{}, err + } + return nil, workspaceCreateOut{OK: true, Workspace: workspaceDetailFrom(w)}, nil + }) +} + +// ===== list_branches ===== + +type listBranchesOut struct { + Branches []string `json:"branches"` +} + +func registerListBranches(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "list_branches", Description: "List git branches of a workspace."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in getWorkspaceIn) (*mcpsdk.CallToolResult, listBranchesOut, error) { + if err := CheckToolFromCtx(ctx, "list_branches"); err != nil { + return nil, listBranchesOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, listBranchesOut{}, err + } + w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) + if err != nil { + return nil, listBranchesOut{}, err + } + bs, err := deps.Workspaces.ListBranches(ctx, wc, w.ID) + if err != nil { + return nil, listBranchesOut{}, err + } + return nil, listBranchesOut{Branches: bs}, nil + }) +} + +// ===== list_worktrees ===== + +type listWorktreesOut struct { + Worktrees []worktreeDetail `json:"worktrees"` +} + +func registerListWorktrees(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "list_worktrees", Description: "List worktrees of a workspace."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in getWorkspaceIn) (*mcpsdk.CallToolResult, listWorktreesOut, error) { + if err := CheckToolFromCtx(ctx, "list_worktrees"); err != nil { + return nil, listWorktreesOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, listWorktreesOut{}, err + } + w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) + if err != nil { + return nil, listWorktreesOut{}, err + } + wts, err := deps.Worktrees.List(ctx, wc, w.ID) + if err != nil { + return nil, listWorktreesOut{}, err + } + out := listWorktreesOut{Worktrees: make([]worktreeDetail, 0, len(wts))} + for _, wt := range wts { + out.Worktrees = append(out.Worktrees, worktreeDetailFrom(wt)) + } + return nil, out, nil + }) +} + +// ===== create_worktree ===== + +type createWorktreeIn struct { + WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"` + Branch string `json:"branch" jsonschema:"branch name for the new worktree"` + BaseBranch string `json:"base_branch,omitempty" jsonschema:"base branch (defaults to workspace default branch)"` +} +type worktreeCreateOut struct { + OK bool `json:"ok"` + Worktree worktreeDetail `json:"worktree"` +} + +func registerCreateWorktree(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "create_worktree", Description: "Create a worktree on a branch."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in createWorktreeIn) (*mcpsdk.CallToolResult, worktreeCreateOut, error) { + if err := CheckToolFromCtx(ctx, "create_worktree"); err != nil { + return nil, worktreeCreateOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, worktreeCreateOut{}, err + } + w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) + if err != nil { + return nil, worktreeCreateOut{}, err + } + wt, err := deps.Worktrees.Create(ctx, wc, w.ID, workspace.CreateWorktreeInput{ + Branch: in.Branch, BaseBranch: in.BaseBranch, + }) + if err != nil { + return nil, worktreeCreateOut{}, err + } + return nil, worktreeCreateOut{OK: true, Worktree: worktreeDetailFrom(wt)}, nil + }) +} + +// ===== delete/acquire/release_worktree ===== + +type worktreeTargetIn struct { + WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"` + WorktreeID string `json:"worktree_id" jsonschema:"worktree UUID"` +} + +func registerDeleteWorktree(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "delete_worktree", Description: "Delete a worktree. Irreversible."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in worktreeTargetIn) (*mcpsdk.CallToolResult, okOut, error) { + if err := CheckToolFromCtx(ctx, "delete_worktree"); err != nil { + return nil, okOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, okOut{}, err + } + w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) + if err != nil { + return nil, okOut{}, err + } + wt, err := worktreeInWorkspace(ctx, deps, wc, w.ID, in.WorktreeID) + if err != nil { + return nil, okOut{}, err + } + if err := deps.Worktrees.Delete(ctx, wc, wt.ID); err != nil { + return nil, okOut{}, err + } + return nil, okOut{OK: true}, nil + }) +} + +func registerAcquireWorktree(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "acquire_worktree", Description: "Acquire (lock) a worktree for exclusive use."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in worktreeTargetIn) (*mcpsdk.CallToolResult, worktreeCreateOut, error) { + if err := CheckToolFromCtx(ctx, "acquire_worktree"); err != nil { + return nil, worktreeCreateOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, worktreeCreateOut{}, err + } + w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) + if err != nil { + return nil, worktreeCreateOut{}, err + } + wt, err := worktreeInWorkspace(ctx, deps, wc, w.ID, in.WorktreeID) + if err != nil { + return nil, worktreeCreateOut{}, err + } + wt, err = deps.Worktrees.Acquire(ctx, wc, wt.ID) + if err != nil { + return nil, worktreeCreateOut{}, err + } + return nil, worktreeCreateOut{OK: true, Worktree: worktreeDetailFrom(wt)}, nil + }) +} + +func registerReleaseWorktree(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "release_worktree", Description: "Release a previously acquired worktree."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in worktreeTargetIn) (*mcpsdk.CallToolResult, worktreeCreateOut, error) { + if err := CheckToolFromCtx(ctx, "release_worktree"); err != nil { + return nil, worktreeCreateOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, worktreeCreateOut{}, err + } + w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) + if err != nil { + return nil, worktreeCreateOut{}, err + } + wt, err := worktreeInWorkspace(ctx, deps, wc, w.ID, in.WorktreeID) + if err != nil { + return nil, worktreeCreateOut{}, err + } + wt, err = deps.Worktrees.Release(ctx, wc, wt.ID) + if err != nil { + return nil, worktreeCreateOut{}, err + } + return nil, worktreeCreateOut{OK: true, Worktree: worktreeDetailFrom(wt)}, nil + }) +} + +// ===== git_status / git_commit / git_push ===== +// +// 三个 git 工具统一用 workspace_id + 可选 worktree_id 定位目标: +// worktree_id 缺省 → 操作 main 工作区;给定 → 校验归属后操作该支线。 + +type gitTargetIn 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"` +} +type gitFileStatus struct { + Path string `json:"path"` + Staged string `json:"staged"` + Unstaged string `json:"unstaged"` +} +type gitStatusOut struct { + Files []gitFileStatus `json:"files"` +} + +func registerGitStatus(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "git_status", Description: "Show git status of the main worktree or a branch worktree."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in gitTargetIn) (*mcpsdk.CallToolResult, gitStatusOut, error) { + if err := CheckToolFromCtx(ctx, "git_status"); err != nil { + return nil, gitStatusOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, gitStatusOut{}, err + } + w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) + if err != nil { + return nil, gitStatusOut{}, err + } + files, err := gitStatusByTarget(ctx, deps, wc, w.ID, in.WorktreeID) + if err != nil { + return nil, gitStatusOut{}, err + } + out := gitStatusOut{Files: make([]gitFileStatus, 0, len(files))} + for _, f := range files { + out.Files = append(out.Files, gitFileStatus{ + Path: f.Path, Staged: string(f.IndexX), Unstaged: string(f.WorktreeY), + }) + } + return nil, out, nil + }) +} + +func gitStatusByTarget(ctx context.Context, deps ServerDeps, wc workspace.Caller, wsID uuid.UUID, worktreeID string) ([]git.FileStatus, error) { + if worktreeID == "" { + return deps.GitOps.StatusOnMain(ctx, wc, wsID) + } + wt, err := worktreeInWorkspace(ctx, deps, wc, wsID, worktreeID) + if err != nil { + return nil, err + } + return deps.GitOps.StatusOnWorktree(ctx, wc, wt.ID) +} + +type createCommitIn 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"` + Message string `json:"message" jsonschema:"commit message (non-empty)"` + AddAll *bool `json:"add_all,omitempty" jsonschema:"stage all changes before commit (default true)"` + Push bool `json:"push,omitempty" jsonschema:"push after commit (default false)"` +} +type gitCommitOut struct { + OK bool `json:"ok"` + CommitHash string `json:"commit_hash"` +} + +func registerGitCommit(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "git_commit", Description: "Commit changes on the main worktree or a branch worktree."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in createCommitIn) (*mcpsdk.CallToolResult, gitCommitOut, error) { + if err := CheckToolFromCtx(ctx, "git_commit"); err != nil { + return nil, gitCommitOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, gitCommitOut{}, err + } + w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) + if err != nil { + return nil, gitCommitOut{}, err + } + addAll := true + if in.AddAll != nil { + addAll = *in.AddAll + } + commit := workspace.CommitInput{Message: in.Message, AddAll: addAll, Push: in.Push} + var hash string + if in.WorktreeID == "" { + hash, err = deps.GitOps.CommitOnMain(ctx, wc, w.ID, commit) + } else { + var wt *workspace.Worktree + wt, err = worktreeInWorkspace(ctx, deps, wc, w.ID, in.WorktreeID) + if err != nil { + return nil, gitCommitOut{}, err + } + hash, err = deps.GitOps.CommitOnWorktree(ctx, wc, wt.ID, commit) + } + if err != nil { + return nil, gitCommitOut{}, err + } + return nil, gitCommitOut{OK: true, CommitHash: hash}, 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."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in gitTargetIn) (*mcpsdk.CallToolResult, okOut, error) { + if err := CheckToolFromCtx(ctx, "git_push"); err != nil { + return nil, okOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, okOut{}, err + } + w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) + if err != nil { + return nil, okOut{}, err + } + if in.WorktreeID == "" { + err = deps.GitOps.PushOnMain(ctx, wc, w.ID) + } else { + var wt *workspace.Worktree + wt, err = worktreeInWorkspace(ctx, deps, wc, w.ID, in.WorktreeID) + if err != nil { + return nil, okOut{}, err + } + err = deps.GitOps.PushOnWorktree(ctx, wc, wt.ID) + } + if err != nil { + return nil, okOut{}, err + } + return nil, okOut{OK: true}, nil + }) +} diff --git a/internal/mcp/tools_workspace_test.go b/internal/mcp/tools_workspace_test.go new file mode 100644 index 0000000..22d1c3d --- /dev/null +++ b/internal/mcp/tools_workspace_test.go @@ -0,0 +1,312 @@ +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/infra/errs" + "github.com/yan1h/agent-coding-workflow/internal/infra/git" + "github.com/yan1h/agent-coding-workflow/internal/workspace" +) + +// ===== fakes ===== + +// fakeWorktreeSvc implements workspace.WorktreeService. +type fakeWorktreeSvc struct { + items []*workspace.Worktree +} + +func (f *fakeWorktreeSvc) Create(_ context.Context, _ workspace.Caller, wsID uuid.UUID, in workspace.CreateWorktreeInput) (*workspace.Worktree, error) { + wt := &workspace.Worktree{ + ID: uuid.New(), WorkspaceID: wsID, Branch: in.Branch, + Status: workspace.WorktreeStatusIdle, CreatedAt: time.Now(), + } + f.items = append(f.items, wt) + return wt, nil +} +func (f *fakeWorktreeSvc) List(_ context.Context, _ workspace.Caller, wsID uuid.UUID) ([]*workspace.Worktree, error) { + out := make([]*workspace.Worktree, 0, len(f.items)) + for _, wt := range f.items { + if wt.WorkspaceID == wsID { + out = append(out, wt) + } + } + return out, nil +} +func (f *fakeWorktreeSvc) Delete(_ context.Context, _ workspace.Caller, wtID uuid.UUID) error { + kept := f.items[:0] + for _, wt := range f.items { + if wt.ID != wtID { + kept = append(kept, wt) + } + } + f.items = kept + return nil +} +func (f *fakeWorktreeSvc) Acquire(_ context.Context, c workspace.Caller, wtID uuid.UUID) (*workspace.Worktree, error) { + for _, wt := range f.items { + if wt.ID == wtID { + wt.Status = workspace.WorktreeStatusActive + wt.ActiveHolder = c.Holder() + return wt, nil + } + } + return nil, errs.New(errs.CodeNotFound, "worktree") +} +func (f *fakeWorktreeSvc) Release(_ context.Context, _ workspace.Caller, wtID uuid.UUID) (*workspace.Worktree, error) { + for _, wt := range f.items { + if wt.ID == wtID { + wt.Status = workspace.WorktreeStatusIdle + wt.ActiveHolder = "" + return wt, nil + } + } + return nil, errs.New(errs.CodeNotFound, "worktree") +} +func (f *fakeWorktreeSvc) ReleaseByHolder(_ context.Context, _ string) ([]*workspace.Worktree, error) { + return nil, nil +} + +// fakeGitOpsSvc implements workspace.GitOpsService 并记录最近一次调用目标。 +type fakeGitOpsSvc struct { + statusFiles []git.FileStatus + lastTarget string // "main:" / "worktree:" + lastCommit workspace.CommitInput +} + +func (f *fakeGitOpsSvc) StatusOnMain(_ context.Context, _ workspace.Caller, wsID uuid.UUID) ([]git.FileStatus, error) { + f.lastTarget = "main:" + wsID.String() + return f.statusFiles, nil +} +func (f *fakeGitOpsSvc) CommitOnMain(_ context.Context, _ workspace.Caller, wsID uuid.UUID, in workspace.CommitInput) (string, error) { + f.lastTarget = "main:" + wsID.String() + f.lastCommit = in + return "hash-main", nil +} +func (f *fakeGitOpsSvc) PushOnMain(_ context.Context, _ workspace.Caller, wsID uuid.UUID) error { + f.lastTarget = "main:" + wsID.String() + return nil +} +func (f *fakeGitOpsSvc) StatusOnWorktree(_ context.Context, _ workspace.Caller, wtID uuid.UUID) ([]git.FileStatus, error) { + f.lastTarget = "worktree:" + wtID.String() + return f.statusFiles, nil +} +func (f *fakeGitOpsSvc) CommitOnWorktree(_ context.Context, _ workspace.Caller, wtID uuid.UUID, in workspace.CommitInput) (string, error) { + f.lastTarget = "worktree:" + wtID.String() + f.lastCommit = in + return "hash-wt", nil +} +func (f *fakeGitOpsSvc) PushOnWorktree(_ context.Context, _ workspace.Caller, wtID uuid.UUID) error { + f.lastTarget = "worktree:" + wtID.String() + return nil +} + +// wsTestDeps 在 testDeps 基础上挂一个已存在的 workspace + worktree 服务。 +func wsTestDeps() (ServerDeps, *fakeWorkspaceSvc, *fakeWorktreeSvc, *fakeGitOpsSvc) { + deps := testDeps() + wsSvc := &fakeWorkspaceSvc{ + items: []*workspace.Workspace{{ + ID: testWSID, ProjectID: testPID, Slug: "ws-1", Name: "Workspace 1", + DefaultBranch: "main", SyncStatus: workspace.SyncStatusIdle, CreatedAt: time.Now(), + }}, + branches: []string{"main", "dev"}, + } + wtSvc := &fakeWorktreeSvc{} + gitSvc := &fakeGitOpsSvc{} + deps.Workspaces = wsSvc + deps.Worktrees = wtSvc + deps.GitOps = gitSvc + return deps, wsSvc, wtSvc, gitSvc +} + +// ===== tests ===== + +func TestGetWorkspace(t *testing.T) { + deps, _, _, _ := wsTestDeps() + result, err := callTool(ctxWithAuth(Scope{}), deps, "get_workspace", map[string]any{ + "workspace_id": testWSID.String(), + }) + require.NoError(t, err) + require.False(t, result.IsError) + + var out workspaceDetail + unmarshalStructured(t, result, &out) + assert.Equal(t, "ws-1", out.Slug) + assert.Equal(t, testPID.String(), out.ProjectID) +} + +func TestGetWorkspace_ScopeDenied(t *testing.T) { + deps, _, _, _ := wsTestDeps() + // token 限定到其他 project → 拒绝 + result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{uuid.New()}}), deps, + "get_workspace", map[string]any{"workspace_id": testWSID.String()}) + require.NoError(t, err) + assert.True(t, result.IsError) +} + +func TestCreateUpdateDeleteWorkspace(t *testing.T) { + deps, wsSvc, _, _ := wsTestDeps() + + result, err := callTool(ctxWithAuth(Scope{}), deps, "create_workspace", map[string]any{ + "project_slug": "test-project", "slug": "ws-2", "name": "Workspace 2", + "git_remote_url": "https://example.com/repo.git", + }) + require.NoError(t, err) + require.False(t, result.IsError) + var createOut workspaceCreateOut + unmarshalStructured(t, result, &createOut) + assert.True(t, createOut.OK) + assert.Equal(t, "ws-2", createOut.Workspace.Slug) + require.Len(t, wsSvc.items, 2) + + result, err = callTool(ctxWithAuth(Scope{}), deps, "update_workspace", map[string]any{ + "workspace_id": createOut.Workspace.ID, "name": "Renamed", + }) + require.NoError(t, err) + require.False(t, result.IsError) + unmarshalStructured(t, result, &createOut) + assert.Equal(t, "Renamed", createOut.Workspace.Name) + + result, err = callTool(ctxWithAuth(Scope{}), deps, "delete_workspace", map[string]any{ + "workspace_id": createOut.Workspace.ID, + }) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Len(t, wsSvc.items, 1) +} + +func TestListBranchesAndSync(t *testing.T) { + deps, _, _, _ := wsTestDeps() + + result, err := callTool(ctxWithAuth(Scope{}), deps, "list_branches", map[string]any{ + "workspace_id": testWSID.String(), + }) + require.NoError(t, err) + var out listBranchesOut + unmarshalStructured(t, result, &out) + assert.Equal(t, []string{"main", "dev"}, out.Branches) + + result, err = callTool(ctxWithAuth(Scope{}), deps, "sync_workspace", map[string]any{ + "workspace_id": testWSID.String(), + }) + require.NoError(t, err) + assert.False(t, result.IsError) +} + +func TestWorktreeLifecycle(t *testing.T) { + deps, _, wtSvc, _ := wsTestDeps() + + result, err := callTool(ctxWithAuth(Scope{}), deps, "create_worktree", map[string]any{ + "workspace_id": testWSID.String(), "branch": "feat/x", + }) + require.NoError(t, err) + require.False(t, result.IsError) + var createOut worktreeCreateOut + unmarshalStructured(t, result, &createOut) + assert.Equal(t, "feat/x", createOut.Worktree.Branch) + wtID := createOut.Worktree.ID + + result, err = callTool(ctxWithAuth(Scope{}), deps, "list_worktrees", map[string]any{ + "workspace_id": testWSID.String(), + }) + require.NoError(t, err) + var listOut listWorktreesOut + unmarshalStructured(t, result, &listOut) + require.Len(t, listOut.Worktrees, 1) + + result, err = callTool(ctxWithAuth(Scope{}), deps, "acquire_worktree", map[string]any{ + "workspace_id": testWSID.String(), "worktree_id": wtID, + }) + require.NoError(t, err) + unmarshalStructured(t, result, &createOut) + assert.Equal(t, string(workspace.WorktreeStatusActive), createOut.Worktree.Status) + + result, err = callTool(ctxWithAuth(Scope{}), deps, "release_worktree", map[string]any{ + "workspace_id": testWSID.String(), "worktree_id": wtID, + }) + require.NoError(t, err) + unmarshalStructured(t, result, &createOut) + assert.Equal(t, string(workspace.WorktreeStatusIdle), createOut.Worktree.Status) + + result, err = callTool(ctxWithAuth(Scope{}), deps, "delete_worktree", map[string]any{ + "workspace_id": testWSID.String(), "worktree_id": wtID, + }) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Empty(t, wtSvc.items) +} + +func TestWorktreeMembershipEnforced(t *testing.T) { + deps, _, wtSvc, _ := wsTestDeps() + // worktree 属于另一个 workspace → 不可经本 workspace 操作 + alien := &workspace.Worktree{ + ID: uuid.New(), WorkspaceID: uuid.New(), Branch: "other", + Status: workspace.WorktreeStatusIdle, CreatedAt: time.Now(), + } + wtSvc.items = append(wtSvc.items, alien) + + result, err := callTool(ctxWithAuth(Scope{}), deps, "delete_worktree", map[string]any{ + "workspace_id": testWSID.String(), "worktree_id": alien.ID.String(), + }) + require.NoError(t, err) + assert.True(t, result.IsError) + assert.Len(t, wtSvc.items, 1) +} + +func TestGitStatusCommitPush_Main(t *testing.T) { + deps, _, _, gitSvc := wsTestDeps() + gitSvc.statusFiles = []git.FileStatus{{Path: "a.go", IndexX: 'M', WorktreeY: ' '}} + + result, err := callTool(ctxWithAuth(Scope{}), deps, "git_status", map[string]any{ + "workspace_id": testWSID.String(), + }) + require.NoError(t, err) + var statusOut gitStatusOut + unmarshalStructured(t, result, &statusOut) + require.Len(t, statusOut.Files, 1) + assert.Equal(t, "M", statusOut.Files[0].Staged) + assert.Equal(t, "main:"+testWSID.String(), gitSvc.lastTarget) + + result, err = callTool(ctxWithAuth(Scope{}), deps, "git_commit", map[string]any{ + "workspace_id": testWSID.String(), "message": "feat: x", + }) + require.NoError(t, err) + var commitOut gitCommitOut + unmarshalStructured(t, result, &commitOut) + assert.Equal(t, "hash-main", commitOut.CommitHash) + assert.True(t, gitSvc.lastCommit.AddAll, "add_all defaults to true") + assert.False(t, gitSvc.lastCommit.Push) + + result, err = callTool(ctxWithAuth(Scope{}), deps, "git_push", map[string]any{ + "workspace_id": testWSID.String(), + }) + require.NoError(t, err) + assert.False(t, result.IsError) +} + +func TestGitCommit_Worktree(t *testing.T) { + deps, _, wtSvc, gitSvc := wsTestDeps() + wt := &workspace.Worktree{ + ID: uuid.New(), WorkspaceID: testWSID, Branch: "feat/y", + Status: workspace.WorktreeStatusIdle, CreatedAt: time.Now(), + } + wtSvc.items = append(wtSvc.items, wt) + + addAll := false + result, err := callTool(ctxWithAuth(Scope{}), deps, "git_commit", map[string]any{ + "workspace_id": testWSID.String(), "worktree_id": wt.ID.String(), + "message": "fix: y", "add_all": addAll, "push": true, + }) + require.NoError(t, err) + var commitOut gitCommitOut + unmarshalStructured(t, result, &commitOut) + assert.Equal(t, "hash-wt", commitOut.CommitHash) + assert.Equal(t, "worktree:"+wt.ID.String(), gitSvc.lastTarget) + assert.False(t, gitSvc.lastCommit.AddAll) + assert.True(t, gitSvc.lastCommit.Push) +}