Skip to content

Commit 1d3735f

Browse files
authored
Merge pull request #707 from chaitin/feat/team-mcp-hub
feat: 支持团队 MCP Hub
2 parents 6de72a8 + 75b9975 commit 1d3735f

96 files changed

Lines changed: 21833 additions & 2850 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/biz/mcphub/auth/service.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package auth
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"log/slog"
8+
"strings"
9+
10+
"github.com/google/uuid"
11+
12+
"github.com/chaitin/MonkeyCode/backend/db"
13+
"github.com/chaitin/MonkeyCode/backend/db/modelapikey"
14+
"github.com/chaitin/MonkeyCode/backend/db/taskvirtualmachine"
15+
)
16+
17+
var (
18+
ErrUnauthorized = errors.New("unauthorized")
19+
ErrTaskNotBound = errors.New("task not bound")
20+
)
21+
22+
type Subject struct {
23+
Token string
24+
UserID uuid.UUID
25+
TaskID uuid.UUID
26+
ModelID uuid.UUID
27+
ModelAPIKeyID uuid.UUID
28+
}
29+
30+
type Service struct {
31+
db *db.Client
32+
logger *slog.Logger
33+
}
34+
35+
func NewService(client *db.Client, logger *slog.Logger) *Service {
36+
if logger == nil {
37+
logger = slog.Default()
38+
}
39+
return &Service{
40+
db: client,
41+
logger: logger,
42+
}
43+
}
44+
45+
func (s *Service) Resolve(ctx context.Context, token string) (*Subject, error) {
46+
token = strings.TrimSpace(token)
47+
if token == "" {
48+
return nil, ErrUnauthorized
49+
}
50+
51+
key, err := s.db.ModelApiKey.Query().
52+
Where(modelapikey.APIKey(token)).
53+
Only(ctx)
54+
if err != nil {
55+
if db.IsNotFound(err) || db.IsNotSingular(err) {
56+
return nil, ErrUnauthorized
57+
}
58+
return nil, fmt.Errorf("query model api key: %w", err)
59+
}
60+
if strings.TrimSpace(key.VirtualmachineID) == "" {
61+
return nil, ErrTaskNotBound
62+
}
63+
64+
tvm, err := s.db.TaskVirtualMachine.Query().
65+
Where(taskvirtualmachine.VirtualmachineID(key.VirtualmachineID)).
66+
Only(ctx)
67+
if err != nil {
68+
if db.IsNotFound(err) || db.IsNotSingular(err) {
69+
return nil, ErrTaskNotBound
70+
}
71+
return nil, fmt.Errorf("query task binding: %w", err)
72+
}
73+
74+
return &Subject{
75+
Token: token,
76+
UserID: key.UserID,
77+
TaskID: tvm.TaskID,
78+
ModelID: key.ModelID,
79+
ModelAPIKeyID: key.ID,
80+
}, nil
81+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package auth
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/google/uuid"
8+
_ "github.com/mattn/go-sqlite3"
9+
10+
"github.com/chaitin/MonkeyCode/backend/db/enttest"
11+
)
12+
13+
func TestResolveRejectsEmptyToken(t *testing.T) {
14+
client := enttest.Open(t, "sqlite3", "file:mcphub-auth-empty?mode=memory&cache=shared&_fk=1")
15+
defer client.Close()
16+
17+
svc := NewService(client, nil)
18+
if _, err := svc.Resolve(context.Background(), " "); err != ErrUnauthorized {
19+
t.Fatalf("Resolve() error = %v, want ErrUnauthorized", err)
20+
}
21+
}
22+
23+
func TestResolveRejectsUnboundModelKey(t *testing.T) {
24+
client := enttest.Open(t, "sqlite3", "file:mcphub-auth-unbound?mode=memory&cache=shared&_fk=1")
25+
defer client.Close()
26+
ctx := context.Background()
27+
28+
userID := uuid.New()
29+
modelID := uuid.New()
30+
client.User.Create().
31+
SetID(userID).
32+
SetName("u").
33+
SetEmail("u@example.com").
34+
SetRole("user").
35+
SetStatus("active").
36+
SaveX(ctx)
37+
client.Model.Create().
38+
SetID(modelID).
39+
SetUserID(userID).
40+
SetProvider("openai").
41+
SetAPIKey("upstream-key").
42+
SetBaseURL("https://example.com").
43+
SetModel("gpt-test").
44+
SetInterfaceType("openai_chat").
45+
SaveX(ctx)
46+
client.ModelApiKey.Create().
47+
SetID(uuid.New()).
48+
SetUserID(userID).
49+
SetModelID(modelID).
50+
SetAPIKey("runtime-token").
51+
SetVirtualmachineID("").
52+
SaveX(ctx)
53+
54+
svc := NewService(client, nil)
55+
if _, err := svc.Resolve(ctx, "runtime-token"); err != ErrTaskNotBound {
56+
t.Fatalf("Resolve() error = %v, want ErrTaskNotBound", err)
57+
}
58+
}

