Skip to content

Commit a98d121

Browse files
feat: add set_secret and configure health actions
Implement Health as single source of truth for server state by adding two new action types that detect and surface configuration issues: - set_secret: Shown when a server references an unresolved secret (e.g., ${env:MISSING_TOKEN}). Detail field contains the secret name. - configure: Shown when OAuth configuration has parameter errors (e.g., missing RFC 8707 resource parameter). Changes: - Add ActionSetSecret/ActionConfigure constants and extraction helpers - Extend HealthCalculatorInput with MissingSecret/OAuthConfigErr fields - Add detection logic in CalculateHealth() with unit tests - Refactor Doctor() to aggregate diagnostics from Health.Action - Add action buttons in ServerCard.vue and Dashboard.vue - Update CLI hints in upstream_cmd.go for new actions - Update TypeScript types and swagger.yaml documentation This ensures CLI (mcpproxy doctor), Web UI, and MCP tools all show consistent health information derived from the same source.
1 parent e88216f commit a98d121

14 files changed

Lines changed: 442 additions & 100 deletions

File tree

cmd/mcpproxy/upstream_cmd.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,12 +247,14 @@ func outputServers(servers []map[string]interface{}) error {
247247
healthAdminState := "enabled"
248248
healthSummary := getStringField(srv, "status") // fallback to old status
249249
healthAction := ""
250+
healthDetail := ""
250251

251252
if healthData != nil {
252253
healthLevel = getStringField(healthData, "level")
253254
healthAdminState = getStringField(healthData, "admin_state")
254255
healthSummary = getStringField(healthData, "summary")
255256
healthAction = getStringField(healthData, "action")
257+
healthDetail = getStringField(healthData, "detail")
256258
}
257259

258260
// Status emoji based on health level and admin state
@@ -286,6 +288,14 @@ func outputServers(servers []map[string]interface{}) error {
286288
actionHint = "Approve in Web UI"
287289
case "view_logs":
288290
actionHint = fmt.Sprintf("upstream logs %s", name)
291+
case health.ActionSetSecret:
292+
if healthDetail != "" {
293+
actionHint = fmt.Sprintf("Set %s", healthDetail)
294+
} else {
295+
actionHint = "Set secret in config"
296+
}
297+
case health.ActionConfigure:
298+
actionHint = "Edit config"
289299
}
290300

291301
fmt.Printf("%-4s %-25s %-10s %-10d %-30s %s\n",

frontend/src/components/ServerCard.vue

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,22 @@
117117
View Logs
118118
</router-link>
119119

120+
<router-link
121+
v-if="healthAction === 'set_secret'"
122+
to="/secrets"
123+
class="btn btn-sm btn-primary"
124+
>
125+
Set Secret
126+
</router-link>
127+
128+
<router-link
129+
v-if="healthAction === 'configure'"
130+
:to="`/servers/${server.name}?tab=config`"
131+
class="btn btn-sm btn-primary"
132+
>
133+
Configure
134+
</router-link>
135+
120136
<!-- Logout button (only when connected with OAuth) -->
121137
<button
122138
v-if="canLogout"

frontend/src/types/api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export interface HealthStatus {
1111
admin_state: 'enabled' | 'disabled' | 'quarantined'
1212
summary: string
1313
detail?: string
14-
action?: 'login' | 'restart' | 'enable' | 'approve' | 'view_logs' | ''
14+
action?: 'login' | 'restart' | 'enable' | 'approve' | 'view_logs' | 'set_secret' | 'configure' | ''
1515
}
1616

1717
// Server types

frontend/src/types/contracts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export interface HealthStatus {
1313
admin_state: 'enabled' | 'disabled' | 'quarantined';
1414
summary: string;
1515
detail?: string;
16-
action?: 'login' | 'restart' | 'enable' | 'approve' | 'view_logs' | '';
16+
action?: 'login' | 'restart' | 'enable' | 'approve' | 'view_logs' | 'set_secret' | 'configure' | '';
1717
}
1818

1919
export interface Server {

frontend/src/views/Dashboard.vue

Lines changed: 14 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,5 @@
11
<template>
22
<div class="space-y-6">
3-
<!-- System Diagnostics -->
4-
<div
5-
v-if="totalDiagnosticsCount > 0"
6-
class="alert"
7-
:class="{
8-
'alert-error': upstreamErrors.length > 0,
9-
'alert-warning': upstreamErrors.length === 0 && (oauthRequired.length > 0 || missingSecrets.length > 0),
10-
'alert-info': upstreamErrors.length === 0 && oauthRequired.length === 0 && missingSecrets.length === 0
11-
}"
12-
>
13-
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
14-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
15-
</svg>
16-
<div class="flex-1">
17-
<h3 class="font-bold">System Diagnostics</h3>
18-
<div class="text-sm">
19-
<span class="badge badge-sm mr-2">{{ upstreamErrors.length }} Error{{ upstreamErrors.length !== 1 ? 's' : '' }}</span>
20-
<span v-if="upstreamErrors.length > 0">{{ upstreamErrors[0].server }}: {{ upstreamErrors[0].message }}</span>
21-
<span v-else-if="oauthRequired.length > 0">{{ oauthRequired.length }} server{{ oauthRequired.length !== 1 ? 's' : '' }} need authentication</span>
22-
<span v-else-if="missingSecrets.length > 0">{{ missingSecrets.length }} missing secret{{ missingSecrets.length !== 1 ? 's' : '' }}</span>
23-
</div>
24-
</div>
25-
<button class="btn btn-sm" @click="showDiagnosticsDetail = true">
26-
Fix
27-
</button>
28-
<button v-if="totalDiagnosticsCount > 1" class="btn btn-ghost btn-sm" @click="showDiagnosticsDetail = true">
29-
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
30-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
31-
</svg>
32-
</button>
33-
</div>
34-
353
<!-- Servers Needing Attention Banner (using unified health status) -->
364
<div
375
v-if="serversNeedingAttention.length > 0"
@@ -68,6 +36,20 @@
6836
>
6937
Enable
7038
</button>
39+
<router-link
40+
v-if="server.health?.action === 'set_secret'"
41+
to="/secrets"
42+
class="btn btn-xs btn-primary"
43+
>
44+
Set Secret
45+
</router-link>
46+
<router-link
47+
v-if="server.health?.action === 'configure'"
48+
:to="`/servers/${server.name}?tab=config`"
49+
class="btn btn-xs btn-primary"
50+
>
51+
Configure
52+
</router-link>
7153
</div>
7254
<div v-if="serversNeedingAttention.length > 3" class="text-xs opacity-60">
7355
... and {{ serversNeedingAttention.length - 3 }} more

internal/health/calculator.go

Lines changed: 103 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ type HealthCalculatorInput struct {
3030
HasRefreshToken bool // True if refresh token exists
3131
UserLoggedOut bool // True if user explicitly logged out
3232

33+
// Secret/config detection
34+
MissingSecret string // Secret name if unresolved (e.g., "GITHUB_TOKEN")
35+
OAuthConfigErr string // OAuth config error (e.g., "requires 'resource' parameter")
36+
3337
// Tool info
3438
ToolCount int
3539
}
@@ -75,7 +79,29 @@ func CalculateHealth(input HealthCalculatorInput, cfg *HealthCalculatorConfig) *
7579
}
7680
}
7781

