Skip to content

Commit ef7b53e

Browse files
feat: Profiles v2 T5 — tray profile switcher (Go + Swift) + active_profile SSE (MCP-3244) (#767)
* feat(profiles): emit active_profile.changed SSE event on default switch (MCP-3244) Backend half of the tray profile switcher (Profiles v2 T5). PUT /api/v1/profiles/active now broadcasts an active_profile.changed runtime event on an actual change, so UI surfaces (Web UI, tray) refetch and reflect a default-profile switch made by another client. Emitted via an optional capability assertion in the httpapi handler so ServerController is not widened (no mock fan-out). Co-Authored-By: Paperclip <noreply@paperclip.ing> * feat(tray): Go tray profile switcher submenu (MCP-3244) Profiles v2 T5 for the cross-platform Go systray tray (macOS/Windows/Linux). - Adds a 'Profile' submenu listing 'All servers' + each configured profile with tool counts; the active profile is checkmarked. Clicking switches the server-level default active profile via PUT /api/v1/profiles/active. - ServerInterface gains GetProfiles/GetActiveProfile/SetActiveProfile; the REST ServerAdapter forwards them to the api.Client (new GetProfiles, GetActiveProfile, SetActiveProfile; makeRequest is now body-capable for the PUT). ProfileInfo lives in a build-tag-free file for stub/linux builds. - The submenu refreshes on the existing 3s sync loop, so an active-profile switch made by another client (Web UI, CLI) is reflected within one interval — consistent with how the REST-adapter tray already reflects server/quarantine changes (its EventsChannel is nil; it does not proxy runtime events). The Swift macOS tray gets the real-time active_profile.changed SSE wiring. - Tests: profileMenuTitle/profileSetChanged helpers, performSync profile refresh, ServerAdapter profile delegation. Cross-builds darwin/windows/linux. Co-Authored-By: Paperclip <noreply@paperclip.ing> * feat(macos-tray): Swift profile switcher submenu via SSE (MCP-3244) Profiles v2 T5 for the native macOS (AppKit) tray. - AppKit rebuildMenu gains a 'Profile' submenu (shown when profiles are configured): 'All servers' (clears the profile) + each profile with tool count; the active selection is checkmarked. switchProfile sets the server-level default via PUT /api/v1/profiles/active. - APIClient: profiles(), activeProfile(), setActiveProfile(); AppState gains profiles + activeProfile; new ProfileSummary/ProfilesListResponse/ ActiveProfileResponse models match the Go REST wire shape. - Real-time reflection: CoreProcessManager.refreshProfiles runs on connect, on the periodic refresh, and on the new active_profile.changed SSE event, so a switch from any client (Web UI, CLI, Go tray) repaints the macOS submenu. - Tests: ProfileModelsTests (decode + Equatable). swift build + swift test green. Co-Authored-By: Paperclip <noreply@paperclip.ing> --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1 parent c7a0e55 commit ef7b53e

20 files changed

Lines changed: 676 additions & 25 deletions

File tree

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
}

internal/httpapi/profiles.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,9 +147,19 @@ func (s *Server) handleSetActiveProfile(w http.ResponseWriter, r *http.Request)
147147
}
148148

149149
s.activeProfileMu.Lock()
150+
changed := s.activeProfile != slug
150151
s.activeProfile = slug
151152
s.activeProfileMu.Unlock()
152153

154+
// Notify other clients (Web UI, tray) via SSE only on an actual change, so a
155+
// switch made here is reflected everywhere (Profiles v2 T5). Emitted through
156+
// an optional capability assertion to avoid widening ServerController.
157+
if changed {
158+
if emitter, ok := s.controller.(interface{ EmitActiveProfileChanged(string) }); ok {
159+
emitter.EmitActiveProfileChanged(slug)
160+
}
161+
}
162+
153163
s.getRequestLogger(r).Infow("default active profile updated", "profile", slug)
154164
s.writeSuccess(w, map[string]interface{}{"active_profile": slug})
155165
}

