From d6279c6ee1e05b09e4105c7bd6a0d90fbcb2490f Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Fri, 8 May 2026 10:34:54 +0800 Subject: [PATCH] feat(mcp): scope check helpers (CheckToolFromCtx + CheckProjectFromCtx) Co-Authored-By: Claude Opus 4.6 --- internal/mcp/scope_check.go | 33 +++++++++++++++ internal/mcp/scope_check_test.go | 73 ++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 internal/mcp/scope_check.go create mode 100644 internal/mcp/scope_check_test.go diff --git a/internal/mcp/scope_check.go b/internal/mcp/scope_check.go new file mode 100644 index 0000000..c93f548 --- /dev/null +++ b/internal/mcp/scope_check.go @@ -0,0 +1,33 @@ +package mcp + +import ( + "context" + + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" +) + +func CheckToolFromCtx(ctx context.Context, toolName string) error { + sess, ok := FromContext(ctx) + if !ok { + return errs.New(errs.CodeInternal, "mcp: AuthSession missing") + } + if !sess.Scope.AllowsTool(toolName) { + return errs.New(errs.CodeMcpTokenScopeViolation, + "tool '"+toolName+"' is not in token scope") + } + return nil +} + +func CheckProjectFromCtx(ctx context.Context, projectID uuid.UUID) error { + sess, ok := FromContext(ctx) + if !ok { + return errs.New(errs.CodeInternal, "mcp: AuthSession missing") + } + if !sess.Scope.AllowsProject(projectID) { + return errs.New(errs.CodeMcpTokenScopeViolation, + "project is not in token scope") + } + return nil +} diff --git a/internal/mcp/scope_check_test.go b/internal/mcp/scope_check_test.go new file mode 100644 index 0000000..2302bac --- /dev/null +++ b/internal/mcp/scope_check_test.go @@ -0,0 +1,73 @@ +package mcp_test + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + "github.com/yan1h/agent-coding-workflow/internal/mcp" +) + +func TestCheckToolFromCtx_Allowed(t *testing.T) { + t.Parallel() + ctx := context.WithValue(context.Background(), mcp.AuthContextKey, + &mcp.AuthSession{Scope: mcp.Scope{Tools: []string{"list_issues"}}}) + require.NoError(t, mcp.CheckToolFromCtx(ctx, "list_issues")) +} + +func TestCheckToolFromCtx_Denied(t *testing.T) { + t.Parallel() + ctx := context.WithValue(context.Background(), mcp.AuthContextKey, + &mcp.AuthSession{Scope: mcp.Scope{Tools: []string{"list_issues"}}}) + + err := mcp.CheckToolFromCtx(ctx, "create_issue") + ae, ok := errs.As(err) + require.True(t, ok) + assert.Equal(t, errs.CodeMcpTokenScopeViolation, ae.Code) +} + +func TestCheckToolFromCtx_NilTools_AllowsAll(t *testing.T) { + t.Parallel() + ctx := context.WithValue(context.Background(), mcp.AuthContextKey, + &mcp.AuthSession{Scope: mcp.Scope{Tools: nil}}) + require.NoError(t, mcp.CheckToolFromCtx(ctx, "anything")) +} + +func TestCheckToolFromCtx_NoSession(t *testing.T) { + t.Parallel() + err := mcp.CheckToolFromCtx(context.Background(), "x") + ae, ok := errs.As(err) + require.True(t, ok) + assert.Equal(t, errs.CodeInternal, ae.Code) +} + +func TestCheckProjectFromCtx_Allowed(t *testing.T) { + t.Parallel() + pid := uuid.New() + ctx := context.WithValue(context.Background(), mcp.AuthContextKey, + &mcp.AuthSession{Scope: mcp.Scope{ProjectIDs: []uuid.UUID{pid}}}) + require.NoError(t, mcp.CheckProjectFromCtx(ctx, pid)) +} + +func TestCheckProjectFromCtx_Denied(t *testing.T) { + t.Parallel() + a, b := uuid.New(), uuid.New() + ctx := context.WithValue(context.Background(), mcp.AuthContextKey, + &mcp.AuthSession{Scope: mcp.Scope{ProjectIDs: []uuid.UUID{a}}}) + + err := mcp.CheckProjectFromCtx(ctx, b) + ae, ok := errs.As(err) + require.True(t, ok) + assert.Equal(t, errs.CodeMcpTokenScopeViolation, ae.Code) +} + +func TestCheckProjectFromCtx_NilProjectIDs_AllowsAll(t *testing.T) { + t.Parallel() + ctx := context.WithValue(context.Background(), mcp.AuthContextKey, + &mcp.AuthSession{Scope: mcp.Scope{ProjectIDs: nil}}) + require.NoError(t, mcp.CheckProjectFromCtx(ctx, uuid.New())) +}