完善 MCP 工具

This commit is contained in:
2026-06-11 08:21:09 +08:00
parent c0f15050d0
commit 1415d3b43b
20 changed files with 3722 additions and 61 deletions
+139
View File
@@ -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,
}
}
+27 -18
View File
@@ -251,30 +251,15 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
chat.NewHandler(chatConvSvc, chatMsgSvc, chatTplSvc, chatEpSvc, chatUsageSvc, chat.NewHandler(chatConvSvc, chatMsgSvc, chatTplSvc, chatEpSvc, chatUsageSvc,
chatUploader, userSvc, userAdminAdapter{svc: userSvc}).Mount(r) 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) mcpRepo := mcp.NewPostgresRepository(pool)
mcpTokenSvc := mcp.NewTokenService(mcpRepo, auditRec, nil) // mcp 自身 audit 不需要 via_mcp 标记 mcpTokenSvc := mcp.NewTokenService(mcpRepo, auditRec, nil) // mcp 自身 audit 不需要 via_mcp 标记
mcpRateLimiter := mcp.NewRateLimiter(mcp.RateLimitConfig{ mcpRateLimiter := mcp.NewRateLimiter(mcp.RateLimitConfig{
PerTokenPerMinute: cfg.MCP.RateLimit.PerTokenPerMinute, PerTokenPerMinute: cfg.MCP.RateLimit.PerTokenPerMinute,
GlobalPerMinute: cfg.MCP.RateLimit.GlobalPerMinute, GlobalPerMinute: cfg.MCP.RateLimit.GlobalPerMinute,
}, nil) }, 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) ===== // ===== ACP module wiring (Part 2: full SessionService + Supervisor) =====
acpRepo := acp.NewPostgresRepository(pool) acpRepo := acp.NewPostgresRepository(pool)
@@ -420,6 +405,30 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
}).Mount(r) }).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 ===== // ===== jobs module wiring =====
jobsRepo := jobs.NewPostgresRepository(pool) jobsRepo := jobs.NewPostgresRepository(pool)
+6 -2
View File
@@ -219,11 +219,15 @@ type SSEEvent struct {
} }
// TemplateService — prompt_templates CRUD。 // TemplateService — prompt_templates CRUD。
//
// 鉴权语义:user 模板仅 owner 可改/删;system 模板仅 admin 可改/删
// (isAdmin 由调用方解析后传入;Create 的 system-scope admin 校验
// 仍在 HTTP handler / MCP 工具层,与既有行为一致)。
type TemplateService interface { type TemplateService interface {
List(ctx context.Context, userID uuid.UUID) ([]PromptTemplate, error) List(ctx context.Context, userID uuid.UUID) ([]PromptTemplate, error)
Create(ctx context.Context, userID uuid.UUID, name, content string, scope TemplateScope) (*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 Update(ctx context.Context, userID uuid.UUID, isAdmin bool, id uuid.UUID, name, content string) error
Delete(ctx context.Context, userID, id uuid.UUID) error Delete(ctx context.Context, userID uuid.UUID, isAdmin bool, id uuid.UUID) error
} }
// EndpointService — admin LLM endpoints / models CRUD(管理员守卫上层处理)。 // EndpointService — admin LLM endpoints / models CRUD(管理员守卫上层处理)。
+12 -2
View File
@@ -492,7 +492,12 @@ func (h *Handler) updateTemplate(w http.ResponseWriter, r *http.Request) {
writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body")) writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body"))
return 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) writeErr(w, r, err)
return return
} }
@@ -510,7 +515,12 @@ func (h *Handler) deleteTemplate(w http.ResponseWriter, r *http.Request) {
writeErr(w, r, err) writeErr(w, r, err)
return 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) writeErr(w, r, err)
return return
} }
+4 -2
View File
@@ -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 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) Update(_ context.Context, _ uuid.UUID, _ bool, _ uuid.UUID, _, _ string) error {
func (f *fakeTplSvc) Delete(_ context.Context, _, _ uuid.UUID) error { return nil } return nil
}
func (f *fakeTplSvc) Delete(_ context.Context, _ uuid.UUID, _ bool, _ uuid.UUID) error { return nil }
// ===== Fake EndpointService ===== // ===== Fake EndpointService =====
+18 -6
View File
@@ -54,7 +54,7 @@ func (s *templateService) Create(ctx context.Context, userID uuid.UUID, name, co
return t, nil 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) t, err := s.repo.GetPromptTemplate(ctx, id)
if err != nil { if err != nil {
return err return err
@@ -62,8 +62,8 @@ func (s *templateService) Update(ctx context.Context, userID, id uuid.UUID, name
if t == nil { if t == nil {
return errs.New(errs.CodeChatTemplateNotFound, "template not found") return errs.New(errs.CodeChatTemplateNotFound, "template not found")
} }
if t.Scope == TemplateScopeUser && (t.OwnerID == nil || *t.OwnerID != userID) { if err := checkTemplateWriteAccess(t, userID, isAdmin); err != nil {
return errs.New(errs.CodeForbidden, "not template owner") return err
} }
if err := s.repo.UpdatePromptTemplate(ctx, id, name, content); err != nil { if err := s.repo.UpdatePromptTemplate(ctx, id, name, content); err != nil {
return err return err
@@ -77,7 +77,7 @@ func (s *templateService) Update(ctx context.Context, userID, id uuid.UUID, name
return nil 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) t, err := s.repo.GetPromptTemplate(ctx, id)
if err != nil { if err != nil {
return err return err
@@ -85,8 +85,8 @@ func (s *templateService) Delete(ctx context.Context, userID, id uuid.UUID) erro
if t == nil { if t == nil {
return errs.New(errs.CodeChatTemplateNotFound, "template not found") return errs.New(errs.CodeChatTemplateNotFound, "template not found")
} }
if t.Scope == TemplateScopeUser && (t.OwnerID == nil || *t.OwnerID != userID) { if err := checkTemplateWriteAccess(t, userID, isAdmin); err != nil {
return errs.New(errs.CodeForbidden, "not template owner") return err
} }
if err := s.repo.DeletePromptTemplate(ctx, id); err != nil { if err := s.repo.DeletePromptTemplate(ctx, id); err != nil {
return err return err
@@ -100,6 +100,18 @@ func (s *templateService) Delete(ctx context.Context, userID, id uuid.UUID) erro
return nil 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) { func (s *templateService) tryAudit(ctx context.Context, e audit.Entry) {
if err := s.auditor.Record(ctx, e); err != nil { if err := s.auditor.Record(ctx, e); err != nil {
s.log.Warn("audit record failed", "action", e.Action, "err", err) s.log.Warn("audit record failed", "action", e.Action, "err", err)
+102
View File
@@ -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)
}
+2
View File
@@ -22,6 +22,7 @@ type AuthSession struct {
TokenID string // 用于审计 metadata 写入 TokenID string // 用于审计 metadata 写入
UserID string // user_id 字符串形式(避免反复 uuid.UUID -> string 转换) UserID string // user_id 字符串形式(避免反复 uuid.UUID -> string 转换)
Scope Scope Scope Scope
Issuer Issuer // system token(agent 会话持有)受额外限制,见 create_acp_session
} }
// FromContext 从 ctx 取出 AuthSession,未鉴权时返 (nil, false)。 // FromContext 从 ctx 取出 AuthSession,未鉴权时返 (nil, false)。
@@ -53,6 +54,7 @@ func NewAuthMiddleware(svc TokenService) func(http.Handler) http.Handler {
TokenID: tok.ID.String(), TokenID: tok.ID.String(),
UserID: tok.UserID.String(), UserID: tok.UserID.String(),
Scope: tok.Scope, Scope: tok.Scope,
Issuer: tok.Issuer,
} }
ctx := context.WithValue(r.Context(), AuthContextKey, sess) ctx := context.WithValue(r.Context(), AuthContextKey, sess)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
+10 -5
View File
@@ -16,9 +16,14 @@ type ServerDeps struct {
Issues project.IssueService Issues project.IssueService
Artifacts project.ArtifactService Artifacts project.ArtifactService
Workspaces workspace.WorkspaceService Workspaces workspace.WorkspaceService
Worktrees workspace.WorktreeService
GitOps workspace.GitOpsService
Templates chat.TemplateService Templates chat.TemplateService
Messages chat.MessageService Messages chat.MessageService
Conversations chat.ConversationService 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。 // NewMCPServer 装配 SDK Server 实例并注册所有工具/prompts/resources。
@@ -28,16 +33,16 @@ func NewMCPServer(deps ServerDeps) *mcpsdk.Server {
Version: "0.1.0", Version: "0.1.0",
}, nil) }, nil)
// Phase F: 16 PM 工具 // Phase F: PM 工具(projects / requirements / issues / artifacts)
registerPMTools(srv, deps) registerPMTools(srv, deps)
// Phase G: chat / prompts / resources // Phase G: chat / prompts / resources
registerChatTools(srv, deps) registerChatTools(srv, deps)
registerPrompts(srv, deps) registerPrompts(srv, deps)
registerResources(srv, deps) registerResources(srv, deps)
// Phase H: workspace / worktree / git、ACP、run profiles
registerWorkspaceTools(srv, deps)
registerACPTools(srv, deps)
registerRunTools(srv, deps)
return srv return srv
} }
// registerChatTools 已在 tools_chat.go 中实现。
// registerResources 已在 resources.go 中实现。
// registerPMTools 已在 tools_pm.go 中实现。
+434
View File
@@ -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
})
}
+218
View File
@@ -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)
}
+565 -8
View File
@@ -6,14 +6,128 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" 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 工具 // registerChatTools 注册 chat 相关 MCP 工具:会话 CRUD、发消息、消息查询、
// MVP 只有一个:list_recent_messages // prompt 模板 CRUD、模型列表
//
// 范围边界(spec 决策):LLM endpoint/model 管理、用量统计、附件上传、
// 流式接收(SSE)与 cancel/retry(强依赖流式上下文)不经 MCP 暴露。
// send_message 的 assistant 回复异步生成,调用方用 list_recent_messages 轮询。
func registerChatTools(srv *mcpsdk.Server, deps ServerDeps) { func registerChatTools(srv *mcpsdk.Server, deps ServerDeps) {
registerListRecentMessages(srv, deps) 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 { type listRecentMessagesIn struct {
ConversationID string `json:"conversation_id" jsonschema:"UUID of the conversation"` ConversationID string `json:"conversation_id" jsonschema:"UUID of the conversation"`
BeforeID *int64 `json:"before_id,omitempty" jsonschema:"cursor: return messages with ID < this value"` 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"` 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) { func registerListRecentMessages(srv *mcpsdk.Server, deps ServerDeps) {
mcpsdk.AddTool(srv, mcpsdk.AddTool(srv,
&mcpsdk.Tool{Name: "list_recent_messages", Description: "List recent chat messages in a conversation."}, &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))} out := listRecentMessagesOut{Messages: make([]messageSummary, 0, len(msgs))}
for _, m := range msgs { for _, m := range msgs {
out.Messages = append(out.Messages, messageSummary{ out.Messages = append(out.Messages, messageSummaryFrom(m))
ID: m.ID, }
Role: string(m.Role), return nil, out, nil
Content: m.Content, })
Status: string(m.Status), }
CreatedAt: m.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
// ===== 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 return nil, out, nil
+407
View File
@@ -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)
}
+175
View File
@@ -14,6 +14,10 @@ import (
func registerPMTools(srv *mcpsdk.Server, deps ServerDeps) { func registerPMTools(srv *mcpsdk.Server, deps ServerDeps) {
registerListProjects(srv, deps) registerListProjects(srv, deps)
registerCreateProject(srv, deps)
registerUpdateProject(srv, deps)
registerArchiveProject(srv, deps)
registerUnarchiveProject(srv, deps)
registerListWorkspaces(srv, deps) registerListWorkspaces(srv, deps)
registerListRequirements(srv, deps) registerListRequirements(srv, deps)
registerListIssues(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 引用(编译期检查) // 保证 fmt 引用(编译期检查)
var _ = fmt.Sprintf var _ = fmt.Sprintf
+69
View File
@@ -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)
}
+83 -17
View File
@@ -76,8 +76,19 @@ func newFakeProjectSvc(ps ...*project.Project) *fakeProjectSvc {
} }
return f return f
} }
func (f *fakeProjectSvc) Create(_ context.Context, _ project.Caller, _ project.CreateProjectInput) (*project.Project, error) { func (f *fakeProjectSvc) Create(_ context.Context, c project.Caller, in project.CreateProjectInput) (*project.Project, error) {
panic("not implemented") 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) { func (f *fakeProjectSvc) Get(_ context.Context, _ project.Caller, slug string) (*project.Project, error) {
if p, ok := f.slugMap[slug]; ok { 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) { func (f *fakeProjectSvc) List(_ context.Context, _ project.Caller, _ bool) ([]*project.Project, error) {
return f.projects, nil return f.projects, nil
} }
func (f *fakeProjectSvc) Update(_ context.Context, _ project.Caller, _ string, _ project.UpdateProjectInput) (*project.Project, error) { func (f *fakeProjectSvc) Update(ctx context.Context, c project.Caller, slug string, in project.UpdateProjectInput) (*project.Project, error) {
panic("not implemented") 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 { func (f *fakeProjectSvc) Archive(ctx context.Context, c project.Caller, slug string) error {
panic("not implemented") 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 { func (f *fakeProjectSvc) Unarchive(ctx context.Context, c project.Caller, slug string) error {
panic("not implemented") p, err := f.Get(ctx, c, slug)
if err != nil {
return err
}
p.ArchivedAt = nil
return nil
} }
// fakeRequirementSvc implements project.RequirementService. // fakeRequirementSvc implements project.RequirementService.
@@ -235,10 +270,18 @@ func (f *fakeIssueSvc) Reopen(_ context.Context, _ project.Caller, slug string,
// fakeWorkspaceSvc implements workspace.WorkspaceService. // fakeWorkspaceSvc implements workspace.WorkspaceService.
type fakeWorkspaceSvc struct { 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) { func (f *fakeWorkspaceSvc) Create(_ context.Context, _ workspace.Caller, _ string, in workspace.CreateWorkspaceInput) (*workspace.Workspace, error) {
panic("not implemented") 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) { func (f *fakeWorkspaceSvc) Get(_ context.Context, _ workspace.Caller, id uuid.UUID) (*workspace.Workspace, error) {
for _, w := range f.items { 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) { func (f *fakeWorkspaceSvc) List(_ context.Context, _ workspace.Caller, _ string) ([]*workspace.Workspace, error) {
return f.items, nil return f.items, nil
} }
func (f *fakeWorkspaceSvc) Update(_ context.Context, _ workspace.Caller, _ uuid.UUID, _ workspace.UpdateWorkspaceInput) (*workspace.Workspace, error) { func (f *fakeWorkspaceSvc) Update(ctx context.Context, c workspace.Caller, id uuid.UUID, in workspace.UpdateWorkspaceInput) (*workspace.Workspace, error) {
panic("not implemented") 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 { func (f *fakeWorkspaceSvc) Delete(ctx context.Context, c workspace.Caller, id uuid.UUID) error {
panic("not implemented") 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) { func (f *fakeWorkspaceSvc) Sync(ctx context.Context, c workspace.Caller, id uuid.UUID) (*workspace.Workspace, error) {
panic("not implemented") return f.Get(ctx, c, id)
} }
func (f *fakeWorkspaceSvc) UpsertCredential(_ context.Context, _ workspace.Caller, _ uuid.UUID, _ workspace.UpsertCredentialInput) (*workspace.Credential, error) { func (f *fakeWorkspaceSvc) UpsertCredential(_ context.Context, _ workspace.Caller, _ uuid.UUID, _ workspace.UpsertCredentialInput) (*workspace.Credential, error) {
panic("not implemented") panic("not implemented")
@@ -267,7 +333,7 @@ func (f *fakeWorkspaceSvc) GetCredentialMeta(_ context.Context, _ workspace.Call
panic("not implemented") panic("not implemented")
} }
func (f *fakeWorkspaceSvc) ListBranches(_ context.Context, _ workspace.Caller, _ uuid.UUID) ([]string, error) { func (f *fakeWorkspaceSvc) ListBranches(_ context.Context, _ workspace.Caller, _ uuid.UUID) ([]string, error) {
panic("not implemented") return f.branches, nil
} }
// ===== test helpers ===== // ===== test helpers =====
+326
View File
@@ -0,0 +1,326 @@
package mcp
import (
"context"
"github.com/google/uuid"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
"github.com/yan1h/agent-coding-workflow/internal/run"
"github.com/yan1h/agent-coding-workflow/internal/workspace"
)
// RunService 是 run profile 工具依赖的窄接口,*run.Service 直接满足;
// 接口化便于单测注入 fake。
type RunService interface {
List(ctx context.Context, c workspace.Caller, wsID uuid.UUID) ([]run.RunProfileResp, error)
Create(ctx context.Context, c workspace.Caller, wsID uuid.UUID, req run.CreateRunProfileReq) (run.RunProfileResp, error)
Update(ctx context.Context, c workspace.Caller, profileID uuid.UUID, req run.UpdateRunProfileReq) (run.RunProfileResp, error)
Delete(ctx context.Context, c workspace.Caller, profileID uuid.UUID) error
Start(ctx context.Context, c workspace.Caller, profileID uuid.UUID) (run.RunStatusResp, error)
Stop(ctx context.Context, c workspace.Caller, profileID uuid.UUID) (run.RunStatusResp, error)
Restart(ctx context.Context, c workspace.Caller, profileID uuid.UUID) (run.RunStatusResp, error)
Status(ctx context.Context, c workspace.Caller, profileID uuid.UUID) (run.RunStatusResp, error)
Logs(ctx context.Context, c workspace.Caller, profileID uuid.UUID, since int64, limit int) ([]run.LogLineResp, error)
}
// registerRunTools 注册 run profile 工具。日志只提供快照式 get_run_logs,
// 流式订阅(WebSocket)不经 MCP 暴露。
//
// 项目 scope 校验策略与 worktree 工具一致:profile 级操作要求同时给
// workspace_id,先按 workspace 做 scope 校验,再用 List 校验 profile 归属。
func registerRunTools(srv *mcpsdk.Server, deps ServerDeps) {
registerListRunProfiles(srv, deps)
registerCreateRunProfile(srv, deps)
registerUpdateRunProfile(srv, deps)
registerDeleteRunProfile(srv, deps)
registerStartRunProfile(srv, deps)
registerStopRunProfile(srv, deps)
registerRestartRunProfile(srv, deps)
registerGetRunStatus(srv, deps)
registerGetRunLogs(srv, deps)
}
// runProfileInWorkspace 校验 profile 归属于给定 workspace 并返回其 UUID。
func runProfileInWorkspace(ctx context.Context, deps ServerDeps, wc workspace.Caller, wsID uuid.UUID, profileID string) (uuid.UUID, error) {
pid, err := uuid.Parse(profileID)
if err != nil {
return uuid.Nil, errs.Wrap(err, errs.CodeInvalidInput, "profile_id parse")
}
ps, err := deps.Runs.List(ctx, wc, wsID)
if err != nil {
return uuid.Nil, err
}
for _, p := range ps {
if p.ID == pid.String() {
return pid, nil
}
}
return uuid.Nil, errs.New(errs.CodeNotFound, "run profile not found in workspace")
}
// ===== list_run_profiles =====
type listRunProfilesIn struct {
WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"`
}
type listRunProfilesOut struct {
Profiles []run.RunProfileResp `json:"profiles"`
}
func registerListRunProfiles(srv *mcpsdk.Server, deps ServerDeps) {
mcpsdk.AddTool(srv,
&mcpsdk.Tool{Name: "list_run_profiles", Description: "List run profiles of a workspace."},
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in listRunProfilesIn) (*mcpsdk.CallToolResult, listRunProfilesOut, error) {
if err := CheckToolFromCtx(ctx, "list_run_profiles"); err != nil {
return nil, listRunProfilesOut{}, err
}
c, err := deps.Caller.Resolve(ctx)
if err != nil {
return nil, listRunProfilesOut{}, err
}
w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID)
if err != nil {
return nil, listRunProfilesOut{}, err
}
ps, err := deps.Runs.List(ctx, wc, w.ID)
if err != nil {
return nil, listRunProfilesOut{}, err
}
return nil, listRunProfilesOut{Profiles: ps}, nil
})
}
// ===== create_run_profile =====
type createRunProfileIn struct {
WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"`
Slug string `json:"slug" jsonschema:"profile slug (non-empty)"`
Name string `json:"name" jsonschema:"display name (non-empty)"`
Description string `json:"description,omitempty"`
Command string `json:"command" jsonschema:"command line to run (non-empty)"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty" jsonschema:"environment overrides (encrypted at rest, never returned)"`
Enabled *bool `json:"enabled,omitempty" jsonschema:"default true"`
}
type runProfileOut struct {
OK bool `json:"ok"`
Profile run.RunProfileResp `json:"profile"`
}
func registerCreateRunProfile(srv *mcpsdk.Server, deps ServerDeps) {
mcpsdk.AddTool(srv,
&mcpsdk.Tool{Name: "create_run_profile", Description: "Create a run profile in a workspace."},
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in createRunProfileIn) (*mcpsdk.CallToolResult, runProfileOut, error) {
if err := CheckToolFromCtx(ctx, "create_run_profile"); err != nil {
return nil, runProfileOut{}, err
}
c, err := deps.Caller.Resolve(ctx)
if err != nil {
return nil, runProfileOut{}, err
}
w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID)
if err != nil {
return nil, runProfileOut{}, err
}
p, err := deps.Runs.Create(ctx, wc, w.ID, run.CreateRunProfileReq{
Slug: in.Slug, Name: in.Name, Description: in.Description,
Command: in.Command, Args: in.Args, Env: in.Env, Enabled: in.Enabled,
})
if err != nil {
return nil, runProfileOut{}, err
}
return nil, runProfileOut{OK: true, Profile: p}, nil
})
}
// ===== update_run_profile =====
type updateRunProfileIn struct {
WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"`
ProfileID string `json:"profile_id" jsonschema:"run profile UUID"`
Slug *string `json:"slug,omitempty"`
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Command *string `json:"command,omitempty"`
Args *[]string `json:"args,omitempty"`
Env *map[string]string `json:"env,omitempty" jsonschema:"full replacement of env overrides"`
Enabled *bool `json:"enabled,omitempty"`
}
func registerUpdateRunProfile(srv *mcpsdk.Server, deps ServerDeps) {
mcpsdk.AddTool(srv,
&mcpsdk.Tool{Name: "update_run_profile", Description: "Update a run profile (patch semantics: omitted fields keep current values)."},
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in updateRunProfileIn) (*mcpsdk.CallToolResult, runProfileOut, error) {
if err := CheckToolFromCtx(ctx, "update_run_profile"); err != nil {
return nil, runProfileOut{}, err
}
c, err := deps.Caller.Resolve(ctx)
if err != nil {
return nil, runProfileOut{}, err
}
w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID)
if err != nil {
return nil, runProfileOut{}, err
}
pid, err := runProfileInWorkspace(ctx, deps, wc, w.ID, in.ProfileID)
if err != nil {
return nil, runProfileOut{}, err
}
p, err := deps.Runs.Update(ctx, wc, pid, run.UpdateRunProfileReq{
Slug: in.Slug, Name: in.Name, Description: in.Description,
Command: in.Command, Args: in.Args, Env: in.Env, Enabled: in.Enabled,
})
if err != nil {
return nil, runProfileOut{}, err
}
return nil, runProfileOut{OK: true, Profile: p}, nil
})
}
// ===== delete_run_profile =====
type runProfileTargetIn struct {
WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"`
ProfileID string `json:"profile_id" jsonschema:"run profile UUID"`
}
func registerDeleteRunProfile(srv *mcpsdk.Server, deps ServerDeps) {
mcpsdk.AddTool(srv,
&mcpsdk.Tool{Name: "delete_run_profile", Description: "Delete a run profile. Irreversible."},
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in runProfileTargetIn) (*mcpsdk.CallToolResult, okOut, error) {
if err := CheckToolFromCtx(ctx, "delete_run_profile"); err != nil {
return nil, okOut{}, err
}
c, err := deps.Caller.Resolve(ctx)
if err != nil {
return nil, okOut{}, err
}
w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID)
if err != nil {
return nil, okOut{}, err
}
pid, err := runProfileInWorkspace(ctx, deps, wc, w.ID, in.ProfileID)
if err != nil {
return nil, okOut{}, err
}
if err := deps.Runs.Delete(ctx, wc, pid); err != nil {
return nil, okOut{}, err
}
return nil, okOut{OK: true}, nil
})
}
// ===== start/stop/restart/status =====
type runStatusOut struct {
OK bool `json:"ok"`
Status run.RunStatusResp `json:"status"`
}
// registerRunLifecycleTool 抽取 start/stop/restart/status 四个同构工具的注册逻辑。
func registerRunLifecycleTool(srv *mcpsdk.Server, deps ServerDeps, name, desc string,
op func(context.Context, workspace.Caller, uuid.UUID) (run.RunStatusResp, error)) {
mcpsdk.AddTool(srv,
&mcpsdk.Tool{Name: name, Description: desc},
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in runProfileTargetIn) (*mcpsdk.CallToolResult, runStatusOut, error) {
if err := CheckToolFromCtx(ctx, name); err != nil {
return nil, runStatusOut{}, err
}
c, err := deps.Caller.Resolve(ctx)
if err != nil {
return nil, runStatusOut{}, err
}
w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID)
if err != nil {
return nil, runStatusOut{}, err
}
pid, err := runProfileInWorkspace(ctx, deps, wc, w.ID, in.ProfileID)
if err != nil {
return nil, runStatusOut{}, err
}
st, err := op(ctx, wc, pid)
if err != nil {
return nil, runStatusOut{}, err
}
return nil, runStatusOut{OK: true, Status: st}, nil
})
}
// 注意:op 不能写成 deps.Runs.Start 这类 method value——注册期对 nil 接口取
// 方法值会 panic(deps.Runs 在部分单测装配里为空),必须用闭包延迟到调用期。
func registerStartRunProfile(srv *mcpsdk.Server, deps ServerDeps) {
registerRunLifecycleTool(srv, deps, "start_run_profile", "Start the process of a run profile.",
func(ctx context.Context, wc workspace.Caller, pid uuid.UUID) (run.RunStatusResp, error) {
return deps.Runs.Start(ctx, wc, pid)
})
}
func registerStopRunProfile(srv *mcpsdk.Server, deps ServerDeps) {
registerRunLifecycleTool(srv, deps, "stop_run_profile", "Stop the process of a run profile.",
func(ctx context.Context, wc workspace.Caller, pid uuid.UUID) (run.RunStatusResp, error) {
return deps.Runs.Stop(ctx, wc, pid)
})
}
func registerRestartRunProfile(srv *mcpsdk.Server, deps ServerDeps) {
registerRunLifecycleTool(srv, deps, "restart_run_profile", "Restart the process of a run profile.",
func(ctx context.Context, wc workspace.Caller, pid uuid.UUID) (run.RunStatusResp, error) {
return deps.Runs.Restart(ctx, wc, pid)
})
}
func registerGetRunStatus(srv *mcpsdk.Server, deps ServerDeps) {
registerRunLifecycleTool(srv, deps, "get_run_status", "Get the live status of a run profile.",
func(ctx context.Context, wc workspace.Caller, pid uuid.UUID) (run.RunStatusResp, error) {
return deps.Runs.Status(ctx, wc, pid)
})
}
// ===== get_run_logs =====
type getRunLogsIn struct {
WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"`
ProfileID string `json:"profile_id" jsonschema:"run profile UUID"`
Since int64 `json:"since,omitempty" jsonschema:"return log lines with ID > this value"`
Limit int `json:"limit,omitempty" jsonschema:"max lines to return (default 200, max 1000)"`
}
type getRunLogsOut struct {
Lines []run.LogLineResp `json:"lines"`
}
func registerGetRunLogs(srv *mcpsdk.Server, deps ServerDeps) {
mcpsdk.AddTool(srv,
&mcpsdk.Tool{Name: "get_run_logs", Description: "Get a snapshot of recent log lines of a run profile (no streaming)."},
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in getRunLogsIn) (*mcpsdk.CallToolResult, getRunLogsOut, error) {
if err := CheckToolFromCtx(ctx, "get_run_logs"); err != nil {
return nil, getRunLogsOut{}, err
}
c, err := deps.Caller.Resolve(ctx)
if err != nil {
return nil, getRunLogsOut{}, err
}
w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID)
if err != nil {
return nil, getRunLogsOut{}, err
}
pid, err := runProfileInWorkspace(ctx, deps, wc, w.ID, in.ProfileID)
if err != nil {
return nil, getRunLogsOut{}, err
}
limit := in.Limit
if limit <= 0 {
limit = 200
}
if limit > 1000 {
limit = 1000
}
lines, err := deps.Runs.Logs(ctx, wc, pid, in.Since, limit)
if err != nil {
return nil, getRunLogsOut{}, err
}
return nil, getRunLogsOut{Lines: lines}, nil
})
}
+198
View File
@@ -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)
}
+614
View File
@@ -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
})
}
+312
View File
@@ -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:<wsID>" / "worktree:<wtID>"
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)
}