backend/biz/mcphub/billing/noop.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package billing
2+
3+
import (
4+
"context"
5+
6+
"github.com/chaitin/MonkeyCode/backend/biz/mcphub/repo"
7+
)
8+
9+
type Noop struct{}
10+
11+
func NewNoop() *Noop {
12+
return &Noop{}
13+
}
14+
15+
func (n *Noop) CanConsume(context.Context, *repo.ToolCallRecord) error {
16+
return nil
17+
}
18+
19+
func (n *Noop) Consume(context.Context, *repo.ToolCallRecord) error {
20+
return nil
21+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package billing
2+
3+
import (
4+
"context"
5+
"testing"
6+
)
7+
8+
func TestNoopAllowsConsume(t *testing.T) {
9+
b := NewNoop()
10+
if err := b.CanConsume(context.Background(), nil); err != nil {
11+
t.Fatalf("CanConsume() error = %v", err)
12+
}
13+
if err := b.Consume(context.Background(), nil); err != nil {
14+
t.Fatalf("Consume() error = %v", err)
15+
}
16+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package syncer
2+
3+
import (
4+
"context"
5+
"crypto/sha256"
6+
"encoding/hex"
7+
"encoding/json"
8+
"fmt"
9+
10+
"github.com/google/uuid"
11+
12+
"github.com/chaitin/MonkeyCode/backend/biz/mcphub/repo"
13+
)
14+
15+
type upstreamRepo interface {
16+
Get(ctx context.Context, id uuid.UUID) (*repo.UpstreamConfig, error)
17+
MarkSyncSuccess(ctx context.Context, id uuid.UUID) error
18+
MarkSyncFailed(ctx context.Context, id uuid.UUID) error
19+
}
20+
21+
type toolRepo interface {
22+
ReplaceByUpstream(ctx context.Context, upstreamID uuid.UUID, rows []repo.UpsertToolInput) error
23+
}
24+
25+
type registryPublisher interface {
26+
Publish(ctx context.Context) error
27+
}
28+
29+
type upstreamClient interface {
30+
ListTools(ctx context.Context, upstream *repo.UpstreamConfig) ([]repo.UpstreamTool, error)
31+
}
32+
33+
type Service struct {
34+
upstreams upstreamRepo
35+
tools toolRepo
36+
registry registryPublisher
37+
client upstreamClient
38+
}
39+
40+
func NewService(upstreams upstreamRepo, tools toolRepo, registry registryPublisher, client upstreamClient) *Service {
41+
return &Service{
42+
upstreams: upstreams,
43+
tools: tools,
44+
registry: registry,
45+
client: client,
46+
}
47+
}
48+
49+
func (s *Service) Sync(ctx context.Context, upstreamID uuid.UUID) error {
50+
upstream, err := s.upstreams.Get(ctx, upstreamID)
51+
if err != nil {
52+
return err
53+
}
54+
55+
tools, err := s.client.ListTools(ctx, upstream)
56+
if err != nil {
57+
_ = s.upstreams.MarkSyncFailed(ctx, upstreamID)
58+
return err
59+
}
60+
61+
rows := make([]repo.UpsertToolInput, 0, len(tools))
62+
for _, tool := range tools {
63+
rows = append(rows, repo.UpsertToolInput{
64+
UpstreamID: upstreamID,
65+
Name: tool.Name,
66+
NamespacedName: NamespacedName(upstream.Slug, tool.Name),
67+
Scope: upstream.Scope,
68+
UserID: upstream.UserID,
69+
TeamID: upstream.TeamID,
70+
Description: tool.Description,
71+
InputSchema: tool.InputSchema,
72+
VersionHash: hashSchema(tool.InputSchema),
73+
Price: 0,
74+
})
75+
}
76+
77+
if err := s.tools.ReplaceByUpstream(ctx, upstreamID, rows); err != nil {
78+
_ = s.upstreams.MarkSyncFailed(ctx, upstreamID)
79+
return err
80+
}
81+
if err := s.registry.Publish(ctx); err != nil {
82+
_ = s.upstreams.MarkSyncFailed(ctx, upstreamID)
83+
return err
84+
}
85+
if err := s.upstreams.MarkSyncSuccess(ctx, upstreamID); err != nil {
86+
return err
87+
}
88+
return nil
89+
}
90+
91+
func hashSchema(raw json.RawMessage) string {
92+
if len(raw) == 0 {
93+
raw = json.RawMessage(`{}`)
94+
}
95+
sum := sha256.Sum256(raw)
96+
return hex.EncodeToString(sum[:])
97+
}
98+
99+
func NamespacedName(slug, tool string) string {
100+
return fmt.Sprintf("%s__%s", slug, tool)
101+
}

0 commit comments

Comments
 (0)