internal/httpapi/profiles_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,50 @@ func TestHandleSetActiveProfileBadBody(t *testing.T) {
131131
w, _ := doJSON(t, srv, http.MethodPut, "/api/v1/profiles/active", []byte(`not json`))
132132
require.Equal(t, http.StatusBadRequest, w.Code)
133133
}
134+
135+
// profileEventRecorder records EmitActiveProfileChanged calls so we can assert
136+
// the handler notifies other clients (via SSE) only on an actual change.
137+
type profileEventRecorder struct {
138+
mockProfilesController
139+
emitted []string
140+
}
141+
142+
func (m *profileEventRecorder) EmitActiveProfileChanged(profile string) {
143+
m.emitted = append(m.emitted, profile)
144+
}
145+
146+
func TestSetActiveProfileEmitsChangeEvent(t *testing.T) {
147+
cfg := &config.Config{
148+
APIKey: "test-key",
149+
Servers: []*config.ServerConfig{
150+
{Name: "research-srv"},
151+
{Name: "deploy-srv"},
152+
},
153+
Profiles: []config.ProfileConfig{
154+
{Name: "research", Servers: []string{"research-srv"}},
155+
{Name: "deploy", Servers: []string{"deploy-srv"}},
156+
},
157+
}
158+
rec := &profileEventRecorder{mockProfilesController: mockProfilesController{apiKey: "test-key", cfg: cfg}}
159+
srv := NewServer(rec, zap.NewNop().Sugar(), nil)
160+
161+
// Setting a new profile emits one change event.
162+
w, _ := doJSON(t, srv, http.MethodPut, "/api/v1/profiles/active", []byte(`{"profile":"research"}`))
163+
require.Equal(t, http.StatusOK, w.Code)
164+
require.Equal(t, []string{"research"}, rec.emitted)
165+
166+
// Setting the same profile again is a no-op — no duplicate event.
167+
w, _ = doJSON(t, srv, http.MethodPut, "/api/v1/profiles/active", []byte(`{"profile":"research"}`))
168+
require.Equal(t, http.StatusOK, w.Code)
169+
require.Equal(t, []string{"research"}, rec.emitted)
170+
171+
// Clearing emits an empty-string change event.
172+
w, _ = doJSON(t, srv, http.MethodPut, "/api/v1/profiles/active", []byte(`{"profile":""}`))
173+
require.Equal(t, http.StatusOK, w.Code)
174+
require.Equal(t, []string{"research", ""}, rec.emitted)
175+
176+
// A rejected (unknown) profile emits nothing.
177+
w, _ = doJSON(t, srv, http.MethodPut, "/api/v1/profiles/active", []byte(`{"profile":"ghost"}`))
178+
require.Equal(t, http.StatusNotFound, w.Code)
179+
require.Equal(t, []string{"research", ""}, rec.emitted)
180+
}

internal/runtime/event_bus.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,15 @@ func (r *Runtime) emitSecretsChanged(operation string, secretName string, extra
353353
r.publishEvent(newEvent(EventTypeSecretsChanged, payload))
354354
}
355355

356+
// EmitActiveProfileChanged emits an event when the server-level default active
357+
// profile changes (Profiles v2). UI surfaces subscribe and refetch
358+
// GET /api/v1/profiles/active so a switch made by one client (Web UI, tray, CLI)
359+
// is reflected everywhere. An empty profile means "all servers".
360+
func (r *Runtime) EmitActiveProfileChanged(profile string) {
361+
payload := map[string]any{"active_profile": profile}
362+
r.publishEvent(newEvent(EventTypeActiveProfileChanged, payload))
363+
}
364+
356365
// EmitOAuthTokenRefreshed emits an event when proactive token refresh succeeds.
357366
// This is used by the RefreshManager to notify subscribers of successful token refresh.
358367
func (r *Runtime) EmitOAuthTokenRefreshed(serverName string, expiresAt time.Time) {

internal/runtime/events.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ const (
1414
EventTypeConfigSaved EventType = "config.saved"
1515
// EventTypeSecretsChanged is emitted when secrets are added, updated, or deleted.
1616
EventTypeSecretsChanged EventType = "secrets.changed"
17+
// EventTypeActiveProfileChanged is emitted when the server-level default
18+
// active profile changes (Profiles v2). UI surfaces (Web UI, tray) refetch
19+
// GET /api/v1/profiles/active to reflect a switch made by another client.
20+
EventTypeActiveProfileChanged EventType = "active_profile.changed"
1721
// EventTypeOAuthTokenRefreshed is emitted when proactive token refresh succeeds.
1822
EventTypeOAuthTokenRefreshed EventType = "oauth.token_refreshed"
1923
// EventTypeOAuthRefreshFailed is emitted when proactive token refresh fails after retries.

internal/server/server.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2593,6 +2593,15 @@ func (s *Server) NotifySecretsChanged(ctx context.Context, operation, secretName
25932593
return s.runtime.NotifySecretsChanged(ctx, operation, secretName)
25942594
}
25952595

2596+
// EmitActiveProfileChanged broadcasts an active_profile.changed event so UI
2597+
// surfaces (Web UI, tray) reflect a default-profile switch made by another
2598+
// client (Profiles v2 T2/T5). The REST handler invokes this via an optional
2599+
// capability assertion, so adding it here does not widen the httpapi
2600+
// ServerController interface.
2601+
func (s *Server) EmitActiveProfileChanged(profile string) {
2602+
s.runtime.EmitActiveProfileChanged(profile)
2603+
}
2604+
25962605
// GetCurrentConfig returns the current configuration
25972606
func (s *Server) GetCurrentConfig() interface{} {
25982607
return s.runtime.GetCurrentConfig()

0 commit comments

Comments
 (0)