Skip to content

Commit b2cb1ac

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/mcp-3233-isolation-mode-merge
# Conflicts: # oas/docs.go
2 parents 3bd787a + ef7b53e commit b2cb1ac

90 files changed

Lines changed: 5304 additions & 243 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.

.github/workflows/bench.yml

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,13 +131,28 @@ jobs:
131131
retention-days: 90
132132
if-no-files-found: warn
133133

134+
# Bootstrap: create the Cloudflare Pages project if it does not yet exist.
135+
# Idempotent — fails silently (continue-on-error) when the project already
136+
# exists (Cloudflare error 8000007). Requires Pages:Edit scope on the token.
137+
# If the token is deploy-only, create the project once manually in the
138+
# Cloudflare dashboard (name: mcpproxy-bench, production branch: main) and
139+
# this step will harmlessly fail every run thereafter.
140+
- name: Create Cloudflare Pages project (if missing)
141+
continue-on-error: true
142+
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
143+
with:
144+
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
145+
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
146+
command: pages project create mcpproxy-bench --production-branch=main
147+
134148
# Publish to Cloudflare Pages (mcpproxy-bench project).
135-
# On first deploy, wrangler auto-creates the project; subsequent deploys
136-
# update it. The Pages URL will be mcpproxy-bench.pages.dev (or a custom
137-
# domain such as bench.mcpproxy.app once configured in Cloudflare).
149+
# --commit-dirty=true: bench results are written into the working tree during
150+
# the run and never committed to git, so wrangler would otherwise reject the
151+
# dirty-tree check. The Pages URL will be mcpproxy-bench.pages.dev (or a
152+
# custom domain such as bench.mcpproxy.app once configured in Cloudflare).
138153
- name: Deploy benchmark dashboard to Cloudflare Pages
139154
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
140155
with:
141156
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
142157
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
143-
command: pages deploy bench/results --project-name=mcpproxy-bench
158+
command: pages deploy bench/results --project-name=mcpproxy-bench --commit-dirty=true

cmd/mcpproxy-tray/internal/api/adapter.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"runtime"
99

1010
internalRuntime "github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime"
11+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/tray"
1112
)
1213

1314
// ClientInterface defines the methods required by ServerAdapter from the API client.
@@ -20,6 +21,9 @@ type ClientInterface interface {
2021
UnquarantineServer(serverName string) error
2122
TriggerOAuthLogin(serverName string) error
2223
StatusChannel() <-chan StatusUpdate
24+
GetProfiles() ([]tray.ProfileInfo, error)
25+
GetActiveProfile() (string, error)
26+
SetActiveProfile(name string) error
2327
}
2428

