magus-cli 7.11.5 → 7.11.7
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__/marketplace-refresh.test.ts +113 -0
- 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/__tests__/spawn-bounded.test.ts +211 -0
- package/src/services/marketplace-catalog-git.ts +17 -26
- package/src/services/marketplace-refresh.ts +63 -43
- 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
- package/src/utils/spawn-bounded.ts +202 -0
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
* A failed `git pull --ff-only` leaves the clone exactly as it was: stale, but intact.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { spawn } from "node:child_process";
|
|
21
20
|
import fs from "node:fs";
|
|
22
21
|
import path from "node:path";
|
|
22
|
+
import { runBounded } from "../utils/spawn-bounded.js";
|
|
23
23
|
import {
|
|
24
24
|
getConfiguredMarketplaces,
|
|
25
25
|
getGlobalClaudeDir,
|
|
@@ -29,6 +29,9 @@ import {
|
|
|
29
29
|
/** Per-git-invocation timeout. A hung pull must never block launching Claude. */
|
|
30
30
|
const GIT_TIMEOUT_MS = 30_000;
|
|
31
31
|
|
|
32
|
+
/** Hard cap per clone, whatever hangs: `git status` then `git pull`, each GIT_TIMEOUT_MS, plus slack. */
|
|
33
|
+
const CLONE_DEADLINE_MS = 2 * GIT_TIMEOUT_MS + 5_000;
|
|
34
|
+
|
|
32
35
|
export interface RefreshResult {
|
|
33
36
|
/** Clones fast-forwarded to a newer commit (or already current). */
|
|
34
37
|
refreshed: string[];
|
|
@@ -42,46 +45,36 @@ function cloneDir(name: string): string {
|
|
|
42
45
|
return path.join(getGlobalClaudeDir(), "plugins", "marketplaces", name);
|
|
43
46
|
}
|
|
44
47
|
|
|
45
|
-
/**
|
|
46
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Run git in `cwd`, resolving stdout on exit 0. Killed after `timeoutMs`, or as
|
|
50
|
+
* soon as `signal` aborts (an already-aborted signal runs nothing). Throws on
|
|
51
|
+
* anything but exit 0.
|
|
52
|
+
*/
|
|
53
|
+
async function runGit(
|
|
47
54
|
cwd: string,
|
|
48
55
|
args: string[],
|
|
49
56
|
timeoutMs: number,
|
|
57
|
+
signal?: AbortSignal,
|
|
50
58
|
): Promise<string> {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
stdout += b.toString();
|
|
61
|
-
});
|
|
62
|
-
proc.stderr?.on("data", (b) => {
|
|
63
|
-
stderr += b.toString();
|
|
64
|
-
});
|
|
65
|
-
proc.on("error", reject);
|
|
66
|
-
proc.on("close", (code, signal) => {
|
|
67
|
-
if (code === 0) {
|
|
68
|
-
resolve(stdout);
|
|
69
|
-
} else {
|
|
70
|
-
reject(
|
|
71
|
-
new Error(
|
|
72
|
-
`git ${args.join(" ")} ${
|
|
73
|
-
signal ? `killed (${signal})` : `exited ${code}`
|
|
74
|
-
}: ${stderr.trim()}`,
|
|
75
|
-
),
|
|
76
|
-
);
|
|
77
|
-
}
|
|
78
|
-
});
|
|
79
|
-
});
|
|
59
|
+
const r = await runBounded("git", args, { cwd, timeoutMs, signal });
|
|
60
|
+
if (r.spawnError) throw r.spawnError;
|
|
61
|
+
if (r.code === 0 && !r.aborted) return r.stdout;
|
|
62
|
+
const how = r.aborted
|
|
63
|
+
? "aborted"
|
|
64
|
+
: r.signal
|
|
65
|
+
? `killed (${r.signal})`
|
|
66
|
+
: `exited ${r.code}`;
|
|
67
|
+
throw new Error(`git ${args.join(" ")} ${how}: ${r.stderr.trim()}`);
|
|
80
68
|
}
|
|
81
69
|
|
|
82
70
|
/** True if the clone has uncommitted changes we must not clobber. */
|
|
83
|
-
async function isDirty(dir: string): Promise<boolean> {
|
|
84
|
-
const out = await runGit(
|
|
71
|
+
async function isDirty(dir: string, signal?: AbortSignal): Promise<boolean> {
|
|
72
|
+
const out = await runGit(
|
|
73
|
+
dir,
|
|
74
|
+
["status", "--porcelain"],
|
|
75
|
+
GIT_TIMEOUT_MS,
|
|
76
|
+
signal,
|
|
77
|
+
);
|
|
85
78
|
return out.trim().length > 0;
|
|
86
79
|
}
|
|
87
80
|
|
|
@@ -99,23 +92,50 @@ export type CloneRefresh = "absent" | "skipped" | "refreshed" | "failed";
|
|
|
99
92
|
* deletes: the worst case is `failed` (stale clone left intact). Shared by the
|
|
100
93
|
* batch refresh below and by claude-cli's "plugin not found" recovery, so both
|
|
101
94
|
* paths get the same safe semantics instead of the old `marketplace update`.
|
|
95
|
+
*
|
|
96
|
+
* Bounded by `deadlineMs`: when it passes, the result is `failed` and the git
|
|
97
|
+
* command then in flight (`status` or `pull`) is SIGKILLed; no further git
|
|
98
|
+
* command starts. Only that direct child is killed: a `git fetch` or `ssh` it
|
|
99
|
+
* spawned may linger until it exits on its own, holding nothing we wait on.
|
|
100
|
+
* The clone directory is never deleted, but a pull killed while it updates the
|
|
101
|
+
* working tree can leave that update part-way and a `.git/index.lock` behind;
|
|
102
|
+
* later pulls then return `failed` until the lock is removed. The deadline sits
|
|
103
|
+
* well above a healthy pull, so that is the price of never hanging, not a
|
|
104
|
+
* normal outcome.
|
|
102
105
|
*/
|
|
103
106
|
export async function fastForwardCloneIfPresent(
|
|
104
107
|
name: string,
|
|
108
|
+
deadlineMs: number = CLONE_DEADLINE_MS,
|
|
105
109
|
): Promise<CloneRefresh> {
|
|
106
110
|
const dir = cloneDir(name);
|
|
107
111
|
// No clone on disk (claude never installed it) or not a git repo.
|
|
108
112
|
if (!fs.existsSync(path.join(dir, ".git"))) return "absent";
|
|
113
|
+
const abort = new AbortController();
|
|
114
|
+
const work = (async (): Promise<CloneRefresh> => {
|
|
115
|
+
try {
|
|
116
|
+
// Never clobber a user's local edits to the clone.
|
|
117
|
+
if (await isDirty(dir, abort.signal)) return "skipped";
|
|
118
|
+
// --ff-only is the safety guarantee: it fetches and fast-forwards, and
|
|
119
|
+
// aborts without mutating the tree if it can't. It never deletes the dir,
|
|
120
|
+
// so a failure degrades to "stale clone", never "deleted marketplace".
|
|
121
|
+
await runGit(dir, ["pull", "--ff-only"], GIT_TIMEOUT_MS, abort.signal);
|
|
122
|
+
return "refreshed";
|
|
123
|
+
} catch {
|
|
124
|
+
return "failed";
|
|
125
|
+
}
|
|
126
|
+
})();
|
|
127
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
128
|
+
const deadline = new Promise<CloneRefresh>((resolve) => {
|
|
129
|
+
timer = setTimeout(() => {
|
|
130
|
+
// Kill the in-flight git child rather than leave it running.
|
|
131
|
+
abort.abort();
|
|
132
|
+
resolve("failed");
|
|
133
|
+
}, deadlineMs);
|
|
134
|
+
});
|
|
109
135
|
try {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
// aborts without mutating the tree if it can't. It never deletes the dir,
|
|
114
|
-
// so a failure degrades to "stale clone", never "deleted marketplace".
|
|
115
|
-
await runGit(dir, ["pull", "--ff-only"], GIT_TIMEOUT_MS);
|
|
116
|
-
return "refreshed";
|
|
117
|
-
} catch {
|
|
118
|
-
return "failed";
|
|
136
|
+
return await Promise.race([work, deadline]);
|
|
137
|
+
} finally {
|
|
138
|
+
clearTimeout(timer);
|
|
119
139
|
}
|
|
120
140
|
}
|
|
121
141
|
|
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
McpRegistryResponse,
|
|
3
3
|
McpRegistryServer,
|
|
4
4
|
McpServer,
|
|
5
|
+
McpServerConfig,
|
|
5
6
|
} from "../types/index.js";
|
|
6
7
|
|
|
7
8
|
const DEFAULT_MCP_REGISTRY_API =
|
|
@@ -155,6 +156,155 @@ export async function fetchCuratedMcpServers(
|
|
|
155
156
|
return servers;
|
|
156
157
|
}
|
|
157
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Whether the server is run by the owner of the domain its name claims.
|
|
161
|
+
* The name is `<reverse-dns namespace>/<slug>`; the server is official when the
|
|
162
|
+
* namespace labels are a label-boundary prefix of the URL host's reversed labels
|
|
163
|
+
* (`com.notion` for `mcp.notion.com`). Code-host namespaces (`io.github.*`) name
|
|
164
|
+
* an account, not an owner, and npm:/pypi:/unparsable sources have no host.
|
|
165
|
+
* It is official FOR A SEARCH: the brand (the namespace's second label, `notion`
|
|
166
|
+
* in `com.notion`) must equal, as a whole word, one of the search words.
|
|
167
|
+
*/
|
|
168
|
+
export function isOfficialMcpServer(
|
|
169
|
+
server: Pick<McpRegistryServer, "name" | "url">,
|
|
170
|
+
query: string,
|
|
171
|
+
): boolean {
|
|
172
|
+
const words = query
|
|
173
|
+
.toLowerCase()
|
|
174
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
175
|
+
.filter(Boolean);
|
|
176
|
+
if (words.length === 0) return false;
|
|
177
|
+
const namespace = server.name.split("/")[0]?.toLowerCase() ?? "";
|
|
178
|
+
const labels = namespace.split(".");
|
|
179
|
+
if (labels.length < 2 || labels.some((label) => label === "")) return false;
|
|
180
|
+
if (labels[0] === "io" && labels[1] === "github") return false;
|
|
181
|
+
if (!words.includes(labels[1] as string)) return false;
|
|
182
|
+
|
|
183
|
+
let host: string;
|
|
184
|
+
try {
|
|
185
|
+
const url = new URL(server.url);
|
|
186
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
187
|
+
host = url.hostname.toLowerCase();
|
|
188
|
+
} catch {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
if (!host) return false;
|
|
192
|
+
|
|
193
|
+
const hostLabels = host.split(".").reverse();
|
|
194
|
+
return (
|
|
195
|
+
labels.length <= hostLabels.length &&
|
|
196
|
+
labels.every((label, i) => label === hostLabels[i])
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export type RegistryInstallKind = "http" | "pypi" | "npm";
|
|
201
|
+
export type RegistryInstallPlan =
|
|
202
|
+
| { ok: true; kind: RegistryInstallKind; config: McpServerConfig }
|
|
203
|
+
| { ok: false; kind: string; reason: string };
|
|
204
|
+
|
|
205
|
+
// Strict single tokens that cannot start with `-`: registry rows are
|
|
206
|
+
// third-party data and the name becomes an argv element, so `npm:-y` or
|
|
207
|
+
// `npm:--package=evil` must never turn into an option.
|
|
208
|
+
const NPM_PACKAGE =
|
|
209
|
+
/^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*(?:@[a-z0-9][a-z0-9._+-]*)?$/i;
|
|
210
|
+
const PYPI_PACKAGE =
|
|
211
|
+
/^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?(?:@[a-z0-9][a-z0-9._+-]*)?$/i;
|
|
212
|
+
|
|
213
|
+
function refuse(kind: string, reason: string): RegistryInstallPlan {
|
|
214
|
+
return { ok: false, kind, reason };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function unreadableAddress(): RegistryInstallPlan {
|
|
218
|
+
return refuse("unknown", "magus can't read this server's address.");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* What installing a registry row writes, decided by the row's `url` alone
|
|
223
|
+
* (`package_registry` is ignored: an `https:` row stays hosted). Expected
|
|
224
|
+
* refusals are returned, never thrown. No shell: the entry is command + args.
|
|
225
|
+
*/
|
|
226
|
+
export function registryInstallPlan(
|
|
227
|
+
server: Pick<McpRegistryServer, "url">,
|
|
228
|
+
): RegistryInstallPlan {
|
|
229
|
+
const raw = (server.url ?? "").trim();
|
|
230
|
+
const match = /^([a-z][a-z0-9+.-]*):(.*)$/is.exec(raw);
|
|
231
|
+
if (!match) return unreadableAddress();
|
|
232
|
+
const scheme = (match[1] as string).toLowerCase();
|
|
233
|
+
const rest = (match[2] as string).trim();
|
|
234
|
+
// `localhost:8080` is host:port, not a scheme.
|
|
235
|
+
if (/^\d+(?:[/?#]|$)/.test(rest)) return unreadableAddress();
|
|
236
|
+
|
|
237
|
+
if (scheme === "http" || scheme === "https") {
|
|
238
|
+
try {
|
|
239
|
+
if (new URL(raw).hostname) {
|
|
240
|
+
return {
|
|
241
|
+
ok: true,
|
|
242
|
+
kind: "http",
|
|
243
|
+
config: { type: "http", url: raw },
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
} catch {
|
|
247
|
+
// falls through to the refusal below
|
|
248
|
+
}
|
|
249
|
+
return unreadableAddress();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (scheme === "pypi" || scheme === "npm") {
|
|
253
|
+
if (rest === "") {
|
|
254
|
+
return refuse(
|
|
255
|
+
scheme,
|
|
256
|
+
`This ${scheme} server has no package name, so there's nothing to run.`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
const pattern = scheme === "pypi" ? PYPI_PACKAGE : NPM_PACKAGE;
|
|
260
|
+
if (!pattern.test(rest)) {
|
|
261
|
+
return refuse(
|
|
262
|
+
scheme,
|
|
263
|
+
`The package name of this ${scheme} server looks wrong, so magus won't run it.`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
ok: true,
|
|
268
|
+
kind: scheme,
|
|
269
|
+
config:
|
|
270
|
+
scheme === "pypi"
|
|
271
|
+
? { command: "uvx", args: [rest] }
|
|
272
|
+
: { command: "npx", args: ["-y", rest] },
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return refuse(
|
|
277
|
+
scheme,
|
|
278
|
+
`magus can't install ${scheme} servers. It can install https, pypi and npm ones.`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Official servers first; endpoint order is kept inside each group. */
|
|
283
|
+
export function officialFirst<
|
|
284
|
+
T extends Pick<McpRegistryServer, "name" | "url">,
|
|
285
|
+
>(servers: readonly T[], query: string): T[] {
|
|
286
|
+
const official = servers.filter((server) =>
|
|
287
|
+
isOfficialMcpServer(server, query),
|
|
288
|
+
);
|
|
289
|
+
const rest = servers.filter((server) => !isOfficialMcpServer(server, query));
|
|
290
|
+
return [...official, ...rest];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Last-to-start wins: take a token before each request and apply its response
|
|
295
|
+
* only while `isLatest(token)` still holds.
|
|
296
|
+
*/
|
|
297
|
+
export function createLatestOnlyGuard(): {
|
|
298
|
+
next(): number;
|
|
299
|
+
isLatest(token: number): boolean;
|
|
300
|
+
} {
|
|
301
|
+
let latest = 0;
|
|
302
|
+
return {
|
|
303
|
+
next: () => ++latest,
|
|
304
|
+
isLatest: (token) => token === latest,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
158
308
|
export function formatDate(dateStr?: string): string {
|
|
159
309
|
if (!dateStr) return "";
|
|
160
310
|
const date = new Date(dateStr);
|
package/src/ui/keymap.ts
CHANGED
|
@@ -294,6 +294,7 @@ const MCP_REGISTRY = [
|
|
|
294
294
|
bind("install", ["Enter"], "install", "Add the server to this project", 90),
|
|
295
295
|
search("Search the registry; the list reloads as you type"),
|
|
296
296
|
bind("local", ["l"], "local", "Back to this project's MCP servers", 50),
|
|
297
|
+
bind("remove", ["d"], "remove", "Remove the server from this project", 45),
|
|
297
298
|
bind("reload", ["R"], "reload", "Reload the results", 40),
|
|
298
299
|
CLEAR_FILTER,
|
|
299
300
|
MOVE,
|
|
@@ -97,6 +97,8 @@ export interface McpRegistryListItem {
|
|
|
97
97
|
server: McpRegistryServer;
|
|
98
98
|
isInstalled: boolean;
|
|
99
99
|
needsRestart: boolean;
|
|
100
|
+
/** Run by the owner of the domain its name claims. */
|
|
101
|
+
isOfficial: boolean;
|
|
100
102
|
}
|
|
101
103
|
|
|
102
104
|
export function renderMcpRegistryRow(
|
|
@@ -104,10 +106,11 @@ export function renderMcpRegistryRow(
|
|
|
104
106
|
_index: number,
|
|
105
107
|
isSelected: boolean,
|
|
106
108
|
): React.ReactNode {
|
|
107
|
-
const { server, isInstalled, needsRestart } = item;
|
|
109
|
+
const { server, isInstalled, needsRestart, isOfficial } = item;
|
|
108
110
|
const version = server.version ? ` v${server.version}` : "";
|
|
109
111
|
const icon = isInstalled ? "● " : "";
|
|
110
112
|
const restart = needsRestart ? RESTART_MARK : "";
|
|
113
|
+
const official = isOfficial ? " official" : "";
|
|
111
114
|
if (isSelected) {
|
|
112
115
|
return (
|
|
113
116
|
<text bg={theme.selection.bg} fg={theme.selection.fg}>
|
|
@@ -115,6 +118,7 @@ export function renderMcpRegistryRow(
|
|
|
115
118
|
{icon}
|
|
116
119
|
{server.name}
|
|
117
120
|
{version}
|
|
121
|
+
{official}
|
|
118
122
|
{restart}{" "}
|
|
119
123
|
</text>
|
|
120
124
|
);
|
|
@@ -127,6 +131,7 @@ export function renderMcpRegistryRow(
|
|
|
127
131
|
<strong>{server.name}</strong>
|
|
128
132
|
</span>
|
|
129
133
|
<span fg={theme.colors.info}>{version}</span>
|
|
134
|
+
{official ? <span fg={theme.colors.success}>{official}</span> : null}
|
|
130
135
|
{restart ? <span fg={theme.colors.warning}>{restart}</span> : null}
|
|
131
136
|
</text>
|
|
132
137
|
);
|
|
@@ -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
|
};
|