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.
- package/package.json +4 -4
- package/src/__tests__/mcp-registry-install-screen.test.tsx +732 -0
- package/src/__tests__/mcp-registry-install.test.ts +551 -0
- package/src/__tests__/mcp-registry-screen-debounce.test.tsx +195 -0
- package/src/__tests__/mcp-registry-screen.test.ts +209 -0
- package/src/services/mcp-registry.ts +150 -0
- package/src/ui/keymap.ts +1 -0
- package/src/ui/renderers/mcpRenderers.tsx +6 -1
- package/src/ui/screens/McpRegistryScreen.tsx +146 -26
|
@@ -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 {
|
|
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(
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
{
|
|
320
|
-
|
|
321
|
-
<text fg={theme.colors.
|
|
322
|
-
|
|
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
|
};
|