Skip to content

Commit 6b70c0f

Browse files
committed
feat(servers): add per-install display name override
Lets users distinguish multiple installs of the same MCP (e.g. two Google Calendars) with a friendly label that survives user-config sync, without changing server_id, alias, or tool prefixes. - Migration 018 + InstalledServer.display_name_override; display_name() precedence: override -> server_name -> server_id tail. - ServerAppService gains set_display_name_override and accepts an optional override on update_config and clone_server. New set_server_display_name Tauri command; save_server_inputs and clone_server extended. - Frontend resolveInstalledDisplayName helper used in every merge path; Configure modal exposes a Display name field and ServerActionMenu always shows Configure (Settings when no inputs); CloneAccountModal adds an optional Display name input. - mcpmux_list_servers reports the effective installed display name so agents see the user's label instead of the catalog name. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 6215633 commit 6b70c0f

22 files changed

Lines changed: 773 additions & 81 deletions

File tree

apps/desktop/src-tauri/src/commands/server.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ pub async fn set_server_oauth_connected(
127127
.map_err(|e| e.to_string())
128128
}
129129

130+
#[allow(clippy::too_many_arguments)]
130131
#[tauri::command]
131132
pub async fn save_server_inputs(
132133
app_service: State<'_, Arc<RwLock<Option<ServerAppService>>>>,
@@ -136,6 +137,7 @@ pub async fn save_server_inputs(
136137
env_overrides: Option<HashMap<String, String>>,
137138
args_append: Option<Vec<String>>,
138139
extra_headers: Option<HashMap<String, String>>,
140+
display_name_override: Option<String>,
139141
) -> Result<InstalledServer, String> {
140142
let service_lock = app_service.read().await;
141143
let service = service_lock
@@ -152,7 +154,32 @@ pub async fn save_server_inputs(
152154
env_overrides,
153155
args_append,
154156
extra_headers,
157+
display_name_override,
155158
)
156159
.await
157160
.map_err(|e| e.to_string())
158161
}
162+
163+
/// Set or clear the user-supplied display label on an installed server.
164+
///
165+
/// Empty/whitespace clears the override and the UI falls back to the cached
166+
/// definition name. Does not change `server_id`, alias, or tool prefixes.
167+
#[tauri::command]
168+
pub async fn set_server_display_name(
169+
app_service: State<'_, Arc<RwLock<Option<ServerAppService>>>>,
170+
id: String,
171+
space_id: String,
172+
display_name: Option<String>,
173+
) -> Result<InstalledServer, String> {
174+
let service_lock = app_service.read().await;
175+
let service = service_lock
176+
.as_ref()
177+
.ok_or("ServerAppService not initialized")?;
178+
179+
let space_uuid = uuid::Uuid::parse_str(&space_id).map_err(|e| e.to_string())?;
180+
181+
service
182+
.set_display_name_override(space_uuid, &id, display_name)
183+
.await
184+
.map_err(|e| e.to_string())
185+
}

apps/desktop/src-tauri/src/commands/server_clone.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,18 @@ use tauri::State;
77
use tokio::sync::RwLock;
88

99
/// Clone an installed server into a new suffixed manual-entry install in the same space.
10+
///
11+
/// `display_name` (optional) is stored as `display_name_override` so the user-supplied
12+
/// label survives later definition refreshes (e.g. user-config sync). When omitted, the
13+
/// auto `"Source (suffix)"` label on the cached definition is used as fallback.
1014
#[tauri::command]
1115
pub async fn clone_server(
1216
app_service: State<'_, Arc<RwLock<Option<ServerAppService>>>>,
1317
space_id: String,
1418
source_server_id: String,
1519
suffix: String,
1620
alias: Option<String>,
21+
display_name: Option<String>,
1722
) -> Result<InstalledServer, String> {
1823
let service_lock = app_service.read().await;
1924
let service = service_lock
@@ -23,7 +28,13 @@ pub async fn clone_server(
2328
let space_uuid = uuid::Uuid::parse_str(&space_id).map_err(|e| e.to_string())?;
2429

2530
service
26-
.clone_server(space_uuid, &source_server_id, &suffix, alias.as_deref())
31+
.clone_server(
32+
space_uuid,
33+
&source_server_id,
34+
&suffix,
35+
alias.as_deref(),
36+
display_name.as_deref(),
37+
)
2738
.await
2839
.map_err(|e| e.to_string())
2940
}

apps/desktop/src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -870,6 +870,7 @@ pub fn run() {
870870
commands::set_server_enabled,
871871
commands::set_server_oauth_connected,
872872
commands::save_server_inputs,
873+
commands::set_server_display_name,
873874
commands::clone_server,
874875
commands::is_clone_id_available,
875876
commands::suggest_clone_suffix,

apps/desktop/src/features/servers/CloneAccountModal.tsx

Lines changed: 69 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,19 @@ export function CloneAccountModal({
3535
onCloned,
3636
}: CloneAccountModalProps) {
3737
const [suffix, setSuffix] = useState('');
38+
const [displayName, setDisplayName] = useState('');
3839
const [isChecking, setIsChecking] = useState(false);
3940
const [isAvailable, setIsAvailable] = useState<boolean | null>(null);
4041
const [isSubmitting, setIsSubmitting] = useState(false);
4142
const [submitError, setSubmitError] = useState<string | null>(null);
4243
const [isLoadingSuggestion, setIsLoadingSuggestion] = useState(false);
4344

45+
const trimmedSuffix = suffix.trim();
46+
const trimmedDisplayName = displayName.trim();
47+
const displayNamePlaceholder = trimmedSuffix
48+
? `${sourceServer.name} (${trimmedSuffix})`
49+
: `${sourceServer.name} (work)`;
50+
4451
const previewId = deriveCloneServerId(sourceServer.id, suffix);
4552
const previewAlias = deriveCloneAlias(suffix);
4653
const hasSuffix = suffix.trim().length > 0;
@@ -131,34 +138,51 @@ export function CloneAccountModal({
131138
setSubmitError(null);
132139

133140
try {
134-
const cloned = await cloneServer(spaceId, sourceServer.id, suffix);
141+
const cloned = await cloneServer(
142+
spaceId,
143+
sourceServer.id,
144+
suffix,
145+
undefined,
146+
trimmedDisplayName.length > 0 ? trimmedDisplayName : undefined
147+
);
135148
onCloned(cloned);
136149
onClose();
137150
} catch (e) {
138151
setSubmitError(String(e));
139152
} finally {
140153
setIsSubmitting(false);
141154
}
142-
}, [hasSuffix, hasCollision, isChecking, spaceId, sourceServer.id, suffix, onCloned, onClose]);
155+
}, [
156+
hasSuffix,
157+
hasCollision,
158+
isChecking,
159+
spaceId,
160+
sourceServer.id,
161+
suffix,
162+
trimmedDisplayName,
163+
onCloned,
164+
onClose,
165+
]);
143166

144167
if (!open) {
145168
return null;
146169
}
147170

148-
const canSubmit = hasSuffix && !hasCollision && !isChecking && !isSubmitting && !isLoadingSuggestion;
171+
const canSubmit =
172+
hasSuffix && !hasCollision && !isChecking && !isSubmitting && !isLoadingSuggestion;
149173

150174
return (
151175
<div
152-
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4"
176+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
153177
data-testid="clone-account-modal-overlay"
154178
>
155179
<div
156-
className="dropdown-menu w-full max-w-md p-6 animate-in fade-in scale-in duration-150"
180+
className="dropdown-menu animate-in fade-in scale-in w-full max-w-md p-6 duration-150"
157181
data-testid="clone-account-modal"
158182
>
159-
<div className="flex items-start justify-between gap-3 mb-4">
183+
<div className="mb-4 flex items-start justify-between gap-3">
160184
<div className="flex items-center gap-3">
161-
<div className="p-2 rounded-lg bg-[rgb(var(--primary))]/10">
185+
<div className="rounded-lg bg-[rgb(var(--primary))]/10 p-2">
162186
<Copy className="h-5 w-5 text-[rgb(var(--primary))]" />
163187
</div>
164188
<div>
@@ -172,7 +196,7 @@ export function CloneAccountModal({
172196
</div>
173197
<button
174198
onClick={onClose}
175-
className="p-1 rounded hover:bg-[rgb(var(--surface-hover))] text-[rgb(var(--muted))] transition-colors"
199+
className="rounded p-1 text-[rgb(var(--muted))] transition-colors hover:bg-[rgb(var(--surface-hover))]"
176200
aria-label="Close"
177201
data-testid="clone-account-close-btn"
178202
>
@@ -181,14 +205,36 @@ export function CloneAccountModal({
181205
</div>
182206

183207
<div className="space-y-4">
208+
<div>
209+
<label
210+
htmlFor="clone-display-name"
211+
className="mb-1 block text-sm font-medium text-[rgb(var(--foreground))]"
212+
>
213+
Display name
214+
</label>
215+
<p className="mb-2 text-xs text-[rgb(var(--muted))]">
216+
Shown in My Servers only. Leave blank to use the default.
217+
</p>
218+
<input
219+
id="clone-display-name"
220+
type="text"
221+
value={displayName}
222+
onChange={(e) => setDisplayName(e.target.value)}
223+
placeholder={displayNamePlaceholder}
224+
className="input w-full"
225+
disabled={isSubmitting}
226+
data-testid="clone-display-name-input"
227+
/>
228+
</div>
229+
184230
<div>
185231
<label
186232
htmlFor="clone-suffix"
187-
className="block text-sm font-medium text-[rgb(var(--foreground))] mb-1"
233+
className="mb-1 block text-sm font-medium text-[rgb(var(--foreground))]"
188234
>
189235
Account label
190236
</label>
191-
<p className="text-xs text-[rgb(var(--muted))] mb-2">
237+
<p className="mb-2 text-xs text-[rgb(var(--muted))]">
192238
Used in the server ID and tool prefix (e.g. work, personal)
193239
</p>
194240
<input
@@ -202,21 +248,24 @@ export function CloneAccountModal({
202248
data-testid="clone-suffix-input"
203249
/>
204250
{hasCollision && (
205-
<p className="text-xs text-[rgb(var(--error))] mt-1" data-testid="clone-collision-error">
251+
<p
252+
className="mt-1 text-xs text-[rgb(var(--error))]"
253+
data-testid="clone-collision-error"
254+
>
206255
An account with this label already exists in this space
207256
</p>
208257
)}
209258
</div>
210259

211260
<div>
212-
<p className="text-xs font-medium text-[rgb(var(--muted))] mb-2">Suggestions</p>
261+
<p className="mb-2 text-xs font-medium text-[rgb(var(--muted))]">Suggestions</p>
213262
<div className="flex flex-wrap gap-2">
214263
{CLONE_SUFFIX_SUGGESTIONS.map((suggestion) => (
215264
<button
216265
key={suggestion}
217266
type="button"
218267
onClick={() => setSuffix(suggestion)}
219-
className={`px-2.5 py-1 text-xs rounded-md border transition-colors ${
268+
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
220269
suffix === suggestion
221270
? 'border-[rgb(var(--primary))] bg-[rgb(var(--primary))]/10 text-[rgb(var(--primary))]'
222271
: 'border-[rgb(var(--border))] text-[rgb(var(--muted))] hover:bg-[rgb(var(--surface-hover))]'
@@ -230,14 +279,16 @@ export function CloneAccountModal({
230279
</div>
231280

232281
{hasSuffix && (
233-
<div className="rounded-lg border border-[rgb(var(--border-subtle))] bg-[rgb(var(--surface-dim))] p-3 space-y-2">
282+
<div className="space-y-2 rounded-lg border border-[rgb(var(--border-subtle))] bg-[rgb(var(--surface-dim))] p-3">
234283
<div className="flex items-center justify-between gap-2 text-sm">
235284
<span className="text-[rgb(var(--muted))]">Server ID</span>
236-
<code className="text-xs font-mono text-[rgb(var(--foreground))]">{previewId || '—'}</code>
285+
<code className="font-mono text-xs text-[rgb(var(--foreground))]">
286+
{previewId || '—'}
287+
</code>
237288
</div>
238289
<div className="flex items-center justify-between gap-2 text-sm">
239290
<span className="text-[rgb(var(--muted))]">Tool prefix</span>
240-
<code className="text-xs font-mono text-[rgb(var(--foreground))]">
291+
<code className="font-mono text-xs text-[rgb(var(--foreground))]">
241292
{previewAlias ? `${previewAlias}_*` : '—'}
242293
</code>
243294
</div>
@@ -264,7 +315,7 @@ export function CloneAccountModal({
264315
<div className="flex justify-end gap-2 pt-2">
265316
<button
266317
onClick={onClose}
267-
className="px-4 py-2 text-sm rounded-lg border border-[rgb(var(--border))] text-[rgb(var(--muted))] hover:bg-[rgb(var(--surface-hover))] transition-colors"
318+
className="rounded-lg border border-[rgb(var(--border))] px-4 py-2 text-sm text-[rgb(var(--muted))] transition-colors hover:bg-[rgb(var(--surface-hover))]"
268319
disabled={isSubmitting}
269320
data-testid="clone-cancel-btn"
270321
>
@@ -273,7 +324,7 @@ export function CloneAccountModal({
273324
<button
274325
onClick={handleSubmit}
275326
disabled={!canSubmit}
276-
className="px-4 py-2 text-sm rounded-lg bg-[rgb(var(--primary))] text-[rgb(var(--primary-foreground))] hover:bg-[rgb(var(--primary-hover))] disabled:opacity-50 transition-colors flex items-center gap-2"
327+
className="flex items-center gap-2 rounded-lg bg-[rgb(var(--primary))] px-4 py-2 text-sm text-[rgb(var(--primary-foreground))] transition-colors hover:bg-[rgb(var(--primary-hover))] disabled:opacity-50"
277328
data-testid="clone-submit-btn"
278329
>
279330
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}

apps/desktop/src/features/servers/ServerActionMenu.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
export interface ServerActionMenuProps {
1111
serverId: string;
1212
serverName: string;
13+
/** Whether the server has credential / config inputs. Servers with no inputs still show
14+
* Configure so the display name can be edited. */
1315
hasInputs: boolean;
1416
isOAuth: boolean;
1517
isEnabled: boolean;
@@ -58,9 +60,11 @@ export function ServerActionMenu({
5860
</button>
5961
</DropdownMenuTrigger>
6062
<DropdownMenuContent align="end" className="w-48 py-1 p-1">
61-
{hasInputs && (
62-
<DropdownMenuAction icon={Settings} label="Configure" onSelect={onConfigure} />
63-
)}
63+
<DropdownMenuAction
64+
icon={Settings}
65+
label={hasInputs ? 'Configure' : 'Settings'}
66+
onSelect={onConfigure}
67+
/>
6468
{isEnabled && (
6569
<DropdownMenuAction icon={RefreshCw} label="Refresh" onSelect={onRefresh} />
6670
)}

0 commit comments

Comments
 (0)