2529
// isServerHealthy returns true if the server is considered healthy.
@@ -122,7 +126,7 @@ func (a *ServerAdapter) GetStatus() interface{} {
122126

123127
// Fallback to empty if we couldn't get it
124128
if listenAddr == "" {
125-
listenAddr = "" // Empty means tray will show "Status: Running" without address
129+
listenAddr = "" // Empty means tray will show "Status: Running" without address
126130
}
127131

128132
servers, serverErr := a.client.GetServers()
@@ -186,6 +190,21 @@ func (a *ServerAdapter) EventsChannel() <-chan internalRuntime.Event {
186190
return nil
187191
}
188192

193+
// GetProfiles returns the configured profiles for the tray switcher (Profiles v2 T5).
194+
func (a *ServerAdapter) GetProfiles() ([]tray.ProfileInfo, error) {
195+
return a.client.GetProfiles()
196+
}
197+
198+
// GetActiveProfile returns the server-level default active profile (empty = all servers).
199+
func (a *ServerAdapter) GetActiveProfile() (string, error) {
200+
return a.client.GetActiveProfile()
201+
}
202+
203+
// SetActiveProfile sets the server-level default active profile (empty clears it).
204+
func (a *ServerAdapter) SetActiveProfile(name string) error {
205+
return a.client.SetActiveProfile(name)
206+
}
207+
189208
// GetQuarantinedServers returns quarantined servers
190209
func (a *ServerAdapter) GetQuarantinedServers() ([]map[string]interface{}, error) {
191210
servers, err := a.client.GetServers()

cmd/mcpproxy-tray/internal/api/adapter_test.go

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66

77
"github.com/stretchr/testify/assert"
88
"github.com/stretchr/testify/require"
9+
10+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/tray"
911
)
1012

1113
// =============================================================================
@@ -21,11 +23,13 @@ type MockClient struct {
2123
enableErr error
2224
quarantineErr error
2325
oauthErr error
24-
enabledServers map[string]bool // tracks enable/disable calls
25-
quarantinedServers map[string]bool // tracks quarantine calls
26-
unquarantinedServers []string // tracks unquarantine calls
27-
oauthTriggered []string // tracks OAuth login calls
26+
enabledServers map[string]bool // tracks enable/disable calls
27+
quarantinedServers map[string]bool // tracks quarantine calls
28+
unquarantinedServers []string // tracks unquarantine calls
29+
oauthTriggered []string // tracks OAuth login calls
2830
statusCh chan StatusUpdate
31+
profiles []tray.ProfileInfo // Profiles v2 T5
32+
activeProfile string // Profiles v2 T5
2933
}
3034

3135
func NewMockClient() *MockClient {
@@ -87,6 +91,46 @@ func (m *MockClient) StatusChannel() <-chan StatusUpdate {
8791
return m.statusCh
8892
}
8993

94+
func (m *MockClient) GetProfiles() ([]tray.ProfileInfo, error) {
95+
return m.profiles, nil
96+
}
97+
98+
func (m *MockClient) GetActiveProfile() (string, error) {
99+
return m.activeProfile, nil
100+
}
101+
102+
func (m *MockClient) SetActiveProfile(name string) error {
103+
m.activeProfile = name
104+
return nil
105+
}
106+
107+
// TestServerAdapterProfileDelegation verifies the adapter forwards the profile
108+
// switcher calls (Profiles v2 T5) to the underlying client.
109+
func TestServerAdapterProfileDelegation(t *testing.T) {
110+
mock := NewMockClient()
111+
mock.profiles = []tray.ProfileInfo{
112+
{Name: "research", ToolCount: 3},
113+
{Name: "deploy", ToolCount: 2},
114+
}
115+
mock.activeProfile = "research"
116+
adapter := NewServerAdapter(mock)
117+
118+
profiles, err := adapter.GetProfiles()
119+
require.NoError(t, err)
120+
require.Len(t, profiles, 2)
121+
assert.Equal(t, "research", profiles[0].Name)
122+
assert.Equal(t, 3, profiles[0].ToolCount)
123+
124+
active, err := adapter.GetActiveProfile()
125+
require.NoError(t, err)
126+
assert.Equal(t, "research", active)
127+
128+
require.NoError(t, adapter.SetActiveProfile("deploy"))
129+
active, err = adapter.GetActiveProfile()
130+
require.NoError(t, err)
131+
assert.Equal(t, "deploy", active)
132+
}
133+
90134
// =============================================================================
91135
// isServerHealthy Unit Tests
92136
// =============================================================================

cmd/mcpproxy-tray/internal/api/client.go

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ package api
22

33
import (
44
"bufio"
5+
"bytes"
56
"context"
67
"crypto/sha256"
78
"crypto/tls"
89
"crypto/x509"
910
"encoding/json"
1011
"fmt"
12+
"io"
1113
"net/http"
1214
"net/url"
1315
"os"
@@ -638,6 +640,63 @@ func (c *Client) TriggerOAuthLogin(serverName string) error {
638640
return nil
639641
}
640642

643+
// GetProfiles fetches the configured profiles (Profiles v2 T5) from
644+
// GET /api/v1/profiles for the tray profile switcher.
645+
func (c *Client) GetProfiles() ([]tray.ProfileInfo, error) {
646+
resp, err := c.makeRequest("GET", "/api/v1/profiles", nil)
647+
if err != nil {
648+
return nil, err
649+
}
650+
if !resp.Success {
651+
return nil, fmt.Errorf("API error: %s", resp.Error)
652+
}
653+
654+
raw, ok := resp.Data["profiles"].([]interface{})
655+
if !ok {
656+
// No profiles configured is a valid, empty result.
657+
return nil, nil
658+
}
659+
660+
var result []tray.ProfileInfo
661+
for _, p := range raw {
662+
pm, ok := p.(map[string]interface{})
663+
if !ok {
664+
continue
665+
}
666+
result = append(result, tray.ProfileInfo{
667+
Name: getString(pm, "name"),
668+
ToolCount: getInt(pm, "tool_count"),
669+
})
670+
}
671+
return result, nil
672+
}
673+
674+
// GetActiveProfile fetches the server-level default active profile from
675+
// GET /api/v1/profiles/active. An empty string means "all servers".
676+
func (c *Client) GetActiveProfile() (string, error) {
677+
resp, err := c.makeRequest("GET", "/api/v1/profiles/active", nil)
678+
if err != nil {
679+
return "", err
680+
}
681+
if !resp.Success {
682+
return "", fmt.Errorf("API error: %s", resp.Error)
683+
}
684+
return getString(resp.Data, "active_profile"), nil
685+
}
686+
687+
// SetActiveProfile sets the server-level default active profile via
688+
// PUT /api/v1/profiles/active. An empty name clears the selection (all servers).
689+
func (c *Client) SetActiveProfile(name string) error {
690+
resp, err := c.makeRequest("PUT", "/api/v1/profiles/active", map[string]string{"profile": name})
691+
if err != nil {
692+
return err
693+
}
694+
if !resp.Success {
695+
return fmt.Errorf("API error: %s", resp.Error)
696+
}
697+
return nil
698+
}
699+
641700
// GetServerTools gets tools for a specific server
642701
func (c *Client) GetServerTools(serverName string) ([]Tool, error) {
643702
endpoint := fmt.Sprintf("/api/v1/servers/%s/tools", serverName)
@@ -887,17 +946,32 @@ func (c *Client) OpenWebUI() error {
887946
}
888947
}
889948

890-
// makeRequest makes an HTTP request to the API with enhanced error handling and retry logic
891-
func (c *Client) makeRequest(method, path string, _ interface{}) (*Response, error) {
949+
// makeRequest makes an HTTP request to the API with enhanced error handling and retry logic.
950+
// When body is non-nil it is JSON-encoded and sent as the request payload (e.g. PUT bodies);
951+
// nil yields a bodyless request, preserving every existing call site.
952+
func (c *Client) makeRequest(method, path string, body interface{}) (*Response, error) {
892953
url, err := c.buildURL(path)
893954
if err != nil {
894955
return nil, err
895956
}
896957
maxRetries := 3
897958
baseDelay := 1 * time.Second
898959

960+
var bodyBytes []byte
961+
if body != nil {
962+
bodyBytes, err = json.Marshal(body)
963+
if err != nil {
964+
return nil, fmt.Errorf("failed to marshal request body: %w", err)
965+
}
966+
}
967+
899968
for attempt := 1; attempt <= maxRetries; attempt++ {
900-
req, err := http.NewRequest(method, url, http.NoBody)
969+
// Recreate the reader each attempt so retries resend the full body.
970+
var bodyReader io.Reader = http.NoBody
971+
if bodyBytes != nil {
972+
bodyReader = bytes.NewReader(bodyBytes)
973+
}
974+
req, err := http.NewRequest(method, url, bodyReader)
901975
if err != nil {
902976
return nil, fmt.Errorf("failed to create request: %w", err)
903977
}

cmd/mcpproxy/token_cmd.go

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ var (
2323
tokenServers string
2424
tokenPermissions string
2525
tokenExpires string
26+
tokenProfilePin string
2627
)
2728

2829
// GetTokenCommand returns the token parent command.
@@ -73,6 +74,7 @@ Examples:
7374
cmd.Flags().StringVar(&tokenServers, "servers", "", "Comma-separated list of allowed server names, or \"*\" for all (required)")
7475
cmd.Flags().StringVar(&tokenPermissions, "permissions", "", "Comma-separated permission tiers: read, write, destructive (required, must include read)")
7576
cmd.Flags().StringVar(&tokenExpires, "expires", "30d", "Token expiry duration (e.g., 7d, 30d, 90d, 365d)")
77+
cmd.Flags().StringVar(&tokenProfilePin, "profile-pin", "", "Pin this token to a profile; it can only operate in that profile (cannot switch via set_profile or /mcp/p/<other>)")
7678
_ = cmd.MarkFlagRequired("name")
7779
_ = cmd.MarkFlagRequired("servers")
7880
_ = cmd.MarkFlagRequired("permissions")
@@ -160,6 +162,9 @@ func runTokenCreate(_ *cobra.Command, _ []string) error {
160162
"permissions": permissions,
161163
"expires_in": tokenExpires,
162164
}
165+
if tokenProfilePin != "" {
166+
body["profile_pin"] = tokenProfilePin
167+
}
163168

164169
bodyJSON, err := json.Marshal(body)
165170
if err != nil {
@@ -209,6 +214,9 @@ func runTokenCreate(_ *cobra.Command, _ []string) error {
209214
printField(" Name: ", result, "name")
210215
printListField(" Servers: ", result, "allowed_servers")
211216
printListField(" Permissions: ", result, "permissions")
217+
if pin := getMapString(result, "profile_pin"); pin != "" {
218+
fmt.Printf(" Profile Pin: %s\n", pin)
219+
}
212220
printField(" Expires: ", result, "expires_at")
213221

214222
return nil
@@ -257,9 +265,9 @@ func runTokenList(_ *cobra.Command, _ []string) error {
257265
}
258266

259267
// Table format
260-
fmt.Printf("%-20s %-14s %-25s %-20s %-8s %-25s\n",
261-
"NAME", "PREFIX", "SERVERS", "PERMISSIONS", "REVOKED", "EXPIRES")
262-
fmt.Println(strings.Repeat("-", 115))
268+
fmt.Printf("%-20s %-14s %-25s %-20s %-8s %-12s %-25s\n",
269+
"NAME", "PREFIX", "SERVERS", "PERMISSIONS", "REVOKED", "PROFILE PIN", "EXPIRES")
270+
fmt.Println(strings.Repeat("-", 128))
263271

264272
for _, t := range tokens {
265273
tok, ok := t.(map[string]interface{})
@@ -276,15 +284,20 @@ func runTokenList(_ *cobra.Command, _ []string) error {
276284
serverList := joinInterfaceSlice(tok, "allowed_servers", 23)
277285
permList := joinInterfaceSlice(tok, "permissions", 0)
278286

287+
pin := getMapString(tok, "profile_pin")
288+
if pin == "" {
289+
pin = "-"
290+
}
291+
279292
expiresAt := getMapString(tok, "expires_at")
280293
if expiresAt != "" {
281294
if t, parseErr := time.Parse(time.RFC3339, expiresAt); parseErr == nil {
282295
expiresAt = t.Format("2006-01-02 15:04")
283296
}
284297
}
285298

286-
fmt.Printf("%-20s %-14s %-25s %-20s %-8s %-25s\n",
287-
name, prefix, serverList, permList, revoked, expiresAt)
299+
fmt.Printf("%-20s %-14s %-25s %-20s %-8s %-12s %-25s\n",
300+
name, prefix, serverList, permList, revoked, pin, expiresAt)
288301
}
289302

290303
return nil
@@ -335,6 +348,9 @@ func runTokenShow(_ *cobra.Command, args []string) error {
335348
printField("Token Prefix: ", result, "token_prefix")
336349
printListField("Servers: ", result, "allowed_servers")
337350
printListField("Permissions: ", result, "permissions")
351+
if pin := getMapString(result, "profile_pin"); pin != "" {
352+
fmt.Printf("Profile Pin: %s\n", pin)
353+
}
338354
if revoked, ok := result["revoked"].(bool); ok {
339355
fmt.Printf("Revoked: %v\n", revoked)
340356
}

0 commit comments

Comments
 (0)