@evantahler/mcpcli 0.1.1
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/LICENSE +21 -0
- package/README.md +432 -0
- package/package.json +56 -0
- package/skills/mcpcli.md +85 -0
- package/src/cli.ts +34 -0
- package/src/client/http.ts +99 -0
- package/src/client/manager.ts +204 -0
- package/src/client/oauth.ts +263 -0
- package/src/client/stdio.ts +12 -0
- package/src/commands/auth.ts +106 -0
- package/src/commands/call.ts +104 -0
- package/src/commands/index.ts +53 -0
- package/src/commands/info.ts +42 -0
- package/src/commands/list.ts +30 -0
- package/src/commands/search.ts +37 -0
- package/src/config/env.ts +41 -0
- package/src/config/loader.ts +118 -0
- package/src/config/schemas.ts +137 -0
- package/src/context.ts +41 -0
- package/src/output/formatter.ts +316 -0
- package/src/output/spinner.ts +39 -0
- package/src/search/index.ts +69 -0
- package/src/search/indexer.ts +91 -0
- package/src/search/keyword.ts +86 -0
- package/src/search/semantic.ts +75 -0
- package/src/validation/schema.ts +77 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { bold, cyan, dim, green, red, yellow } from "ansis";
|
|
2
|
+
import type { Tool } from "../config/schemas.ts";
|
|
3
|
+
import type { ToolWithServer } from "../client/manager.ts";
|
|
4
|
+
import type { ValidationError } from "../validation/schema.ts";
|
|
5
|
+
import type { SearchResult } from "../search/index.ts";
|
|
6
|
+
|
|
7
|
+
export interface FormatOptions {
|
|
8
|
+
json?: boolean;
|
|
9
|
+
withDescriptions?: boolean;
|
|
10
|
+
verbose?: boolean;
|
|
11
|
+
showSecrets?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Check if stdout is a TTY (interactive terminal) */
|
|
15
|
+
export function isInteractive(options: FormatOptions): boolean {
|
|
16
|
+
if (options.json) return false;
|
|
17
|
+
return process.stdout.isTTY ?? false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Format a list of tools with server names */
|
|
21
|
+
export function formatToolList(tools: ToolWithServer[], options: FormatOptions): string {
|
|
22
|
+
if (!isInteractive(options)) {
|
|
23
|
+
if (options.withDescriptions) {
|
|
24
|
+
return JSON.stringify(
|
|
25
|
+
tools.map((t) => ({
|
|
26
|
+
server: t.server,
|
|
27
|
+
tool: t.tool.name,
|
|
28
|
+
description: t.tool.description ?? "",
|
|
29
|
+
})),
|
|
30
|
+
null,
|
|
31
|
+
2,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return JSON.stringify(
|
|
35
|
+
tools.map((t) => ({ server: t.server, tool: t.tool.name })),
|
|
36
|
+
null,
|
|
37
|
+
2,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (tools.length === 0) {
|
|
42
|
+
return dim("No tools found");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Calculate column widths
|
|
46
|
+
const maxServer = Math.max(...tools.map((t) => t.server.length));
|
|
47
|
+
const maxTool = Math.max(...tools.map((t) => t.tool.name.length));
|
|
48
|
+
|
|
49
|
+
return tools
|
|
50
|
+
.map((t) => {
|
|
51
|
+
const server = cyan(t.server.padEnd(maxServer));
|
|
52
|
+
const tool = bold(t.tool.name.padEnd(maxTool));
|
|
53
|
+
if (options.withDescriptions && t.tool.description) {
|
|
54
|
+
return `${server} ${tool} ${dim(t.tool.description)}`;
|
|
55
|
+
}
|
|
56
|
+
return `${server} ${tool}`;
|
|
57
|
+
})
|
|
58
|
+
.join("\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Format tools for a single server */
|
|
62
|
+
export function formatServerTools(
|
|
63
|
+
serverName: string,
|
|
64
|
+
tools: Tool[],
|
|
65
|
+
options: FormatOptions,
|
|
66
|
+
): string {
|
|
67
|
+
if (!isInteractive(options)) {
|
|
68
|
+
return JSON.stringify(
|
|
69
|
+
{
|
|
70
|
+
server: serverName,
|
|
71
|
+
tools: tools.map((t) => ({ name: t.name, description: t.description ?? "" })),
|
|
72
|
+
},
|
|
73
|
+
null,
|
|
74
|
+
2,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (tools.length === 0) {
|
|
79
|
+
return dim(`No tools found for ${serverName}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const header = cyan.bold(serverName);
|
|
83
|
+
const maxName = Math.max(...tools.map((t) => t.name.length));
|
|
84
|
+
|
|
85
|
+
const lines = tools.map((t) => {
|
|
86
|
+
const name = ` ${bold(t.name.padEnd(maxName))}`;
|
|
87
|
+
if (t.description) {
|
|
88
|
+
return `${name} ${dim(t.description)}`;
|
|
89
|
+
}
|
|
90
|
+
return name;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return [header, ...lines].join("\n");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Format a tool schema */
|
|
97
|
+
export function formatToolSchema(serverName: string, tool: Tool, options: FormatOptions): string {
|
|
98
|
+
if (!isInteractive(options)) {
|
|
99
|
+
return JSON.stringify(
|
|
100
|
+
{
|
|
101
|
+
server: serverName,
|
|
102
|
+
tool: tool.name,
|
|
103
|
+
description: tool.description ?? "",
|
|
104
|
+
inputSchema: tool.inputSchema,
|
|
105
|
+
},
|
|
106
|
+
null,
|
|
107
|
+
2,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const lines: string[] = [];
|
|
112
|
+
lines.push(`${cyan(serverName)}/${bold(tool.name)}`);
|
|
113
|
+
|
|
114
|
+
if (tool.description) {
|
|
115
|
+
lines.push(dim(tool.description));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
lines.push("");
|
|
119
|
+
lines.push(bold("Input Schema:"));
|
|
120
|
+
lines.push(formatSchema(tool.inputSchema, 2));
|
|
121
|
+
|
|
122
|
+
return lines.join("\n");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Format a JSON schema as a readable parameter list */
|
|
126
|
+
function formatSchema(schema: Tool["inputSchema"], indent: number): string {
|
|
127
|
+
const pad = " ".repeat(indent);
|
|
128
|
+
const properties = schema.properties ?? {};
|
|
129
|
+
const required = new Set(schema.required ?? []);
|
|
130
|
+
|
|
131
|
+
if (Object.keys(properties).length === 0) {
|
|
132
|
+
return `${pad}${dim("(no parameters)")}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return Object.entries(properties)
|
|
136
|
+
.map(([name, prop]) => {
|
|
137
|
+
const p = prop as Record<string, unknown>;
|
|
138
|
+
const type = (p.type as string) ?? "any";
|
|
139
|
+
const req = required.has(name) ? red("*") : "";
|
|
140
|
+
const desc = p.description ? ` ${dim(String(p.description))}` : "";
|
|
141
|
+
return `${pad}${green(name)}${req} ${dim(`(${type})`)}${desc}`;
|
|
142
|
+
})
|
|
143
|
+
.join("\n");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Format detailed tool help with example payload */
|
|
147
|
+
export function formatToolHelp(serverName: string, tool: Tool, options: FormatOptions): string {
|
|
148
|
+
if (!isInteractive(options)) {
|
|
149
|
+
return JSON.stringify(
|
|
150
|
+
{
|
|
151
|
+
server: serverName,
|
|
152
|
+
tool: tool.name,
|
|
153
|
+
description: tool.description ?? "",
|
|
154
|
+
inputSchema: tool.inputSchema,
|
|
155
|
+
example: generateExample(tool.inputSchema),
|
|
156
|
+
},
|
|
157
|
+
null,
|
|
158
|
+
2,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const lines: string[] = [];
|
|
163
|
+
lines.push(`${cyan(serverName)}/${bold(tool.name)}`);
|
|
164
|
+
|
|
165
|
+
if (tool.description) {
|
|
166
|
+
lines.push(dim(tool.description));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
lines.push("");
|
|
170
|
+
lines.push(bold("Parameters:"));
|
|
171
|
+
lines.push(formatSchema(tool.inputSchema, 2));
|
|
172
|
+
|
|
173
|
+
const example = generateExample(tool.inputSchema);
|
|
174
|
+
lines.push("");
|
|
175
|
+
lines.push(bold("Example:"));
|
|
176
|
+
lines.push(dim(` mcpcli call ${serverName} ${tool.name} '${JSON.stringify(example)}'`));
|
|
177
|
+
|
|
178
|
+
return lines.join("\n");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Generate an example payload from a JSON schema */
|
|
182
|
+
function generateExample(schema: Tool["inputSchema"]): Record<string, unknown> {
|
|
183
|
+
const properties = schema.properties ?? {};
|
|
184
|
+
const required = new Set(schema.required ?? []);
|
|
185
|
+
const example: Record<string, unknown> = {};
|
|
186
|
+
|
|
187
|
+
for (const [name, prop] of Object.entries(properties)) {
|
|
188
|
+
const p = prop as Record<string, unknown>;
|
|
189
|
+
// Include required fields and first few optional fields
|
|
190
|
+
if (required.has(name) || Object.keys(example).length < 3) {
|
|
191
|
+
example[name] = exampleValue(name, p);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return example;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function exampleValue(name: string, prop: Record<string, unknown>): unknown {
|
|
199
|
+
// Use enum first choice if available
|
|
200
|
+
if (Array.isArray(prop.enum) && prop.enum.length > 0) {
|
|
201
|
+
return prop.enum[0];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Use default if provided
|
|
205
|
+
if (prop.default !== undefined) {
|
|
206
|
+
return prop.default;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const type = prop.type as string | undefined;
|
|
210
|
+
switch (type) {
|
|
211
|
+
case "string":
|
|
212
|
+
return `<${name}>`;
|
|
213
|
+
case "number":
|
|
214
|
+
case "integer":
|
|
215
|
+
return 0;
|
|
216
|
+
case "boolean":
|
|
217
|
+
return true;
|
|
218
|
+
case "array":
|
|
219
|
+
return [];
|
|
220
|
+
case "object":
|
|
221
|
+
return {};
|
|
222
|
+
default:
|
|
223
|
+
return `<${name}>`;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Format a tool call result */
|
|
228
|
+
export function formatCallResult(result: unknown, _options: FormatOptions): string {
|
|
229
|
+
return JSON.stringify(parseNestedJson(result), null, 2);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Recursively parse JSON strings inside MCP content blocks */
|
|
233
|
+
function parseNestedJson(value: unknown): unknown {
|
|
234
|
+
if (typeof value === "string") {
|
|
235
|
+
try {
|
|
236
|
+
return parseNestedJson(JSON.parse(value));
|
|
237
|
+
} catch {
|
|
238
|
+
return value;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (Array.isArray(value)) {
|
|
242
|
+
return value.map(parseNestedJson);
|
|
243
|
+
}
|
|
244
|
+
if (typeof value === "object" && value !== null) {
|
|
245
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, parseNestedJson(v)]));
|
|
246
|
+
}
|
|
247
|
+
return value;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Format validation errors for tool input */
|
|
251
|
+
export function formatValidationErrors(
|
|
252
|
+
serverName: string,
|
|
253
|
+
toolName: string,
|
|
254
|
+
errors: ValidationError[],
|
|
255
|
+
options: FormatOptions,
|
|
256
|
+
): string {
|
|
257
|
+
if (!isInteractive(options)) {
|
|
258
|
+
return JSON.stringify({
|
|
259
|
+
error: "validation",
|
|
260
|
+
server: serverName,
|
|
261
|
+
tool: toolName,
|
|
262
|
+
details: errors,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const header = `${red("error:")} invalid arguments for ${cyan(serverName)}/${bold(toolName)}`;
|
|
267
|
+
const details = errors.map((e) => ` ${yellow(e.path)}: ${e.message}`).join("\n");
|
|
268
|
+
return `${header}\n${details}`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Format search results */
|
|
272
|
+
export function formatSearchResults(results: SearchResult[], options: FormatOptions): string {
|
|
273
|
+
if (!isInteractive(options)) {
|
|
274
|
+
return JSON.stringify(results, null, 2);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (results.length === 0) {
|
|
278
|
+
return dim("No matching tools found");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const maxServer = Math.max(...results.map((r) => r.server.length));
|
|
282
|
+
const maxTool = Math.max(...results.map((r) => r.tool.length));
|
|
283
|
+
|
|
284
|
+
// First line of description only for the main row
|
|
285
|
+
const firstLine = (s: string) => s.split("\n")[0] ?? "";
|
|
286
|
+
|
|
287
|
+
return results
|
|
288
|
+
.map((r) => {
|
|
289
|
+
const server = cyan(r.server.padEnd(maxServer));
|
|
290
|
+
const tool = bold(r.tool.padEnd(maxTool));
|
|
291
|
+
const score = yellow(r.score.toFixed(2).padStart(5));
|
|
292
|
+
const summary = firstLine(r.description);
|
|
293
|
+
const line = `${server} ${tool} ${score} ${dim(summary)}`;
|
|
294
|
+
|
|
295
|
+
// Show remaining description lines indented below
|
|
296
|
+
const descLines = r.description.split("\n").slice(1);
|
|
297
|
+
const extra = descLines.filter((l) => l.trim()).length > 0;
|
|
298
|
+
if (!extra) return line;
|
|
299
|
+
|
|
300
|
+
const indent = " ".repeat(maxServer + maxTool + 12);
|
|
301
|
+
const rest = descLines
|
|
302
|
+
.filter((l) => l.trim())
|
|
303
|
+
.map((l) => `${indent}${dim(l.trim())}`)
|
|
304
|
+
.join("\n");
|
|
305
|
+
return `${line}\n${rest}`;
|
|
306
|
+
})
|
|
307
|
+
.join("\n");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Format an error message */
|
|
311
|
+
export function formatError(message: string, options: FormatOptions): string {
|
|
312
|
+
if (!isInteractive(options)) {
|
|
313
|
+
return JSON.stringify({ error: message });
|
|
314
|
+
}
|
|
315
|
+
return `${red("error:")} ${message}`;
|
|
316
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createSpinner } from "nanospinner";
|
|
2
|
+
import type { FormatOptions } from "./formatter.ts";
|
|
3
|
+
|
|
4
|
+
export interface Spinner {
|
|
5
|
+
update(text: string): void;
|
|
6
|
+
success(text?: string): void;
|
|
7
|
+
error(text?: string): void;
|
|
8
|
+
stop(): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Create a spinner that only renders in interactive mode */
|
|
12
|
+
export function startSpinner(text: string, options: FormatOptions): Spinner {
|
|
13
|
+
// No spinner in JSON/piped mode
|
|
14
|
+
if (options.json || !(process.stderr.isTTY ?? false)) {
|
|
15
|
+
return {
|
|
16
|
+
update() {},
|
|
17
|
+
success() {},
|
|
18
|
+
error() {},
|
|
19
|
+
stop() {},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const spinner = createSpinner(text, { stream: process.stderr }).start();
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
update(text: string) {
|
|
27
|
+
spinner.update({ text });
|
|
28
|
+
},
|
|
29
|
+
success(text?: string) {
|
|
30
|
+
spinner.success({ text });
|
|
31
|
+
},
|
|
32
|
+
error(text?: string) {
|
|
33
|
+
spinner.error({ text });
|
|
34
|
+
},
|
|
35
|
+
stop() {
|
|
36
|
+
spinner.stop();
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { SearchIndex } from "../config/schemas.ts";
|
|
2
|
+
import { keywordSearch } from "./keyword.ts";
|
|
3
|
+
import { semanticSearch } from "./semantic.ts";
|
|
4
|
+
|
|
5
|
+
export interface SearchResult {
|
|
6
|
+
server: string;
|
|
7
|
+
tool: string;
|
|
8
|
+
description: string;
|
|
9
|
+
score: number;
|
|
10
|
+
matchType: "keyword" | "semantic" | "both";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SearchOptions {
|
|
14
|
+
keywordOnly?: boolean;
|
|
15
|
+
semanticOnly?: boolean;
|
|
16
|
+
topK?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Search tools using keyword and/or semantic matching */
|
|
20
|
+
export async function search(
|
|
21
|
+
query: string,
|
|
22
|
+
index: SearchIndex,
|
|
23
|
+
options: SearchOptions = {},
|
|
24
|
+
): Promise<SearchResult[]> {
|
|
25
|
+
const topK = options.topK ?? 10;
|
|
26
|
+
const results = new Map<string, SearchResult>();
|
|
27
|
+
|
|
28
|
+
const runKeyword = !options.semanticOnly;
|
|
29
|
+
const runSemantic = !options.keywordOnly;
|
|
30
|
+
|
|
31
|
+
// Keyword search
|
|
32
|
+
if (runKeyword) {
|
|
33
|
+
const matches = keywordSearch(query, index.tools);
|
|
34
|
+
for (const m of matches) {
|
|
35
|
+
const key = `${m.server}/${m.tool}`;
|
|
36
|
+
results.set(key, {
|
|
37
|
+
server: m.server,
|
|
38
|
+
tool: m.tool,
|
|
39
|
+
description: m.description,
|
|
40
|
+
score: m.score,
|
|
41
|
+
matchType: "keyword",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Semantic search
|
|
47
|
+
if (runSemantic && index.tools.some((t) => t.embedding.length > 0)) {
|
|
48
|
+
const matches = await semanticSearch(query, index.tools, topK);
|
|
49
|
+
for (const m of matches) {
|
|
50
|
+
const key = `${m.server}/${m.tool}`;
|
|
51
|
+
const existing = results.get(key);
|
|
52
|
+
if (existing) {
|
|
53
|
+
// Combine scores: keyword 0.4 + semantic 0.6
|
|
54
|
+
existing.score = existing.score * 0.4 + m.score * 0.6;
|
|
55
|
+
existing.matchType = "both";
|
|
56
|
+
} else {
|
|
57
|
+
results.set(key, {
|
|
58
|
+
server: m.server,
|
|
59
|
+
tool: m.tool,
|
|
60
|
+
description: m.description,
|
|
61
|
+
score: m.score,
|
|
62
|
+
matchType: "semantic",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return [...results.values()].sort((a, b) => b.score - a.score).slice(0, topK);
|
|
69
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { ServerManager, ToolWithServer } from "../client/manager.ts";
|
|
2
|
+
import type { SearchIndex, IndexedTool } from "../config/schemas.ts";
|
|
3
|
+
import { generateEmbedding } from "./semantic.ts";
|
|
4
|
+
|
|
5
|
+
/** Extract keywords from a tool name by splitting on separators and camelCase */
|
|
6
|
+
export function extractKeywords(name: string): string[] {
|
|
7
|
+
// Split on underscores, hyphens, dots
|
|
8
|
+
const parts = name.split(/[_\-.]+/);
|
|
9
|
+
|
|
10
|
+
// Also split camelCase
|
|
11
|
+
const words: string[] = [];
|
|
12
|
+
for (const part of parts) {
|
|
13
|
+
words.push(...part.replace(/([a-z])([A-Z])/g, "$1 $2").split(/\s+/));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return words.map((w) => w.toLowerCase()).filter((w) => w.length > 1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Generate scenario phrases from tool name and description */
|
|
20
|
+
export function generateScenarios(name: string, description: string): string[] {
|
|
21
|
+
const scenarios: string[] = [];
|
|
22
|
+
|
|
23
|
+
// Use description as-is if short enough
|
|
24
|
+
if (description && description.length < 200) {
|
|
25
|
+
scenarios.push(description);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Extract action + noun from tool name (e.g., "SendMessage" → "send a message")
|
|
29
|
+
const keywords = extractKeywords(name);
|
|
30
|
+
if (keywords.length >= 2) {
|
|
31
|
+
scenarios.push(keywords.join(" "));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return scenarios;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Build an IndexedTool from a tool with server info */
|
|
38
|
+
async function indexTool(t: ToolWithServer): Promise<IndexedTool> {
|
|
39
|
+
const description = t.tool.description ?? "";
|
|
40
|
+
const keywords = extractKeywords(t.tool.name);
|
|
41
|
+
const scenarios = generateScenarios(t.tool.name, description);
|
|
42
|
+
|
|
43
|
+
// Build text for embedding: combine name, description, and scenarios
|
|
44
|
+
const embeddingText = [t.tool.name, description, ...scenarios].filter(Boolean).join(" ");
|
|
45
|
+
const embedding = await generateEmbedding(embeddingText);
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
server: t.server,
|
|
49
|
+
tool: t.tool.name,
|
|
50
|
+
description,
|
|
51
|
+
input_schema: t.tool.inputSchema,
|
|
52
|
+
scenarios,
|
|
53
|
+
keywords,
|
|
54
|
+
embedding,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface IndexProgress {
|
|
59
|
+
total: number;
|
|
60
|
+
current: number;
|
|
61
|
+
tool: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Build a search index from all configured servers */
|
|
65
|
+
export async function buildSearchIndex(
|
|
66
|
+
manager: ServerManager,
|
|
67
|
+
onProgress?: (progress: IndexProgress) => void,
|
|
68
|
+
): Promise<SearchIndex> {
|
|
69
|
+
const { tools, errors } = await manager.getAllTools();
|
|
70
|
+
|
|
71
|
+
if (errors.length > 0) {
|
|
72
|
+
for (const err of errors) {
|
|
73
|
+
process.stderr.write(`warning: ${err.server}: ${err.message}\n`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const indexed: IndexedTool[] = [];
|
|
78
|
+
|
|
79
|
+
for (let i = 0; i < tools.length; i++) {
|
|
80
|
+
const t = tools[i]!;
|
|
81
|
+
onProgress?.({ total: tools.length, current: i + 1, tool: `${t.server}/${t.tool.name}` });
|
|
82
|
+
indexed.push(await indexTool(t));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
version: 1,
|
|
87
|
+
indexed_at: new Date().toISOString(),
|
|
88
|
+
embedding_model: "Xenova/all-MiniLM-L6-v2",
|
|
89
|
+
tools: indexed,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import picomatch from "picomatch";
|
|
2
|
+
import type { IndexedTool } from "../config/schemas.ts";
|
|
3
|
+
|
|
4
|
+
export interface KeywordMatch {
|
|
5
|
+
server: string;
|
|
6
|
+
tool: string;
|
|
7
|
+
description: string;
|
|
8
|
+
score: number;
|
|
9
|
+
matchedField: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface FieldWeight {
|
|
13
|
+
field: string;
|
|
14
|
+
weight: number;
|
|
15
|
+
values: (t: IndexedTool) => string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const FIELDS: FieldWeight[] = [
|
|
19
|
+
{ field: "name", weight: 1.0, values: (t) => [t.tool] },
|
|
20
|
+
{ field: "keyword", weight: 0.8, values: (t) => t.keywords },
|
|
21
|
+
{ field: "scenario", weight: 0.6, values: (t) => t.scenarios },
|
|
22
|
+
{ field: "description", weight: 0.4, values: (t) => [t.description] },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/** Check if query looks like a glob pattern */
|
|
26
|
+
function isGlob(query: string): boolean {
|
|
27
|
+
return /[*?[\]{}]/.test(query);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Search indexed tools by keyword/glob matching */
|
|
31
|
+
export function keywordSearch(query: string, tools: IndexedTool[]): KeywordMatch[] {
|
|
32
|
+
const queryLower = query.toLowerCase();
|
|
33
|
+
const tokens = queryLower.split(/\s+/).filter(Boolean);
|
|
34
|
+
|
|
35
|
+
// If any token is a glob, use picomatch for name matching
|
|
36
|
+
const globTokens = tokens.filter(isGlob);
|
|
37
|
+
const textTokens = tokens.filter((t) => !isGlob(t));
|
|
38
|
+
const globMatcher = globTokens.length > 0 ? picomatch(globTokens, { nocase: true }) : null;
|
|
39
|
+
|
|
40
|
+
const results: KeywordMatch[] = [];
|
|
41
|
+
|
|
42
|
+
for (const tool of tools) {
|
|
43
|
+
let bestScore = 0;
|
|
44
|
+
let bestField = "";
|
|
45
|
+
|
|
46
|
+
// Glob matching against tool name
|
|
47
|
+
if (globMatcher && globMatcher(tool.tool)) {
|
|
48
|
+
bestScore = 1.0;
|
|
49
|
+
bestField = "name";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Text token matching against all fields
|
|
53
|
+
if (textTokens.length > 0) {
|
|
54
|
+
for (const { field, weight, values } of FIELDS) {
|
|
55
|
+
const fieldValues = values(tool).map((v) => v.toLowerCase());
|
|
56
|
+
let matchCount = 0;
|
|
57
|
+
|
|
58
|
+
for (const token of textTokens) {
|
|
59
|
+
if (fieldValues.some((v) => v.includes(token))) {
|
|
60
|
+
matchCount++;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (matchCount > 0) {
|
|
65
|
+
const score = (matchCount / textTokens.length) * weight;
|
|
66
|
+
if (score > bestScore) {
|
|
67
|
+
bestScore = score;
|
|
68
|
+
bestField = field;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (bestScore > 0) {
|
|
75
|
+
results.push({
|
|
76
|
+
server: tool.server,
|
|
77
|
+
tool: tool.tool,
|
|
78
|
+
description: tool.description,
|
|
79
|
+
score: bestScore,
|
|
80
|
+
matchedField: bestField,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return results.sort((a, b) => b.score - a.score);
|
|
86
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { IndexedTool } from "../config/schemas.ts";
|
|
2
|
+
|
|
3
|
+
export interface SemanticMatch {
|
|
4
|
+
server: string;
|
|
5
|
+
tool: string;
|
|
6
|
+
description: string;
|
|
7
|
+
score: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Lazy-loaded pipeline singleton
|
|
11
|
+
let pipelineInstance: ((text: string) => Promise<Float32Array>) | null = null;
|
|
12
|
+
|
|
13
|
+
/** Get or create the embedding pipeline */
|
|
14
|
+
async function getEmbedder(): Promise<(text: string) => Promise<Float32Array>> {
|
|
15
|
+
if (pipelineInstance) return pipelineInstance;
|
|
16
|
+
|
|
17
|
+
const { pipeline } = await import("@huggingface/transformers");
|
|
18
|
+
const extractor = await pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2", {
|
|
19
|
+
dtype: "fp32",
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
pipelineInstance = async (text: string): Promise<Float32Array> => {
|
|
23
|
+
const output = await extractor(text, { pooling: "mean", normalize: true });
|
|
24
|
+
// output.data is a Float32Array of the pooled embedding
|
|
25
|
+
return output.data as Float32Array;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
return pipelineInstance;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Generate an embedding vector for text */
|
|
32
|
+
export async function generateEmbedding(text: string): Promise<number[]> {
|
|
33
|
+
const embed = await getEmbedder();
|
|
34
|
+
const vec = await embed(text);
|
|
35
|
+
return Array.from(vec);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Cosine similarity between two vectors */
|
|
39
|
+
export function cosineSimilarity(a: number[], b: number[]): number {
|
|
40
|
+
if (a.length !== b.length || a.length === 0) return 0;
|
|
41
|
+
|
|
42
|
+
let dot = 0;
|
|
43
|
+
let magA = 0;
|
|
44
|
+
let magB = 0;
|
|
45
|
+
for (let i = 0; i < a.length; i++) {
|
|
46
|
+
dot += a[i]! * b[i]!;
|
|
47
|
+
magA += a[i]! * a[i]!;
|
|
48
|
+
magB += b[i]! * b[i]!;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const denom = Math.sqrt(magA) * Math.sqrt(magB);
|
|
52
|
+
return denom === 0 ? 0 : dot / denom;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Search indexed tools by semantic similarity */
|
|
56
|
+
export async function semanticSearch(
|
|
57
|
+
query: string,
|
|
58
|
+
tools: IndexedTool[],
|
|
59
|
+
topK = 10,
|
|
60
|
+
): Promise<SemanticMatch[]> {
|
|
61
|
+
// Only search tools that have embeddings
|
|
62
|
+
const withEmbeddings = tools.filter((t) => t.embedding.length > 0);
|
|
63
|
+
if (withEmbeddings.length === 0) return [];
|
|
64
|
+
|
|
65
|
+
const queryEmbedding = await generateEmbedding(query);
|
|
66
|
+
|
|
67
|
+
const scored = withEmbeddings.map((tool) => ({
|
|
68
|
+
server: tool.server,
|
|
69
|
+
tool: tool.tool,
|
|
70
|
+
description: tool.description,
|
|
71
|
+
score: cosineSimilarity(queryEmbedding, tool.embedding),
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
return scored.sort((a, b) => b.score - a.score).slice(0, topK);
|
|
75
|
+
}
|