mcp-config-manager 1.0.13 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,17 +3,284 @@ import {
3
3
  addServerApi, updateServerApi, deleteServerApi, copyServerApi,
4
4
  exportConfigApi, exportServerApi, importConfigApi, getClientConfigApi, renameServerApi, updateServerEnvApi, updateServerInClientsApi
5
5
  } from './api.js';
6
+ import { getIconPreference, setIconPreference, removeIconPreference } from './iconPreferences.js';
7
+ import { getServerLogo, getLogoByDomain, clearLogoCache, getInitials } from './logoService.js';
6
8
 
7
9
  let clients = []; // This will be passed from main.js
8
10
  let currentClient = null; // This will be passed from main.js
9
11
  let loadClientsCallback = null; // Callback to main.js to reload all clients
10
12
 
13
+ // Icon editor state
14
+ let currentIconType = 'auto';
15
+ let currentIconValue = null;
16
+ let selectedSearchDomain = null;
17
+
11
18
  export function initModals(clientData, currentClientData, loadClientsFn) {
12
19
  clients = clientData;
13
20
  currentClient = currentClientData;
14
21
  loadClientsCallback = loadClientsFn;
15
22
  }
16
23
 
24
+ // Popular services for icon search suggestions
25
+ const POPULAR_SERVICES = [
26
+ { domain: 'github.com', name: 'GitHub' },
27
+ { domain: 'gitlab.com', name: 'GitLab' },
28
+ { domain: 'slack.com', name: 'Slack' },
29
+ { domain: 'notion.so', name: 'Notion' },
30
+ { domain: 'linear.app', name: 'Linear' },
31
+ { domain: 'figma.com', name: 'Figma' },
32
+ { domain: 'stripe.com', name: 'Stripe' },
33
+ { domain: 'openai.com', name: 'OpenAI' },
34
+ { domain: 'anthropic.com', name: 'Anthropic' },
35
+ { domain: 'vercel.com', name: 'Vercel' },
36
+ { domain: 'supabase.com', name: 'Supabase' },
37
+ { domain: 'postgresql.org', name: 'PostgreSQL' },
38
+ ];
39
+
40
+ /**
41
+ * Initialize icon editor with current server's icon preference
42
+ * Now uses simplified inline UI with just icon preview + pencil edit button
43
+ */
44
+ async function initIconEditor(serverName, serverConfig) {
45
+ // Reset state
46
+ currentIconType = 'auto';
47
+ currentIconValue = null;
48
+ selectedSearchDomain = null;
49
+
50
+ // Load current preference
51
+ if (serverName) {
52
+ const pref = getIconPreference(serverName);
53
+ if (pref) {
54
+ currentIconType = pref.type;
55
+ currentIconValue = pref.value;
56
+ if (pref.type === 'logodev') {
57
+ selectedSearchDomain = pref.value;
58
+ }
59
+ }
60
+ }
61
+
62
+ // Update mini preview
63
+ await updateIconPreviewMini(serverName, serverConfig);
64
+
65
+ // Setup click handler for the edit trigger
66
+ const iconEditTrigger = document.getElementById('iconEditTrigger');
67
+ if (iconEditTrigger) {
68
+ iconEditTrigger.onclick = () => {
69
+ showIconSearchModal(serverName, serverConfig);
70
+ };
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Update the mini icon preview in the server name row
76
+ */
77
+ async function updateIconPreviewMini(serverName, serverConfig) {
78
+ const previewContainer = document.getElementById('iconPreviewMini');
79
+ if (!previewContainer) return;
80
+
81
+ const initials = getInitials(serverName || 'New');
82
+ let logoUrl = null;
83
+
84
+ if (currentIconType === 'none') {
85
+ // Show initials only
86
+ previewContainer.innerHTML = `<div class="server-logo-fallback">${initials}</div>`;
87
+ return;
88
+ }
89
+
90
+ if (currentIconType === 'url' && currentIconValue) {
91
+ logoUrl = currentIconValue;
92
+ } else if (currentIconType === 'logodev' && currentIconValue) {
93
+ logoUrl = getLogoByDomain(currentIconValue);
94
+ } else if (currentIconType === 'auto') {
95
+ // Use auto-detection (skip custom prefs since we're in edit mode)
96
+ if (serverName) {
97
+ logoUrl = await getServerLogo(serverName, serverConfig, true);
98
+ }
99
+ }
100
+
101
+ if (logoUrl) {
102
+ previewContainer.innerHTML = `
103
+ <img src="${logoUrl}" alt="${serverName || 'icon'}"
104
+ onerror="this.parentElement.innerHTML='<div class=\\'server-logo-fallback\\'>${initials}</div>'">
105
+ `;
106
+ } else {
107
+ previewContainer.innerHTML = `<div class="server-logo-fallback">${initials}</div>`;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Show icon search modal with options for auto, search, URL, and none
113
+ */
114
+ function showIconSearchModal(serverName, serverConfig) {
115
+ const modal = document.getElementById('iconSearchModal');
116
+ const searchInput = document.getElementById('iconSearchInput');
117
+ const resultsDiv = document.getElementById('iconSearchResults');
118
+ const suggestionsGrid = document.getElementById('suggestionsGrid');
119
+ const confirmBtn = document.getElementById('confirmIconSearch');
120
+ const cancelBtn = document.getElementById('cancelIconSearch');
121
+
122
+ // Track temporary selection state
123
+ let tempType = currentIconType;
124
+ let tempValue = currentIconValue;
125
+ let tempSelectedDomain = selectedSearchDomain;
126
+
127
+ // Reset search
128
+ searchInput.value = '';
129
+
130
+ // Build initial results with quick action buttons
131
+ function renderQuickActions() {
132
+ const autoSelected = tempType === 'auto';
133
+ const noneSelected = tempType === 'none';
134
+
135
+ let html = `
136
+ <div class="icon-quick-actions">
137
+ <button type="button" class="icon-quick-btn ${autoSelected ? 'selected' : ''}" data-action="auto">
138
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
139
+ <circle cx="12" cy="12" r="10"></circle>
140
+ <path d="M12 6v6l4 2"></path>
141
+ </svg>
142
+ Auto detect
143
+ </button>
144
+ <button type="button" class="icon-quick-btn ${noneSelected ? 'selected' : ''}" data-action="none">
145
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
146
+ <circle cx="12" cy="12" r="10"></circle>
147
+ <line x1="4.93" y1="4.93" x2="19.07" y2="19.07"></line>
148
+ </svg>
149
+ No icon
150
+ </button>
151
+ </div>
152
+ `;
153
+
154
+ // Show currently selected logo if any
155
+ if (tempType === 'logodev' && tempSelectedDomain) {
156
+ html += renderSearchResultHtml(tempSelectedDomain, true);
157
+ } else if (tempType === 'url' && tempValue) {
158
+ html += `
159
+ <div class="icon-search-result selected">
160
+ <img src="${tempValue}" alt="custom" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 40 40%22><rect fill=%22%23e2e8f0%22 width=%2240%22 height=%2240%22/><text x=%2250%25%22 y=%2250%25%22 dominant-baseline=%22middle%22 text-anchor=%22middle%22 fill=%22%2364748b%22 font-size=%2214%22>?</text></svg>'">
161
+ <div class="icon-search-result-info">
162
+ <div class="icon-search-result-domain">Custom URL</div>
163
+ <div class="icon-search-result-url">${tempValue}</div>
164
+ </div>
165
+ </div>
166
+ `;
167
+ } else if (!autoSelected && !noneSelected) {
168
+ html += '<div class="icon-search-hint">Search for a logo or enter a custom URL</div>';
169
+ }
170
+
171
+ return html;
172
+ }
173
+
174
+ function renderSearchResultHtml(domain, selected = false) {
175
+ const logoUrl = getLogoByDomain(domain, { size: 64 });
176
+ return `
177
+ <div class="icon-search-result ${selected ? 'selected' : ''}">
178
+ <img src="${logoUrl}" alt="${domain}" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 40 40%22><rect fill=%22%23e2e8f0%22 width=%2240%22 height=%2240%22/><text x=%2250%25%22 y=%2250%25%22 dominant-baseline=%22middle%22 text-anchor=%22middle%22 fill=%22%2364748b%22 font-size=%2214%22>?</text></svg>'">
179
+ <div class="icon-search-result-info">
180
+ <div class="icon-search-result-domain">${domain}</div>
181
+ <div class="icon-search-result-url">Logo from logo.dev</div>
182
+ </div>
183
+ </div>
184
+ `;
185
+ }
186
+
187
+ resultsDiv.innerHTML = renderQuickActions();
188
+ confirmBtn.disabled = false; // Always allow confirm since auto is valid
189
+
190
+ // Quick action button handlers
191
+ resultsDiv.addEventListener('click', (e) => {
192
+ const btn = e.target.closest('.icon-quick-btn');
193
+ if (btn) {
194
+ const action = btn.dataset.action;
195
+ if (action === 'auto') {
196
+ tempType = 'auto';
197
+ tempValue = null;
198
+ tempSelectedDomain = null;
199
+ } else if (action === 'none') {
200
+ tempType = 'none';
201
+ tempValue = null;
202
+ tempSelectedDomain = null;
203
+ }
204
+ resultsDiv.innerHTML = renderQuickActions();
205
+ }
206
+ });
207
+
208
+ // Populate suggestions
209
+ suggestionsGrid.innerHTML = '';
210
+ POPULAR_SERVICES.forEach(service => {
211
+ const chip = document.createElement('div');
212
+ chip.className = 'suggestion-chip';
213
+ chip.innerHTML = `
214
+ <img src="${getLogoByDomain(service.domain, { size: 32 })}" alt="${service.name}" onerror="this.style.display='none'">
215
+ <span>${service.name}</span>
216
+ `;
217
+ chip.onclick = () => {
218
+ tempType = 'logodev';
219
+ tempSelectedDomain = service.domain;
220
+ tempValue = service.domain;
221
+ resultsDiv.innerHTML = renderQuickActions();
222
+ };
223
+ suggestionsGrid.appendChild(chip);
224
+ });
225
+
226
+ // Search input handler - supports domain search and custom URL
227
+ searchInput.onkeydown = (e) => {
228
+ if (e.key === 'Enter') {
229
+ e.preventDefault();
230
+ const query = searchInput.value.trim();
231
+ if (query) {
232
+ // Check if it's a full URL
233
+ if (query.startsWith('http://') || query.startsWith('https://')) {
234
+ tempType = 'url';
235
+ tempValue = query;
236
+ tempSelectedDomain = null;
237
+ } else {
238
+ // Treat as domain
239
+ let domain = query.toLowerCase();
240
+ if (!domain.includes('.')) {
241
+ domain = `${domain}.com`;
242
+ }
243
+ tempType = 'logodev';
244
+ tempSelectedDomain = domain;
245
+ tempValue = domain;
246
+ }
247
+ resultsDiv.innerHTML = renderQuickActions();
248
+ }
249
+ }
250
+ };
251
+
252
+ // Confirm selection
253
+ confirmBtn.onclick = () => {
254
+ currentIconType = tempType;
255
+ currentIconValue = tempValue;
256
+ selectedSearchDomain = tempSelectedDomain;
257
+ modal.style.display = 'none';
258
+ updateIconPreviewMini(serverName, serverConfig);
259
+ };
260
+
261
+ // Cancel
262
+ cancelBtn.onclick = () => {
263
+ modal.style.display = 'none';
264
+ };
265
+
266
+ modal.style.display = 'flex';
267
+ searchInput.focus();
268
+ }
269
+
270
+
271
+ /**
272
+ * Save icon preference for a server (called after server is saved)
273
+ */
274
+ function saveIconPreference(serverName) {
275
+ if (currentIconType === 'auto') {
276
+ removeIconPreference(serverName);
277
+ } else {
278
+ setIconPreference(serverName, currentIconType, currentIconValue);
279
+ }
280
+ // Clear cache so new preference takes effect
281
+ clearLogoCache(serverName);
282
+ }
283
+
17
284
  export function showServerModal(serverName = null, serverConfig = null, loadClientServers, renderKanbanBoard, clientId = null, loadClientsFn) {
18
285
  const modal = document.getElementById('serverModal');
19
286
  const title = document.getElementById('modalTitle');
@@ -45,13 +312,11 @@ export function showServerModal(serverName = null, serverConfig = null, loadClie
45
312
 
46
313
  // Set JSON editor content
47
314
  if (!serverName && !serverConfig) {
48
- // For new servers, show the expected format
315
+ // For new servers, show the expected config format (server name comes from form field)
49
316
  document.getElementById('jsonEditor').value = JSON.stringify({
50
- "new-server": {
51
- "command": "npx",
52
- "args": ["-y", "@example/mcp-server"],
53
- "env": {}
54
- }
317
+ "command": "npx",
318
+ "args": ["-y", "@example/mcp-server"],
319
+ "env": {}
55
320
  }, null, 2);
56
321
  } else {
57
322
  document.getElementById('jsonEditor').value = JSON.stringify(serverConfig || {}, null, 2);
@@ -94,6 +359,9 @@ export function showServerModal(serverName = null, serverConfig = null, loadClie
94
359
  btn.onclick = () => switchTab(btn.dataset.tab);
95
360
  });
96
361
 
362
+ // Initialize icon editor
363
+ initIconEditor(serverName, serverConfig);
364
+
97
365
  modal.style.display = 'flex';
98
366
  document.getElementById('serverName').focus();
99
367
 
@@ -128,28 +396,35 @@ async function saveServer(originalName, loadClientServers, renderKanbanBoard, lo
128
396
  try {
129
397
  const jsonData = JSON.parse(document.getElementById('jsonEditor').value);
130
398
 
399
+ // Server name always comes from the form field
400
+ newServerName = document.getElementById('serverName').value;
401
+ if (!newServerName) {
402
+ alert('Server name cannot be empty. Please enter a name in the Server Name field.');
403
+ return;
404
+ }
405
+
131
406
  // Check if this is a new server (originalName is null)
132
407
  if (!originalName) {
133
- // For new servers in JSON mode, expect format: { "serverName": { config } }
408
+ // For new servers, JSON should be just the config (not wrapped)
409
+ // But also support legacy wrapped format { "serverName": { config } } for backwards compatibility
134
410
  const keys = Object.keys(jsonData);
135
411
  if (keys.length === 0) {
136
- alert('Server configuration cannot be empty. Use format: { "serverName": { "command": "...", "args": [...] } }');
412
+ alert('Server configuration cannot be empty.');
137
413
  return;
138
414
  }
139
- if (keys.length > 1) {
140
- alert('Only one server can be added at a time. Use format: { "serverName": { "command": "...", "args": [...] } }');
141
- return;
415
+
416
+ // Check if this looks like a wrapped format (single key with object value containing command/args/env)
417
+ if (keys.length === 1 && typeof jsonData[keys[0]] === 'object' &&
418
+ (jsonData[keys[0]].command || jsonData[keys[0]].args || jsonData[keys[0]].env || jsonData[keys[0]].type)) {
419
+ // Legacy wrapped format - use the inner config
420
+ serverConfig = jsonData[keys[0]];
421
+ } else {
422
+ // New unwrapped format - use JSON directly as config
423
+ serverConfig = jsonData;
142
424
  }
143
- newServerName = keys[0];
144
- serverConfig = jsonData[newServerName];
145
425
  } else {
146
- // For editing existing servers, use the name from the input field
426
+ // For editing existing servers, use the JSON directly as config
147
427
  serverConfig = jsonData;
148
- newServerName = document.getElementById('serverName').value;
149
- if (!newServerName) {
150
- alert('Server name cannot be empty.');
151
- return;
152
- }
153
428
  }
154
429
  } catch (e) {
155
430
  alert('Invalid JSON format');
@@ -231,6 +506,9 @@ async function saveServer(originalName, loadClientServers, renderKanbanBoard, lo
231
506
  if (!renameResponse.success) {
232
507
  throw new Error('Failed to rename server');
233
508
  }
509
+ // Migrate icon preference if server was renamed
510
+ const { migrateIconPreference } = await import('./iconPreferences.js');
511
+ migrateIconPreference(originalName, newServerName);
234
512
  } catch (error) {
235
513
  alert('Failed to rename server: ' + error.message);
236
514
  return;
@@ -253,6 +531,9 @@ async function saveServer(originalName, loadClientServers, renderKanbanBoard, lo
253
531
  throw new Error(`Failed to save server`);
254
532
  }
255
533
 
534
+ // Save icon preference
535
+ saveIconPreference(serverToSaveName);
536
+
256
537
  document.getElementById('serverModal').style.display = 'none';
257
538
  await loadClientServers();
258
539
  if (renderKanbanBoard) {
@@ -504,10 +785,7 @@ export async function deleteSelected(loadClientServers, renderKanbanBoard, loadC
504
785
  }
505
786
 
506
787
  export async function removeFromAll(serverName, loadClients, loadClientServers, loadClientsFn) {
507
- if (!confirm(`Are you sure you want to remove "${serverName}" from ALL clients?`)) {
508
- return;
509
- }
510
-
788
+ // Note: Confirmation should be handled by the caller before calling this function
511
789
  let removedCount = 0;
512
790
  const errors = [];
513
791
 
@@ -533,11 +811,16 @@ export async function removeFromAll(serverName, loadClients, loadClientServers,
533
811
 
534
812
  if (errors.length > 0) {
535
813
  alert(`Removed "${serverName}" from ${removedCount} client(s).\nFailed for: ${errors.join(', ')}`);
814
+ } else if (removedCount === 0) {
815
+ alert(`Server "${serverName}" was not found in any clients.`);
536
816
  } else {
817
+ alert(`Successfully removed "${serverName}" from ${removedCount} client(s).`);
537
818
  }
538
819
 
539
820
  loadClientsFn();
540
- await loadClientServers();
821
+ if (loadClientServers) {
822
+ await loadClientServers();
823
+ }
541
824
  }
542
825
 
543
826
  export const showAddServerToClientsModal = (serverName, serverConfig) => {
@@ -754,6 +1037,43 @@ export const showCopySingleEnvVarModal = async (serverName, envKey, envValue, so
754
1037
  };
755
1038
 
756
1039
 
1040
+ export function showSelectClientModal(onClientSelected, loadClientsFn) {
1041
+ const modal = document.getElementById('selectClientModal');
1042
+ const selectClientList = document.getElementById('selectClientList');
1043
+
1044
+ // Populate client list as radio buttons (single selection)
1045
+ selectClientList.innerHTML = '';
1046
+ clients.forEach((client, index) => {
1047
+ const item = document.createElement('div');
1048
+ item.className = 'radio-item';
1049
+ item.innerHTML = `
1050
+ <input type="radio" name="selectedClient" id="selectClient-${client.id}" value="${client.id}" ${index === 0 ? 'checked' : ''}>
1051
+ <label for="selectClient-${client.id}">${client.name}</label>
1052
+ `;
1053
+ selectClientList.appendChild(item);
1054
+ });
1055
+
1056
+ modal.style.display = 'flex';
1057
+
1058
+ const form = document.getElementById('selectClientForm');
1059
+ form.onsubmit = (e) => {
1060
+ e.preventDefault();
1061
+ const selectedRadio = document.querySelector('input[name="selectedClient"]:checked');
1062
+ if (!selectedRadio) {
1063
+ alert('Please select a client.');
1064
+ return;
1065
+ }
1066
+ const selectedClientId = selectedRadio.value;
1067
+ modal.style.display = 'none';
1068
+ onClientSelected(selectedClientId);
1069
+ };
1070
+
1071
+ // Setup cancel button
1072
+ document.getElementById('cancelSelectClient').onclick = () => {
1073
+ modal.style.display = 'none';
1074
+ };
1075
+ }
1076
+
757
1077
  export function showRemoteServerModal(loadClientServers, renderKanbanBoard, clientId = null, loadClientsFn) {
758
1078
  const modal = document.getElementById('remoteServerModal');
759
1079