magus-cli 7.11.5 → 7.11.6

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.
@@ -15,9 +15,17 @@ import {
15
15
  addMcpServer,
16
16
  getEnabledMcpServers,
17
17
  getInstalledMcpServers,
18
+ removeMcpServer,
18
19
  setAllowMcp,
19
20
  } from "../../services/claude-settings.js";
20
- import { formatDate, searchMcpServers } from "../../services/mcp-registry.js";
21
+ import {
22
+ createLatestOnlyGuard,
23
+ formatDate,
24
+ isOfficialMcpServer,
25
+ officialFirst,
26
+ registryInstallPlan,
27
+ searchMcpServers,
28
+ } from "../../services/mcp-registry.js";
21
29
  import type { McpRegistryServer, McpServerConfig } from "../../types/index.js";
22
30
  import { ScrollableList } from "../components/ScrollableList.js";
23
31
  import {
@@ -131,24 +139,52 @@ export function McpRegistryScreen() {
131
139
  void loadInstalled();
132
140
  }, [loadInstalled]);
133
141
 
142
+ // Overlapping loads (first load, debounce, R) can resolve out of order: only
143
+ // the newest request's response may touch state. Unmounting takes a token too,
144
+ // so a response that lands afterwards is dropped.
145
+ const guardRef = useRef(createLatestOnlyGuard());
146
+ // The name under the cursor, so a replaced list keeps it on the same server.
147
+ const selectedNameRef = useRef<string | undefined>(undefined);
148
+ selectedNameRef.current = servers[mcpRegistry.selectedIndex]?.name;
149
+ // The query the list on screen was loaded with: the cursor follows a name
150
+ // only across a reload of the same query, never into a different result set.
151
+ const loadedQueryRef = useRef<string | undefined>(undefined);
152
+ // The same query as state, for rendering: badges follow the list on screen,
153
+ // not the text being typed.
154
+ const [loadedQuery, setLoadedQuery] = useState("");
155
+
134
156
  // Load servers
