You've already forked agentic-coding-workflow
完善 MCP 工具
This commit is contained in:
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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(管理员守卫上层处理)。
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 =====
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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 中实现。
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 =====
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user