78-
// 2. Connection state checks
82+
// 2. Missing secret check
83+
if input.MissingSecret != "" {
84+
return &contracts.HealthStatus{
85+
Level: LevelUnhealthy,
86+
AdminState: StateEnabled,
87+
Summary: "Missing secret",
88+
Detail: input.MissingSecret,
89+
Action: ActionSetSecret,
90+
}
91+
}
92+
93+
// 3. OAuth config error check
94+
if input.OAuthConfigErr != "" {
95+
return &contracts.HealthStatus{
96+
Level: LevelUnhealthy,
97+
AdminState: StateEnabled,
98+
Summary: "OAuth configuration error",
99+
Detail: input.OAuthConfigErr,
100+
Action: ActionConfigure,
101+
}
102+
}
103+
104+
// 4. Connection state checks
79105
switch input.State {
80106
case "error":
81107
return &contracts.HealthStatus{
@@ -106,7 +132,7 @@ func CalculateHealth(input HealthCalculatorInput, cfg *HealthCalculatorConfig) *
106132
}
107133
}
108134

109-
// 3. OAuth state checks (only for servers that require OAuth)
135+
// 5. OAuth state checks (only for servers that require OAuth)
110136
if input.OAuthRequired {
111137
// User explicitly logged out - needs re-authentication
112138
if input.UserLoggedOut {
@@ -177,7 +203,7 @@ func CalculateHealth(input HealthCalculatorInput, cfg *HealthCalculatorConfig) *
177203
}
178204
}
179205

