完善 MCP 工具

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