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.
@@ -0,0 +1,195 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { testRender } from "@opentui/react/test-utils";
6
+ import { act, useEffect } from "react";
7
+ import { KeyRouter } from "../ui/input/KeyRouter.js";
8
+ import McpRegistryScreen from "../ui/screens/McpRegistryScreen.js";
9
+ import { AppProvider, useNavigation } from "../ui/state/AppContext.js";
10
+ import { DimensionsProvider } from "../ui/state/DimensionsContext.js";
11
+
12
+ // REGRESSION: MTL-337 debounce window and cursor jump — round 2 of /dev:fix session dev-fix-20261101-153739-a08a5829
13
+ //
14
+ // The real McpRegistryScreen is mounted under the real providers, with a fake
15
+ // `fetch` for the registry endpoint and a throwaway project path. Keys go
16
+ // through the test renderer's mock keyboard; the outcome is read from what the
17
+ // screen wrote to .mcp.json.
18
+
19
+ // Real-looking hosts for the servers whose host decides "official"; every
20
+ // other name gets an example.org host.
21
+ const HOSTS: Record<string, string> = {
22
+ "com.notion/mcp": "https://mcp.notion.com/mcp",
23
+ "ai.smithery/smithery-notion": "https://server.smithery.ai/notion/mcp",
24
+ "com.linear/mcp": "https://mcp.linear.com/mcp",
25
+ };
26
+
27
+ const server = (name: string) => ({
28
+ name,
29
+ url: HOSTS[name] ?? `https://${name.replace(/[^a-z]/g, "")}.example.org/mcp`,
30
+ short_description: `${name} description`,
31
+ });
32
+
33
+ type Deferred = { query: string; resolve: (names: string[]) => void };
34
+
35
+ let cleanup: Array<() => Promise<void> | void> = [];
36
+ const realFetch = globalThis.fetch;
37
+
38
+ afterEach(async () => {
39
+ globalThis.fetch = realFetch;
40
+ for (const fn of cleanup.reverse()) await fn();
41
+ cleanup = [];
42
+ });
43
+
44
+ function GoToRegistry() {
45
+ const { navigateToScreen } = useNavigation();
46
+ // biome-ignore lint/correctness/useExhaustiveDependencies: navigate once on mount
47
+ useEffect(() => navigateToScreen("mcp-registry"), []);
48
+ return null;
49
+ }
50
+
51
+ async function mount(initialNames: string[]) {
52
+ const project = mkdtempSync(join(tmpdir(), "mcp-registry-screen-"));
53
+ cleanup.push(() => rmSync(project, { recursive: true, force: true }));
54
+
55
+ const pending: Deferred[] = [];
56
+ globalThis.fetch = (async (input: unknown) => {
57
+ const url = new URL(String(input));
58
+ const query = url.searchParams.get("q") ?? "";
59
+ const names = await new Promise<string[]>((resolve) => {
60
+ if (query === "") resolve(initialNames);
61
+ else pending.push({ query, resolve });
62
+ });
63
+ return new Response(JSON.stringify({ servers: names.map(server) }), {
64
+ status: 200,
65
+ headers: { "content-type": "application/json" },
66
+ });
67
+ }) as typeof fetch;
68
+
69
+ const { renderer, mockInput, renderOnce, captureCharFrame } =
70
+ await testRender(
71
+ <AppProvider initialProjectPath={project}>
72
+ <DimensionsProvider>
73
+ <KeyRouter>
74
+ <GoToRegistry />
75
+ <McpRegistryScreen />
76
+ </KeyRouter>
77
+ </DimensionsProvider>
78
+ </AppProvider>,
79
+ { width: 110, height: 30 },
80
+ );
81
+ cleanup.push(async () => {
82
+ await act(async () => renderer.destroy());
83
+ });
84
+
85
+ const settle = async (ms = 30) => {
86
+ for (let i = 0; i < 3; i++) {
87
+ await new Promise((r) => setTimeout(r, ms));
88
+ await renderOnce();
89
+ }
90
+ };
91
+ const send = async (fn: () => void | Promise<void>, ms?: number) =>
92
+ act(async () => {
93
+ await fn();
94
+ await settle(ms);
95
+ });
96
+ await act(() => settle());
97
+
98
+ const mcpJson = () => {
99
+ const path = join(project, ".mcp.json");
100
+ return existsSync(path) ? readFileSync(path, "utf8") : "";
101
+ };
102
+ return { mockInput, send, settle, pending, mcpJson, frame: captureCharFrame };
103
+ }
104
+
105
+ describe("registry screen: the list is not actionable while a query change is pending", () => {
106
+ test("typing a query then Enter, Enter inside the debounce installs nothing from the old list", async () => {
107
+ const m = await mount(["com.old/alpha", "com.old/beta"]);
108
+
109
+ // "/" opens the search box, the text is typed, the first Enter leaves it,
110
+ // the second Enter installs the row under the cursor. All of it happens
111
+ // well inside the 300 ms debounce, before loadServers has started.
112
+ await m.send(() => m.mockInput.typeText("/notion"), 5);
113
+ await m.send(() => m.mockInput.pressEnter(), 5);
114
+ await m.send(() => m.mockInput.pressEnter(), 5);
115
+
116
+ expect(m.pending).toHaveLength(0); // debounce has not fired yet
117
+ expect(m.mcpJson()).not.toContain("com.old/");
118
+ });
119
+
120
+ test("after the new query's response lands, Enter installs the new list's first row", async () => {
121
+ const m = await mount(["com.old/alpha", "com.old/beta"]);
122
+ await m.send(() => m.mockInput.typeText("/notion"), 5);
123
+ await m.send(() => m.mockInput.pressEnter(), 5);
124
+ await m.settle(400); // debounce fires, request is now pending
125
+ expect(m.pending).toHaveLength(1);
126
+ await m.send(() => m.pending[0]?.resolve(["com.notion/mcp"]));
127
+ await m.send(() => m.mockInput.pressEnter(), 100);
128
+
129
+ expect(m.mcpJson()).toContain("com.notion/mcp");
130
+ });
131
+ });
132
+
133
+ describe("registry screen: the cursor follows the name only when the query is unchanged", () => {
134
+ test("a new query puts the cursor on row 0 of the new results, even when the old row-0 server is in them", async () => {
135
+ const m = await mount(["com.old/alpha", "com.old/beta"]);
136
+ await m.send(() => m.mockInput.typeText("/x"), 5);
137
+ await m.send(() => m.mockInput.pressEnter(), 5);
138
+ await m.settle(400);
139
+ expect(m.pending).toHaveLength(1);
140
+ // The old row-0 server comes back, but second.
141
+ await m.send(() =>
142
+ m.pending[0]?.resolve(["com.new/first", "com.old/alpha"]),
143
+ );
144
+ await m.send(() => m.mockInput.pressEnter(), 100);
145
+
146
+ expect(m.mcpJson()).toContain("com.new/first");
147
+ expect(m.mcpJson()).not.toContain("com.old/alpha");
148
+ });
149
+ });
150
+
151
+ describe("registry screen: the official badge follows the search", () => {
152
+ const rowLines = (frame: string, needle: string) =>
153
+ frame.split("\n").filter((line) => line.includes(needle));
154
+
155
+ test("the unfiltered list shows no official badge on any row", async () => {
156
+ const m = await mount([
157
+ "com.linear/mcp",
158
+ "ai.smithery/smithery-notion",
159
+ "com.notion/mcp",
160
+ ]);
161
+ await m.send(() => m.settle(400)); // the initial load runs behind the debounce
162
+ expect(m.frame()).toContain("notion/mcp"); // the rows did render
163
+ expect(m.frame()).not.toContain("official");
164
+ });
165
+
166
+ test("after searching notion only the real notion server is badged, and it is first", async () => {
167
+ const m = await mount([
168
+ "com.linear/mcp",
169
+ "ai.smithery/smithery-notion",
170
+ "com.notion/mcp",
171
+ ]);
172
+ await m.send(() => m.mockInput.typeText("/notion"), 5);
173
+ await m.send(() => m.mockInput.pressEnter(), 5);
174
+ await m.settle(400);
175
+ expect(m.pending).toHaveLength(1);
176
+ // The endpoint answers with the third-party server before the real one.
177
+ await m.send(() =>
178
+ m.pending[0]?.resolve(["ai.smithery/smithery-notion", "com.notion/mcp"]),
179
+ );
180
+
181
+ await m.send(() => m.settle(100));
182
+ const frame = m.frame();
183
+ const lines = frame.split("\n");
184
+ const notionRow = lines.findIndex((l) => l.includes("com.notion/mcp"));
185
+ const smitheryRow = lines.findIndex((l) =>
186
+ l.includes("ai.smithery/smithery-notion"),
187
+ );
188
+ expect(notionRow).toBeGreaterThanOrEqual(0);
189
+ expect(notionRow).toBeLessThan(smitheryRow); // official first
190
+ expect(lines[notionRow]).toContain("official");
191
+ expect(rowLines(frame, "smithery-notion").join("\n")).not.toContain(
192
+ "official",
193
+ );
194
+ });
195
+ });
@@ -0,0 +1,209 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { parseKeypress } from "@opentui/core";
3
+ import {
4
+ createLatestOnlyGuard,
5
+ isOfficialMcpServer,
6
+ officialFirst,
7
+ } from "../services/mcp-registry.js";
8
+ import type { KeyPress } from "../ui/input/key-dispatch.js";
9
+ import { SCREEN_BINDINGS, matchBinding } from "../ui/keymap.js";
10
+
11
+ // REGRESSION: MTL-337 registry screen installs the wrong server / no remove / no official first — Fixed in /dev:fix session dev-fix-20261101-153739-a08a5829
12
+
13
+ const srv = (name: string, url: string) => ({
14
+ name,
15
+ url,
16
+ short_description: "",
17
+ });
18
+
19
+ const press = (raw: string): KeyPress => {
20
+ const k = parseKeypress(raw);
21
+ return {
22
+ name: k?.name ?? "",
23
+ sequence: k?.sequence ?? "",
24
+ ctrl: k?.ctrl === true,
25
+ meta: k?.meta === true,
26
+ shift: k?.shift === true,
27
+ };
28
+ };
29
+
30
+ describe("isOfficialMcpServer", () => {
31
+ const notion = srv("com.notion/mcp", "https://mcp.notion.com/mcp");
32
+
33
+ test("an empty or whitespace query makes nothing official", () => {
34
+ expect(isOfficialMcpServer(notion, "")).toBe(false);
35
+ expect(isOfficialMcpServer(notion, " ")).toBe(false);
36
+ });
37
+
38
+ test("com.notion/mcp served from mcp.notion.com is official for the query notion", () => {
39
+ expect(isOfficialMcpServer(notion, "notion")).toBe(true);
40
+ });
41
+
42
+ test("a third-party host is not official for the brand it mentions", () => {
43
+ expect(
44
+ isOfficialMcpServer(
45
+ srv("ai.smithery/smithery-notion", "https://server.smithery.ai/notion"),
46
+ "notion",
47
+ ),
48
+ ).toBe(false);
49
+ expect(
50
+ isOfficialMcpServer(
51
+ srv("com.foreverworks/notion", "https://mcp.foreverworks.com/notion"),
52
+ "notion",
53
+ ),
54
+ ).toBe(false);
55
+ });
56
+
57
+ test("a server is official for its own brand, but not for another brand's query", () => {
58
+ const smithery = srv(
59
+ "ai.smithery/smithery-notion",
60
+ "https://server.smithery.ai/notion",
61
+ );
62
+ expect(isOfficialMcpServer(smithery, "smithery")).toBe(true);
63
+ expect(isOfficialMcpServer(notion, "smithery")).toBe(false);
64
+ });
65
+
66
+ test("the query is case-insensitive and split into words", () => {
67
+ expect(isOfficialMcpServer(notion, "Notion MCP")).toBe(true);
68
+ expect(isOfficialMcpServer(notion, "mcp-NOTION")).toBe(true);
69
+ });
70
+
71
+ test("a word must equal the brand, a substring does not count", () => {
72
+ expect(isOfficialMcpServer(notion, "notional")).toBe(false);
73
+ expect(isOfficialMcpServer(notion, "not")).toBe(false);
74
+ });
75
+
76
+ test("com.mcparmory/notion packaged on pypi is not official", () => {
77
+ expect(
78
+ isOfficialMcpServer(
79
+ srv("com.mcparmory/notion", "pypi:mcparmory-notion"),
80
+ "mcparmory notion",
81
+ ),
82
+ ).toBe(false);
83
+ });
84
+
85
+ test("an io.github.<owner> namespace is never official, even when the host matches", () => {
86
+ expect(
87
+ isOfficialMcpServer(
88
+ srv("io.github.someone/notion", "https://github.io/someone/notion"),
89
+ "github someone notion",
90
+ ),
91
+ ).toBe(false);
92
+ expect(
93
+ isOfficialMcpServer(
94
+ srv("io.github.notion/mcp", "https://mcp.github.io/mcp"),
95
+ "github notion",
96
+ ),
97
+ ).toBe(false);
98
+ });
99
+
100
+ test("npm: and pypi: sources are not official", () => {
101
+ expect(
102
+ isOfficialMcpServer(srv("com.notion/mcp", "npm:@notion/mcp"), "notion"),
103
+ ).toBe(false);
104
+ expect(
105
+ isOfficialMcpServer(srv("com.notion/mcp", "pypi:notion-mcp"), "notion"),
106
+ ).toBe(false);
107
+ });
108
+
109
+ test("an unparsable URL is not official", () => {
110
+ expect(
111
+ isOfficialMcpServer(srv("com.notion/mcp", "not a url"), "notion"),
112
+ ).toBe(false);
113
+ expect(isOfficialMcpServer(srv("com.notion/mcp", ""), "notion")).toBe(
114
+ false,
115
+ );
116
+ });
117
+
118
+ test("a namespace that is only a string prefix of the host does not match on a label boundary", () => {
119
+ expect(
120
+ isOfficialMcpServer(srv("com.not/x", "https://notion.com/mcp"), "not"),
121
+ ).toBe(false);
122
+ expect(
123
+ isOfficialMcpServer(srv("com.not/x", "https://notion.com/mcp"), "notion"),
124
+ ).toBe(false);
125
+ });
126
+ });
127
+
128
+ describe("officialFirst", () => {
129
+ const a = srv("io.github.a/one", "https://a.example.org/mcp");
130
+ const o1 = srv("com.notion/mcp", "https://mcp.notion.com/mcp");
131
+ const b = srv("com.mcparmory/notion", "pypi:mcparmory-notion");
132
+ const o2 = srv("com.notion/second", "https://api.notion.com/second");
133
+ const other = srv("com.linear/mcp", "https://mcp.linear.com/mcp");
134
+
135
+ test("official servers move to the front, endpoint order kept inside each group", () => {
136
+ expect(officialFirst([a, o1, b, other, o2], "notion")).toEqual([
137
+ o1,
138
+ o2,
139
+ a,
140
+ b,
141
+ other,
142
+ ]);
143
+ });
144
+
145
+ test("with an empty query the order is unchanged", () => {
146
+ expect(officialFirst([a, o1, b, o2], "")).toEqual([a, o1, b, o2]);
147
+ });
148
+
149
+ test("does not mutate its input", () => {
150
+ const input = [a, o1, b];
151
+ const snapshot = [...input];
152
+ const out = officialFirst(input, "notion");
153
+ expect(input).toEqual(snapshot);
154
+ expect(out).not.toBe(input);
155
+ });
156
+
157
+ test("an empty list stays empty", () => {
158
+ expect(officialFirst([], "notion")).toEqual([]);
159
+ });
160
+ });
161
+
162
+ describe("MCP registry keymap", () => {
163
+ test("d is bound to a remove action on the mcp-registry screen", () => {
164
+ expect(matchBinding(SCREEN_BINDINGS["mcp-registry"], press("d"))?.id).toBe(
165
+ "remove",
166
+ );
167
+ });
168
+ });
169
+
170
+ describe("stale registry responses", () => {
171
+ // The screen (McpRegistryScreen.tsx) is a React/OpenTUI component whose
172
+ // loadServers closes over component state; the repo has no harness that
173
+ // drives it with fake fetches. The guard is therefore tested as the small
174
+ // pure helper the screen will call: take a token before each request, apply
175
+ // the response only when isLatest(token) still holds.
176
+ test("only the newest request's response is applied when responses arrive out of order", async () => {
177
+ const guard = createLatestOnlyGuard();
178
+ const deferred = <T>() => {
179
+ let resolve!: (v: T) => void;
180
+ const promise = new Promise<T>((r) => {
181
+ resolve = r;
182
+ });
183
+ return { promise, resolve };
184
+ };
185
+ const first = deferred<string[]>();
186
+ const second = deferred<string[]>();
187
+ let applied: string[] | null = null;
188
+ const appliedFrom: string[] = [];
189
+
190
+ const load = async (label: string, p: Promise<string[]>) => {
191
+ const token = guard.next();
192
+ const result = await p;
193
+ if (guard.isLatest(token)) {
194
+ applied = result;
195
+ appliedFrom.push(label);
196
+ }
197
+ };
198
+
199
+ const a = load("old", first.promise);
200
+ const b = load("new", second.promise);
201
+ second.resolve(["new-server"]); // newest answers first
202
+ await b;
203
+ first.resolve(["old-server"]); // stale answer lands last
204
+ await a;
205
+
206
+ expect(applied).toEqual(["new-server"]);
207
+ expect(appliedFrom).toEqual(["new"]);
208
+ });
209
+ });
@@ -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
  );