180-
// 4. Healthy state - connected with valid authentication (if required)
206+
// 6. Healthy state - connected with valid authentication (if required)
181207
return &contracts.HealthStatus{
182208
Level: LevelHealthy,
183209
AdminState: StateEnabled,
@@ -289,3 +315,77 @@ func containsLower(s, substr string) bool {
289315
}
290316
return false
291317
}
318+
319+
// ExtractMissingSecret extracts the secret name from an error message if the error
320+
// indicates a missing secret reference (e.g., unresolved environment variable).
321+
// Returns the secret name or empty string if the error is not about missing secrets.
322+
func ExtractMissingSecret(lastError string) string {
323+
if lastError == "" {
324+
return ""
325+
}
326+
327+
// Pattern: "environment variable VARNAME not found or empty"
328+
const prefix = "environment variable "
329+
const suffix = " not found or empty"
330+
if idx := findSubstring(lastError, prefix); idx >= 0 {
331+
start := idx + len(prefix)
332+
if endIdx := findSubstring(lastError[start:], suffix); endIdx > 0 {
333+
return lastError[start : start+endIdx]
334+
}
335+
}
336+
337+
// Pattern: "${env:VARNAME}" unresolved
338+
const envPrefix = "${env:"
339+
if idx := findSubstring(lastError, envPrefix); idx >= 0 {
340+
start := idx + len(envPrefix)
341+
if endIdx := findChar(lastError[start:], '}'); endIdx > 0 {
342+
return lastError[start : start+endIdx]
343+
}
344+
}
345+
346+
return ""
347+
}
348+
349+
// ExtractOAuthConfigError extracts an OAuth configuration error from the error message.
350+
// Returns the config error description or empty string if not an OAuth config issue.
351+
func ExtractOAuthConfigError(lastError string) string {
352+
if lastError == "" {
353+
return ""
354+
}
355+
356+
// OAuth config issues typically mention "resource" parameter or config validation
357+
configPatterns := []string{
358+
"requires 'resource' parameter",
359+
"missing client_id",
360+
"oauth config validation failed",
361+
"invalid oauth configuration",
362+
}
363+
364+
for _, pattern := range configPatterns {
365+
if containsIgnoreCase(lastError, pattern) {
366+
return lastError
367+
}
368+
}
369+
370+
return ""
371+
}
372+
373+
// findSubstring returns the index of substr in s, or -1 if not found.
374+
func findSubstring(s, substr string) int {
375+
for i := 0; i <= len(s)-len(substr); i++ {
376+
if s[i:i+len(substr)] == substr {
377+
return i
378+
}
379+
}
380+
return -1
381+
}
382+
383+
// findChar returns the index of ch in s, or -1 if not found.
384+
func findChar(s string, ch byte) int {
385+
for i := 0; i < len(s); i++ {
386+
if s[i] == ch {
387+
return i
388+
}
389+
}
390+
return -1
391+
}

0 commit comments

Comments
 (0)