135
- const loadServers = useCallback(async (query: string) => {
136
- setIsLoading(true);
137
- setError(null);
138
- try {
139
- const response = await searchMcpServers({ query, limit: 50 });
140
- if (!response || !Array.isArray(response.servers)) {
141
- throw new Error("Invalid response from MCP Registry API");
157
+ const loadServers = useCallback(
158
+ async (query: string) => {
159
+ const guard = guardRef.current;
160
+ const token = guard.next();
161
+ setIsLoading(true);
162
+ setError(null);
163
+ try {
164
+ const response = await searchMcpServers({ query, limit: 50 });
165
+ if (!response || !Array.isArray(response.servers)) {
166
+ throw new Error("Invalid response from MCP Registry API");
167
+ }
168
+ if (!guard.isLatest(token)) return;
169
+ // Deduplicate to show only latest version of each server, official first
170
+ const next = officialFirst(deduplicateServers(response.servers), query);
171
+ const sameQuery = loadedQueryRef.current === query;
172
+ const keptAt = sameQuery
173
+ ? next.findIndex((server) => server.name === selectedNameRef.current)
174
+ : -1;
175
+ loadedQueryRef.current = query;
176
+ setLoadedQuery(query);
177
+ setServers(next);
178
+ dispatch({ type: "MCPREGISTRY_SELECT", index: Math.max(0, keptAt) });
179
+ } catch (err) {
180
+ if (!guard.isLatest(token)) return;
181
+ setError(err instanceof Error ? err.message : "Failed to load servers");
182
+ setServers([]);
142
183
  }
143
- // Deduplicate to show only latest version of each server
144
- const deduplicated = deduplicateServers(response.servers);
145
- setServers(deduplicated);
146
- } catch (err) {
147
- setError(err instanceof Error ? err.message : "Failed to load servers");
148
- setServers([]);
149
- }
150
- setIsLoading(false);
151
- }, []);
184
+ if (guard.isLatest(token)) setIsLoading(false);
185
+ },
186
+ [dispatch],
187
+ );
152
188
 
153
189
  // Debounced search
154
190
  const debouncedSearch = useCallback(
@@ -168,7 +204,9 @@ export function McpRegistryScreen() {
168
204
  const initialQueryRef = useRef(searchQuery);
169
205
  useEffect(() => {
170
206
  loadServers(initialQueryRef.current);
207
+ const guard = guardRef.current;
171
208
  return () => {
209
+ guard.next();
172
210
  if (searchTimeoutRef.current) {
173
211
  clearTimeout(searchTimeoutRef.current);
174
212
  }
@@ -179,6 +217,12 @@ export function McpRegistryScreen() {
179
217
 
180
218
  /** A new query: the list reloads, debounced, and the cursor starts over. */
181
219
  const setQuery = (query: string) => {
220
+ // The shown list is stale from this keystroke: drop any response still in
221
+ // flight and mark the list pending until the scheduled load settles, so
222
+ // install/remove cannot act on the old rows. The scheduled loadServers
223
+ // always runs (or the screen unmounts) and clears this in every outcome.
224
+ guardRef.current.next();
225
+ setIsLoading(true);
182
226
  setSearchQuery(query);
183
227
  dispatch({ type: "MCPREGISTRY_SEARCH", query });
184
228
  dispatch({ type: "MCPREGISTRY_SELECT", index: 0 });
@@ -201,6 +245,9 @@ export function McpRegistryScreen() {
201
245
  },
202
246
  search: () => dispatch({ type: "SET_SEARCHING", isSearching: true }),
203
247
  local: () => navigateToScreen("mcp"),
248
+ remove: () => {
249
+ void handleRemove();
250
+ },
204
251
  reload: () => {
205
252
  void loadServers(searchQuery);
206
253
  },
@@ -219,14 +266,53 @@ export function McpRegistryScreen() {
219
266
  },
220
267
  });
221
268
 
269
+ const handleRemove = async () => {
270
+ if (isLoading) return;
271
+ const server = servers[mcpRegistry.selectedIndex];
272
+ if (!server || !installedNames.has(server.name)) return;
273
+
274
+ const confirmed = await modal.confirm(
275
+ `Remove ${server.name}?`,
276
+ "This will remove the MCP server configuration.",
277
+ );
278
+ if (!confirmed) return;
279
+
280
+ modal.loading(`Removing ${server.name}...`);
281
+ try {
282
+ await removeMcpServer(server.name, state.projectPath);
283
+ modal.hideModal();
284
+ dispatch({ type: "MCP_SERVER_CHANGED", name: server.name });
285
+ await loadInstalled();
286
+ } catch (error) {
287
+ modal.hideModal();
288
+ await showFailures(
289
+ stepFailureReport(
290
+ `Could not remove ${server.name}`,
291
+ server.name,
292
+ error,
293
+ ),
294
+ );
295
+ }
296
+ };
297
+
222
298
  const handleInstall = async () => {
299
+ // While loading, the visible panel is "Loading..." and `servers` is the
300
+ // previous, hidden list: installing from it would add a row nobody sees.
301
+ if (isLoading) return;
223
302
  const server = servers[mcpRegistry.selectedIndex];
224
303
  if (!server) return;
225
304
 
226
- const config: McpServerConfig = {
227
- type: "http",
228
- url: server.url,
229
- };
305
+ const plan = registryInstallPlan(server);
306
+ if (!plan.ok) {
307
+ // Nothing is written: the popup comes before the modal and the writes.
308
+ await showFailures({
309
+ headline: `${server.name} wasn't installed`,
310
+ rows: [{ subject: server.name, reason: plan.reason }],
311
+ fix: null,
312
+ });
313
+ return;
314
+ }
315
+ const config: McpServerConfig = plan.config;
230
316
 
231
317
  modal.loading(`Installing ${server.name}...`);
232
318
  try {
@@ -250,6 +336,7 @@ export function McpRegistryScreen() {
250
336
  server,
251
337
  isInstalled: installedNames.has(server.name),
252
338
  needsRestart: state.mcp.changedThisSession.includes(server.name),
339
+ isOfficial: isOfficialMcpServer(server, loadedQuery),
253
340
  }));
254
341
 
255
342
  // Get selected server
@@ -279,6 +366,9 @@ export function McpRegistryScreen() {
279
366
  ? `v${selectedServer.version}`
280
367
  : "unknown";
281
368
 
369
+ const plan = registryInstallPlan(selectedServer);
370
+ const packageRuns = plan.ok && plan.kind !== "http" ? plan.config : null;
371
+
282
372
  return (
283
373
  <box flexDirection="column">
284
374
  <text fg={theme.colors.accent}>
@@ -288,6 +378,30 @@ export function McpRegistryScreen() {
288
378
  <text fg={theme.colors.text}>{selectedServer.short_description}</text>
289
379
  </box>
290
380
  <box marginTop={1}>{renderMcpRegistryStatus(selectedItem)}</box>
381
+ {selectedItem.isOfficial ? (
382
+ <box flexDirection="row">
383
+ <text fg={theme.colors.muted}>{"Publisher "}</text>
384
+ <text fg={theme.colors.success}>{"official"}</text>
385
+ </box>
386
+ ) : null}
387
+ {packageRuns ? (
388
+ <box flexDirection="column" marginTop={1}>
389
+ <box flexDirection="row">
390
+ <text fg={theme.colors.muted}>{"Runs "}</text>
391
+ <text fg={theme.colors.text}>
392
+ {[packageRuns.command, ...(packageRuns.args ?? [])].join(" ")}
393
+ </text>
394
+ </box>
395
+ <text fg={theme.colors.warning}>
396
+ It may need API keys or settings to work. magus doesn't add any.
397
+ </text>
398
+ {selectedServer.source_code_url ? (
399
+ <text fg={theme.colors.muted}>
400
+ The source below says what to set.
401
+ </text>
402
+ ) : null}
403
+ </box>
404
+ ) : null}
291
405
  <box flexDirection="row" marginTop={1}>
292
406
  <text fg={theme.colors.text}>
293
407
  <strong>Version: </strong>
@@ -316,11 +430,17 @@ export function McpRegistryScreen() {
316
430
  </text>
317
431
  </box>
318
432
  )}
319
- {selectedItem.isInstalled ? null : (
320
- <box marginTop={1}>
321
- <text fg={theme.colors.muted}>Press Enter to install</text>
322
- </box>
323
- )}
433
+ <box marginTop={1}>
434
+ {!selectedItem.isInstalled && !plan.ok ? (
435
+ <text fg={theme.colors.warning}>{plan.reason}</text>
436
+ ) : (
437
+ <text fg={theme.colors.muted}>
438
+ {selectedItem.isInstalled
439
+ ? "Press d to remove"
440
+ : "Press Enter to install"}
441
+ </text>
442
+ )}
443
+ </box>
324
444
  </box>
325
445
  );
326
446
  };