ACP / MCP 完善

This commit is contained in:
2026-06-11 12:43:18 +08:00
parent 1415d3b43b
commit 0561e126cd
22 changed files with 786 additions and 219 deletions
+41 -18
View File
@@ -2,7 +2,6 @@ package mcp
import (
"context"
"fmt"
"github.com/google/uuid"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
@@ -126,6 +125,27 @@ func resolveMountRefs(ctx context.Context, deps ServerDeps, c project.Caller,
return refs, nil
}
// scopedConversation 解析 conversation_id、取会话(service 层含 ownership 校验)
// 并做项目 scope 校验:ProjectID 为 nil 的个人会话不受 project scope 约束。
// limit 控制随会话返回的最近消息条数,仅 get_conversation 需要,其余入口传 1。
func scopedConversation(ctx context.Context, deps ServerDeps, userID uuid.UUID,
conversationID string, limit int) (*chat.Conversation, []chat.Message, error) {
convID, err := uuid.Parse(conversationID)
if err != nil {
return nil, nil, errs.Wrap(err, errs.CodeInvalidInput, "conversation_id parse")
}
cv, msgs, err := deps.Conversations.Get(ctx, userID, convID, limit)
if err != nil {
return nil, nil, err
}
if cv.ProjectID != nil {
if err := CheckProjectFromCtx(ctx, *cv.ProjectID); err != nil {
return nil, nil, err
}
}
return cv, msgs, nil
}
// ===== list_recent_messages =====
type listRecentMessagesIn struct {
@@ -166,9 +186,9 @@ func registerListRecentMessages(srv *mcpsdk.Server, deps ServerDeps) {
return nil, listRecentMessagesOut{}, err
}
convID, err := uuid.Parse(in.ConversationID)
cv, _, err := scopedConversation(ctx, deps, c.UserID, in.ConversationID, 1)
if err != nil {
return nil, listRecentMessagesOut{}, fmt.Errorf("invalid conversation_id: %w", err)
return nil, listRecentMessagesOut{}, err
}
limit := in.Limit
@@ -179,7 +199,7 @@ func registerListRecentMessages(srv *mcpsdk.Server, deps ServerDeps) {
limit = 200
}
msgs, err := deps.Messages.ListMessages(ctx, c.UserID, convID, in.BeforeID, limit)
msgs, err := deps.Messages.ListMessages(ctx, c.UserID, cv.ID, in.BeforeID, limit)
if err != nil {
return nil, listRecentMessagesOut{}, err
}
@@ -248,6 +268,13 @@ func registerListConversations(srv *mcpsdk.Server, deps ServerDeps) {
}
out := listConversationsOut{Conversations: make([]conversationDetail, 0, len(cvs))}
for i := range cvs {
// 与 list_acp_sessions 一致:scope 外项目挂载的会话静默跳过;
// ProjectID 为 nil 的个人会话不受 project scope 约束。
if cvs[i].ProjectID != nil {
if err := CheckProjectFromCtx(ctx, *cvs[i].ProjectID); err != nil {
continue
}
}
out.Conversations = append(out.Conversations, conversationDetailFrom(&cvs[i]))
}
return nil, out, nil
@@ -276,10 +303,6 @@ func registerGetConversation(srv *mcpsdk.Server, deps ServerDeps) {
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
@@ -287,7 +310,7 @@ func registerGetConversation(srv *mcpsdk.Server, deps ServerDeps) {
if limit > 200 {
limit = 200
}
cv, msgs, err := deps.Conversations.Get(ctx, c.UserID, convID, limit)
cv, msgs, err := scopedConversation(ctx, deps, c.UserID, in.ConversationID, limit)
if err != nil {
return nil, getConversationOut{}, err
}
@@ -378,11 +401,11 @@ func registerUpdateConversationTitle(srv *mcpsdk.Server, deps ServerDeps) {
if err != nil {
return nil, okOut{}, err
}
convID, err := uuid.Parse(in.ConversationID)
cv, _, err := scopedConversation(ctx, deps, c.UserID, in.ConversationID, 1)
if err != nil {
return nil, okOut{}, errs.Wrap(err, errs.CodeInvalidInput, "conversation_id parse")
return nil, okOut{}, err
}
if err := deps.Conversations.UpdateTitle(ctx, c.UserID, convID, in.Title); err != nil {
if err := deps.Conversations.UpdateTitle(ctx, c.UserID, cv.ID, in.Title); err != nil {
return nil, okOut{}, err
}
return nil, okOut{OK: true}, nil
@@ -406,11 +429,11 @@ func registerDeleteConversation(srv *mcpsdk.Server, deps ServerDeps) {
if err != nil {
return nil, okOut{}, err
}
convID, err := uuid.Parse(in.ConversationID)
cv, _, err := scopedConversation(ctx, deps, c.UserID, in.ConversationID, 1)
if err != nil {
return nil, okOut{}, errs.Wrap(err, errs.CodeInvalidInput, "conversation_id parse")
return nil, okOut{}, err
}
if err := deps.Conversations.SoftDelete(ctx, c.UserID, convID); err != nil {
if err := deps.Conversations.SoftDelete(ctx, c.UserID, cv.ID); err != nil {
return nil, okOut{}, err
}
return nil, okOut{OK: true}, nil
@@ -440,11 +463,11 @@ func registerSendMessage(srv *mcpsdk.Server, deps ServerDeps) {
if err != nil {
return nil, sendMessageOut{}, err
}
convID, err := uuid.Parse(in.ConversationID)
cv, _, err := scopedConversation(ctx, deps, c.UserID, in.ConversationID, 1)
if err != nil {
return nil, sendMessageOut{}, errs.Wrap(err, errs.CodeInvalidInput, "conversation_id parse")
return nil, sendMessageOut{}, err
}
userMsgID, assistantMsgID, err := deps.Messages.Send(ctx, c.UserID, convID, in.Content, nil)
userMsgID, assistantMsgID, err := deps.Messages.Send(ctx, c.UserID, cv.ID, in.Content, nil)
if err != nil {
return nil, sendMessageOut{}, err
}
+69 -2
View File
@@ -281,9 +281,11 @@ func TestUpdateTitleAndDeleteConversation(t *testing.T) {
}
func TestSendMessage(t *testing.T) {
deps, _, msgSvc, _ := chatTestDeps()
deps, convSvc, msgSvc, _ := chatTestDeps()
cv := chat.Conversation{ID: uuid.New(), UserID: testUID, ModelID: uuid.New(), CreatedAt: time.Now(), UpdatedAt: time.Now()}
convSvc.items = []chat.Conversation{cv}
result, err := callTool(ctxWithAuth(Scope{}), deps, "send_message", map[string]any{
"conversation_id": uuid.New().String(), "content": "hello",
"conversation_id": cv.ID.String(), "content": "hello",
})
require.NoError(t, err)
require.False(t, result.IsError)
@@ -294,6 +296,71 @@ func TestSendMessage(t *testing.T) {
assert.Equal(t, "hello", msgSvc.sentContent)
}
// TestConversationTools_ScopeDenied 受限 token 直访 scope 外 project 挂载的会话 → 全部拒绝。
func TestConversationTools_ScopeDenied(t *testing.T) {
deps, convSvc, _, _ := chatTestDeps()
projB := uuid.New()
cvB := chat.Conversation{ID: uuid.New(), UserID: testUID, ModelID: uuid.New(), ProjectID: &projB, CreatedAt: time.Now(), UpdatedAt: time.Now()}
convSvc.items = []chat.Conversation{cvB}
// token 限定到 testPID,cvB 挂载在 projB 上 → 拒绝
ctx := ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{testPID}})
cases := []struct {
tool string
args map[string]any
}{
{"list_recent_messages", map[string]any{"conversation_id": cvB.ID.String()}},
{"get_conversation", map[string]any{"conversation_id": cvB.ID.String()}},
{"update_conversation_title", map[string]any{"conversation_id": cvB.ID.String(), "title": "t"}},
{"delete_conversation", map[string]any{"conversation_id": cvB.ID.String()}},
{"send_message", map[string]any{"conversation_id": cvB.ID.String(), "content": "hi"}},
}
for _, tc := range cases {
result, err := callTool(ctx, deps, tc.tool, tc.args)
require.NoError(t, err, tc.tool)
assert.True(t, result.IsError, tc.tool)
}
// 被拒绝的 delete/update 不应实际生效
require.Len(t, convSvc.items, 1)
assert.Nil(t, convSvc.items[0].Title)
}
// TestConversationTools_PersonalNotScoped ProjectID 为 nil 的个人会话不受 project scope 约束。
func TestConversationTools_PersonalNotScoped(t *testing.T) {
deps, convSvc, _, _ := chatTestDeps()
cv := chat.Conversation{ID: uuid.New(), UserID: testUID, ModelID: uuid.New(), CreatedAt: time.Now(), UpdatedAt: time.Now()}
convSvc.items = []chat.Conversation{cv}
result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{uuid.New()}}), deps,
"get_conversation", map[string]any{"conversation_id": cv.ID.String()})
require.NoError(t, err)
assert.False(t, result.IsError)
}
// TestListConversations_ScopeFiltered 无 project filter 时静默剔除 scope 外项目挂载的会话。
func TestListConversations_ScopeFiltered(t *testing.T) {
deps, convSvc, _, _ := chatTestDeps()
pidA := testPID
projB := uuid.New()
convSvc.items = []chat.Conversation{
{ID: uuid.New(), UserID: testUID, ModelID: uuid.New(), ProjectID: &pidA, CreatedAt: time.Now(), UpdatedAt: time.Now()},
{ID: uuid.New(), UserID: testUID, ModelID: uuid.New(), ProjectID: &projB, CreatedAt: time.Now(), UpdatedAt: time.Now()},
{ID: uuid.New(), UserID: testUID, ModelID: uuid.New(), CreatedAt: time.Now(), UpdatedAt: time.Now()},
}
result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{pidA}}), deps,
"list_conversations", map[string]any{})
require.NoError(t, err)
require.False(t, result.IsError)
var out listConversationsOut
unmarshalStructured(t, result, &out)
// projA 挂载 + 无挂载的保留,projB 挂载的被剔除
require.Len(t, out.Conversations, 2)
for _, cv := range out.Conversations {
assert.NotEqual(t, projB.String(), cv.ProjectID)
}
}
func TestPromptTemplateCRUD(t *testing.T) {
deps, _, _, tplSvc := chatTestDeps()