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.
@@ -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
- /** Run git in `cwd`, resolving stdout on exit 0. Killed after `timeoutMs`. */
46
- function runGit(
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
- return new Promise((resolve, reject) => {
52
- const proc = spawn("git", args, {
53
- cwd,
54
- timeout: timeoutMs,
55
- killSignal: "SIGKILL",
56
- });
57
- let stdout = "";
58
- let stderr = "";
59
- proc.stdout?.on("data", (b) => {
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(dir, ["status", "--porcelain"], GIT_TIMEOUT_MS);
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
- // Never clobber a user's local edits to the clone.
111
- if (await isDirty(dir)) return "skipped";
112
- // --ff-only is the safety guarantee: it fetches and fast-forwards, and
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 { 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
  };