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
|
@@ -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
|
);
|