@evantahler/mcpx 0.18.3 → 0.18.5
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 +63 -63
- package/src/cli.ts +46 -54
- package/src/client/browser.ts +15 -15
- package/src/client/debug-fetch.ts +53 -56
- package/src/client/elicitation.ts +279 -291
- package/src/client/http.ts +1 -1
- package/src/client/manager.ts +481 -514
- package/src/client/oauth.ts +272 -282
- package/src/client/sse.ts +1 -1
- package/src/client/stdio.ts +7 -7
- package/src/client/trace.ts +146 -152
- package/src/client/transport-options.ts +20 -20
- package/src/commands/add.ts +160 -165
- package/src/commands/allow.ts +141 -142
- package/src/commands/auth.ts +86 -90
- package/src/commands/check-update.ts +49 -53
- package/src/commands/deny.ts +114 -117
- package/src/commands/exec.ts +218 -222
- package/src/commands/index.ts +41 -41
- package/src/commands/info.ts +48 -50
- package/src/commands/list.ts +49 -49
- package/src/commands/ping.ts +47 -50
- package/src/commands/prompt.ts +40 -50
- package/src/commands/remove.ts +54 -56
- package/src/commands/resource.ts +31 -36
- package/src/commands/search.ts +35 -39
- package/src/commands/servers.ts +44 -48
- package/src/commands/skill.ts +89 -95
- package/src/commands/task.ts +50 -60
- package/src/commands/upgrade.ts +191 -208
- package/src/commands/with-command.ts +27 -29
- package/src/config/env.ts +26 -28
- package/src/config/loader.ts +99 -102
- package/src/config/schemas.ts +78 -87
- package/src/constants.ts +17 -17
- package/src/context.ts +51 -51
- package/src/lib/client-settings.ts +127 -140
- package/src/lib/input.ts +23 -26
- package/src/output/format-output.ts +12 -16
- package/src/output/format-table.ts +39 -42
- package/src/output/formatter.ts +790 -814
- package/src/output/logger.ts +140 -152
- package/src/sdk.ts +283 -291
- package/src/search/index.ts +50 -54
- package/src/search/indexer.ts +65 -65
- package/src/search/keyword.ts +54 -54
- package/src/search/semantic.ts +39 -39
- package/src/search/staleness.ts +3 -3
- package/src/search/types.ts +4 -4
- package/src/update/background.ts +51 -51
- package/src/update/cache.ts +21 -21
- package/src/update/checker.ts +81 -86
- package/src/validation/schema.ts +52 -58
package/src/search/index.ts
CHANGED
|
@@ -1,70 +1,66 @@
|
|
|
1
1
|
import type { SearchIndex } from "../config/schemas.ts";
|
|
2
|
+
import { DEFAULTS } from "../constants.ts";
|
|
2
3
|
import { keywordSearch } from "./keyword.ts";
|
|
3
4
|
import { semanticSearch } from "./semantic.ts";
|
|
4
|
-
import { DEFAULTS } from "../constants.ts";
|
|
5
5
|
|
|
6
6
|
export interface SearchResult {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
server: string;
|
|
8
|
+
tool: string;
|
|
9
|
+
description: string;
|
|
10
|
+
score: number;
|
|
11
|
+
matchType: "keyword" | "semantic" | "both";
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export interface SearchOptions {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
keywordOnly?: boolean;
|
|
16
|
+
semanticOnly?: boolean;
|
|
17
|
+
topK?: number;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/** Search tools using keyword and/or semantic matching */
|
|
21
|
-
export async function search(
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
options: SearchOptions = {},
|
|
25
|
-
): Promise<SearchResult[]> {
|
|
26
|
-
const topK = options.topK ?? DEFAULTS.SEARCH_TOP_K;
|
|
27
|
-
const results = new Map<string, SearchResult>();
|
|
21
|
+
export async function search(query: string, index: SearchIndex, options: SearchOptions = {}): Promise<SearchResult[]> {
|
|
22
|
+
const topK = options.topK ?? DEFAULTS.SEARCH_TOP_K;
|
|
23
|
+
const results = new Map<string, SearchResult>();
|
|
28
24
|
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
const runKeyword = !options.semanticOnly;
|
|
26
|
+
const runSemantic = !options.keywordOnly;
|
|
31
27
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
28
|
+
// Keyword search
|
|
29
|
+
if (runKeyword) {
|
|
30
|
+
const matches = keywordSearch(query, index.tools);
|
|
31
|
+
for (const m of matches) {
|
|
32
|
+
const key = `${m.server}/${m.tool}`;
|
|
33
|
+
results.set(key, {
|
|
34
|
+
server: m.server,
|
|
35
|
+
tool: m.tool,
|
|
36
|
+
description: m.description,
|
|
37
|
+
score: m.score,
|
|
38
|
+
matchType: "keyword",
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
46
42
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
43
|
+
// Semantic search
|
|
44
|
+
if (runSemantic && index.tools.some((t) => t.embedding.length > 0)) {
|
|
45
|
+
const matches = await semanticSearch(query, index.tools, topK);
|
|
46
|
+
for (const m of matches) {
|
|
47
|
+
const key = `${m.server}/${m.tool}`;
|
|
48
|
+
const existing = results.get(key);
|
|
49
|
+
if (existing) {
|
|
50
|
+
// Combine scores: keyword 0.4 + semantic 0.6
|
|
51
|
+
existing.score = existing.score * 0.4 + m.score * 0.6;
|
|
52
|
+
existing.matchType = "both";
|
|
53
|
+
} else {
|
|
54
|
+
results.set(key, {
|
|
55
|
+
server: m.server,
|
|
56
|
+
tool: m.tool,
|
|
57
|
+
description: m.description,
|
|
58
|
+
score: m.score,
|
|
59
|
+
matchType: "semantic",
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
68
64
|
|
|
69
|
-
|
|
65
|
+
return [...results.values()].sort((a, b) => b.score - a.score).slice(0, topK);
|
|
70
66
|
}
|
package/src/search/indexer.ts
CHANGED
|
@@ -1,92 +1,92 @@
|
|
|
1
1
|
import type { ServerManager, ToolWithServer } from "../client/manager.ts";
|
|
2
|
-
import type {
|
|
3
|
-
import { generateEmbedding } from "./semantic.ts";
|
|
2
|
+
import type { IndexedTool, SearchIndex } from "../config/schemas.ts";
|
|
4
3
|
import { logger } from "../output/logger.ts";
|
|
4
|
+
import { generateEmbedding } from "./semantic.ts";
|
|
5
5
|
|
|
6
6
|
/** Extract keywords from a tool name by splitting on separators and camelCase */
|
|
7
7
|
export function extractKeywords(name: string): string[] {
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
// Split on underscores, hyphens, dots
|
|
9
|
+
const parts = name.split(/[_\-.]+/);
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
// Also split camelCase
|
|
12
|
+
const words: string[] = [];
|
|
13
|
+
for (const part of parts) {
|
|
14
|
+
words.push(...part.replace(/([a-z])([A-Z])/g, "$1 $2").split(/\s+/));
|
|
15
|
+
}
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
return words.map((w) => w.toLowerCase()).filter((w) => w.length > 1);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/** Generate scenario phrases from tool name and description */
|
|
21
21
|
export function generateScenarios(name: string, description: string): string[] {
|
|
22
|
-
|
|
22
|
+
const scenarios: string[] = [];
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
// Use description as-is if short enough
|
|
25
|
+
if (description && description.length < 200) {
|
|
26
|
+
scenarios.push(description);
|
|
27
|
+
}
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
// Extract action + noun from tool name (e.g., "SendMessage" → "send a message")
|
|
30
|
+
const keywords = extractKeywords(name);
|
|
31
|
+
if (keywords.length >= 2) {
|
|
32
|
+
scenarios.push(keywords.join(" "));
|
|
33
|
+
}
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
return scenarios;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
/** Build an IndexedTool from a tool with server info */
|
|
39
39
|
async function indexTool(t: ToolWithServer): Promise<IndexedTool> {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
40
|
+
const description = t.tool.description ?? "";
|
|
41
|
+
const keywords = extractKeywords(t.tool.name);
|
|
42
|
+
const scenarios = generateScenarios(t.tool.name, description);
|
|
43
|
+
|
|
44
|
+
// Build text for embedding: combine name, description, and scenarios
|
|
45
|
+
const embeddingText = [t.tool.name, description, ...scenarios].filter(Boolean).join(" ");
|
|
46
|
+
const embedding = await generateEmbedding(embeddingText);
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
server: t.server,
|
|
50
|
+
tool: t.tool.name,
|
|
51
|
+
description,
|
|
52
|
+
input_schema: t.tool.inputSchema,
|
|
53
|
+
scenarios,
|
|
54
|
+
keywords,
|
|
55
|
+
embedding,
|
|
56
|
+
};
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
export interface IndexProgress {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
total: number;
|
|
61
|
+
current: number;
|
|
62
|
+
tool: string;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/** Build a search index from all configured servers */
|
|
66
66
|
export async function buildSearchIndex(
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
manager: ServerManager,
|
|
68
|
+
onProgress?: (progress: IndexProgress) => void,
|
|
69
69
|
): Promise<SearchIndex> {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
70
|
+
const { tools, errors } = await manager.getAllTools();
|
|
71
|
+
|
|
72
|
+
if (errors.length > 0) {
|
|
73
|
+
for (const err of errors) {
|
|
74
|
+
logger.warn(`${err.server}: ${err.message}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const indexed: IndexedTool[] = [];
|
|
79
|
+
|
|
80
|
+
for (let i = 0; i < tools.length; i++) {
|
|
81
|
+
const t = tools[i]!;
|
|
82
|
+
onProgress?.({ total: tools.length, current: i + 1, tool: `${t.server}/${t.tool.name}` });
|
|
83
|
+
indexed.push(await indexTool(t));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
version: 1,
|
|
88
|
+
indexed_at: new Date().toISOString(),
|
|
89
|
+
embedding_model: "Xenova/all-MiniLM-L6-v2",
|
|
90
|
+
tools: indexed,
|
|
91
|
+
};
|
|
92
92
|
}
|
package/src/search/keyword.ts
CHANGED
|
@@ -3,81 +3,81 @@ import type { IndexedTool } from "../config/schemas.ts";
|
|
|
3
3
|
import type { BaseMatch } from "./types.ts";
|
|
4
4
|
|
|
5
5
|
export interface KeywordMatch extends BaseMatch {
|
|
6
|
-
|
|
6
|
+
matchedField: string;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
interface FieldWeight {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
field: string;
|
|
11
|
+
weight: number;
|
|
12
|
+
values: (t: IndexedTool) => string[];
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
const FIELDS: FieldWeight[] = [
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
{ field: "name", weight: 1.0, values: (t) => [t.tool] },
|
|
17
|
+
{ field: "keyword", weight: 0.8, values: (t) => t.keywords },
|
|
18
|
+
{ field: "scenario", weight: 0.6, values: (t) => t.scenarios },
|
|
19
|
+
{ field: "description", weight: 0.4, values: (t) => [t.description] },
|
|
20
20
|
];
|
|
21
21
|
|
|
22
22
|
/** Check if query looks like a glob pattern */
|
|
23
23
|
function isGlob(query: string): boolean {
|
|
24
|
-
|
|
24
|
+
return /[*?[\]{}]/.test(query);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/** Search indexed tools by keyword/glob matching */
|
|
28
28
|
export function keywordSearch(query: string, tools: IndexedTool[]): KeywordMatch[] {
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
const queryLower = query.toLowerCase();
|
|
30
|
+
const tokens = queryLower.split(/\s+/).filter(Boolean);
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
// If any token is a glob, use picomatch for name matching
|
|
33
|
+
const globTokens = tokens.filter(isGlob);
|
|
34
|
+
const textTokens = tokens.filter((t) => !isGlob(t));
|
|
35
|
+
const globMatcher = globTokens.length > 0 ? picomatch(globTokens, { nocase: true }) : null;
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
const results: KeywordMatch[] = [];
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
for (const tool of tools) {
|
|
40
|
+
let bestScore = 0;
|
|
41
|
+
let bestField = "";
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
// Glob matching against tool name
|
|
44
|
+
if (globMatcher?.(tool.tool)) {
|
|
45
|
+
bestScore = 1.0;
|
|
46
|
+
bestField = "name";
|
|
47
|
+
}
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
49
|
+
// Text token matching against all fields
|
|
50
|
+
if (textTokens.length > 0) {
|
|
51
|
+
for (const { field, weight, values } of FIELDS) {
|
|
52
|
+
const fieldValues = values(tool).map((v) => v.toLowerCase());
|
|
53
|
+
let matchCount = 0;
|
|
54
54
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
55
|
+
for (const token of textTokens) {
|
|
56
|
+
if (fieldValues.some((v) => v.includes(token))) {
|
|
57
|
+
matchCount++;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
61
|
+
if (matchCount > 0) {
|
|
62
|
+
const score = (matchCount / textTokens.length) * weight;
|
|
63
|
+
if (score > bestScore) {
|
|
64
|
+
bestScore = score;
|
|
65
|
+
bestField = field;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
71
|
+
if (bestScore > 0) {
|
|
72
|
+
results.push({
|
|
73
|
+
server: tool.server,
|
|
74
|
+
tool: tool.tool,
|
|
75
|
+
description: tool.description,
|
|
76
|
+
score: bestScore,
|
|
77
|
+
matchedField: bestField,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
return results.sort((a, b) => b.score - a.score);
|
|
83
83
|
}
|
package/src/search/semantic.ts
CHANGED
|
@@ -9,64 +9,64 @@ let pipelineInstance: ((text: string) => Promise<Float32Array>) | null = null;
|
|
|
9
9
|
|
|
10
10
|
/** Get or create the embedding pipeline */
|
|
11
11
|
async function getEmbedder(): Promise<(text: string) => Promise<Float32Array>> {
|
|
12
|
-
|
|
12
|
+
if (pipelineInstance) return pipelineInstance;
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
const { pipeline } = await import("@huggingface/transformers");
|
|
15
|
+
const extractor = await pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2", {
|
|
16
|
+
dtype: "fp32",
|
|
17
|
+
});
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
pipelineInstance = async (text: string): Promise<Float32Array> => {
|
|
20
|
+
const output = await extractor(text, { pooling: "mean", normalize: true });
|
|
21
|
+
// output.data is a Float32Array of the pooled embedding
|
|
22
|
+
return output.data as Float32Array;
|
|
23
|
+
};
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
return pipelineInstance;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/** Generate an embedding vector for text */
|
|
29
29
|
export async function generateEmbedding(text: string): Promise<number[]> {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
const embed = await getEmbedder();
|
|
31
|
+
const vec = await embed(text);
|
|
32
|
+
return Array.from(vec);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/** Cosine similarity between two vectors */
|
|
36
36
|
export function cosineSimilarity(a: number[], b: number[]): number {
|
|
37
|
-
|
|
37
|
+
if (a.length !== b.length || a.length === 0) return 0;
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
39
|
+
let dot = 0;
|
|
40
|
+
let magA = 0;
|
|
41
|
+
let magB = 0;
|
|
42
|
+
for (let i = 0; i < a.length; i++) {
|
|
43
|
+
dot += a[i]! * b[i]!;
|
|
44
|
+
magA += a[i]! * a[i]!;
|
|
45
|
+
magB += b[i]! * b[i]!;
|
|
46
|
+
}
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
const denom = Math.sqrt(magA) * Math.sqrt(magB);
|
|
49
|
+
return denom === 0 ? 0 : dot / denom;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
/** Search indexed tools by semantic similarity */
|
|
53
53
|
export async function semanticSearch(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
query: string,
|
|
55
|
+
tools: IndexedTool[],
|
|
56
|
+
topK: number = DEFAULTS.SEARCH_TOP_K,
|
|
57
57
|
): Promise<SemanticMatch[]> {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
// Only search tools that have embeddings
|
|
59
|
+
const withEmbeddings = tools.filter((t) => t.embedding.length > 0);
|
|
60
|
+
if (withEmbeddings.length === 0) return [];
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
const queryEmbedding = await generateEmbedding(query);
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
64
|
+
const scored = withEmbeddings.map((tool) => ({
|
|
65
|
+
server: tool.server,
|
|
66
|
+
tool: tool.tool,
|
|
67
|
+
description: tool.description,
|
|
68
|
+
score: cosineSimilarity(queryEmbedding, tool.embedding),
|
|
69
|
+
}));
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
return scored.sort((a, b) => b.score - a.score).slice(0, topK);
|
|
72
72
|
}
|
package/src/search/staleness.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { SearchIndex, ServersFile } from "../config/schemas.ts";
|
|
|
2
2
|
|
|
3
3
|
/** Return server names that appear in the index but not in the current config */
|
|
4
4
|
export function getStaleServers(index: SearchIndex, servers: ServersFile): string[] {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
const configured = new Set(Object.keys(servers.mcpServers));
|
|
6
|
+
const indexed = new Set(index.tools.map((t) => t.server));
|
|
7
|
+
return [...indexed].filter((s) => !configured.has(s));
|
|
8
8
|
}
|
package/src/search/types.ts
CHANGED