@utdk/mcp 0.1.0-dev.646adf4
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/.turbo/turbo-build.log +4 -0
- package/LICENSE +373 -0
- package/README.md +81 -0
- package/dist/__tests__/auth.test.js +63 -0
- package/dist/__tests__/auth.test.js.map +1 -0
- package/dist/__tests__/loader.test.js +205 -0
- package/dist/__tests__/loader.test.js.map +1 -0
- package/dist/__tests__/search.test.js +204 -0
- package/dist/__tests__/search.test.js.map +1 -0
- package/dist/auth.js +82 -0
- package/dist/auth.js.map +1 -0
- package/dist/loader.js +211 -0
- package/dist/loader.js.map +1 -0
- package/dist/search.js +143 -0
- package/dist/search.js.map +1 -0
- package/dist/server.js +294 -0
- package/dist/server.js.map +1 -0
- package/package.json +60 -0
- package/src/__tests__/auth.test.ts +78 -0
- package/src/__tests__/loader.test.ts +264 -0
- package/src/__tests__/search.test.ts +243 -0
- package/src/auth.ts +111 -0
- package/src/loader.ts +309 -0
- package/src/search.ts +175 -0
- package/src/server.ts +362 -0
- package/tsconfig.json +13 -0
- package/vitest.config.ts +9 -0
package/src/auth.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth chain for @utdk/mcp-server.
|
|
3
|
+
*
|
|
4
|
+
* Resolves credentials in priority order: environment variables → gateway proxy.
|
|
5
|
+
* Auth configuration is read from the provider package's `utdk.auth` field.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { ApiKey, BearerToken, OAuth2ClientCredentials } from "@utdk/common";
|
|
9
|
+
import type { AuthProvider } from "@utdk/common";
|
|
10
|
+
|
|
11
|
+
export type { AuthProvider };
|
|
12
|
+
|
|
13
|
+
export interface UtdkAuthConfig {
|
|
14
|
+
auth_type: string;
|
|
15
|
+
api_key?: string;
|
|
16
|
+
var_name?: string;
|
|
17
|
+
location?: string;
|
|
18
|
+
token_url?: string;
|
|
19
|
+
client_id?: string;
|
|
20
|
+
client_secret?: string;
|
|
21
|
+
scope?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Interpolate `${ENV_VAR}` placeholders in a string with actual environment values.
|
|
26
|
+
* Returns null if any placeholder resolved to an empty or missing env var.
|
|
27
|
+
*/
|
|
28
|
+
function interpolateEnv(value: string): string | null {
|
|
29
|
+
let hasEmpty = false;
|
|
30
|
+
const result = value.replace(/\$\{([^}]+)\}/g, (_match, varName: string) => {
|
|
31
|
+
const envValue = process.env[varName];
|
|
32
|
+
if (!envValue) {
|
|
33
|
+
hasEmpty = true;
|
|
34
|
+
}
|
|
35
|
+
return envValue ?? "";
|
|
36
|
+
});
|
|
37
|
+
return hasEmpty ? null : result;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build an AuthProvider from a provider's `utdk.auth` config entry and env vars.
|
|
42
|
+
* Returns undefined if no credentials are found in the environment.
|
|
43
|
+
*/
|
|
44
|
+
function authFromConfig(config: UtdkAuthConfig): AuthProvider | undefined {
|
|
45
|
+
const authType = config.auth_type;
|
|
46
|
+
|
|
47
|
+
if (authType === "api_key" && config.api_key) {
|
|
48
|
+
const resolved = interpolateEnv(config.api_key);
|
|
49
|
+
if (resolved === null) return undefined;
|
|
50
|
+
|
|
51
|
+
const trimmed = resolved.trim();
|
|
52
|
+
if (!trimmed) return undefined;
|
|
53
|
+
|
|
54
|
+
const varName = config.var_name ?? "Authorization";
|
|
55
|
+
const location = config.location ?? "header";
|
|
56
|
+
|
|
57
|
+
if (location !== "header") {
|
|
58
|
+
// Query/cookie auth not yet supported in this chain
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// If the value looks like "Bearer <token>", use BearerToken; otherwise ApiKey
|
|
63
|
+
const bearerMatch = /^Bearer\s+(\S.*)$/.exec(trimmed);
|
|
64
|
+
if (bearerMatch) {
|
|
65
|
+
return new BearerToken((bearerMatch[1] as string).trim());
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return new ApiKey({ headerName: varName, value: trimmed });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (authType === "oauth2") {
|
|
72
|
+
const clientId = config.client_id ? interpolateEnv(config.client_id) : null;
|
|
73
|
+
const clientSecret = config.client_secret ? interpolateEnv(config.client_secret) : null;
|
|
74
|
+
|
|
75
|
+
if (clientId && clientSecret && config.token_url) {
|
|
76
|
+
return new OAuth2ClientCredentials({
|
|
77
|
+
clientId,
|
|
78
|
+
clientSecret,
|
|
79
|
+
tokenUrl: config.token_url,
|
|
80
|
+
scopes: config.scope?.split(/\s+/) ?? [],
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Build an AuthProvider for a provider from its utdk auth config array.
|
|
92
|
+
* Tries each config entry in order; returns the first that resolves from env.
|
|
93
|
+
* Returns undefined if no credentials are available (unauthenticated or gateway-delegated).
|
|
94
|
+
*/
|
|
95
|
+
export function buildAuthProvider(
|
|
96
|
+
providerName: string,
|
|
97
|
+
authConfigs: UtdkAuthConfig[],
|
|
98
|
+
): AuthProvider | undefined {
|
|
99
|
+
// First try the explicit provider TOKEN env var (e.g. GITHUB_TOKEN, SLACK_TOKEN)
|
|
100
|
+
const providerToken = process.env[`${providerName.toUpperCase()}_TOKEN`];
|
|
101
|
+
if (providerToken) {
|
|
102
|
+
return new BearerToken(providerToken);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (const config of authConfigs) {
|
|
106
|
+
const provider = authFromConfig(config);
|
|
107
|
+
if (provider) return provider;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
package/src/loader.ts
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic provider loader for @utdk/mcp-server.
|
|
3
|
+
*
|
|
4
|
+
* Loads OpenAPI documents and auth configuration from @utdk/* provider packages.
|
|
5
|
+
* Converts each OpenAPI spec to MCP-compatible tool definitions using OpenApiConverter.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { OpenApiConverter } from "@utcp/http";
|
|
9
|
+
import type { Tool } from "@utcp/sdk";
|
|
10
|
+
import type { AuthProvider } from "@utdk/common";
|
|
11
|
+
import { withSpan, configureTelemetry } from "@utdk/common";
|
|
12
|
+
|
|
13
|
+
import { buildAuthProvider } from "./auth.js";
|
|
14
|
+
import type { UtdkAuthConfig } from "./auth.js";
|
|
15
|
+
|
|
16
|
+
export interface ProviderTool {
|
|
17
|
+
/** MCP tool name (e.g. "github__repos_list") */
|
|
18
|
+
mcpName: string;
|
|
19
|
+
/** Original UTCP tool name (e.g. "github.repos/list") */
|
|
20
|
+
utcpName: string;
|
|
21
|
+
description: string;
|
|
22
|
+
/** JSON Schema for the tool's inputs */
|
|
23
|
+
inputSchema: Record<string, unknown>;
|
|
24
|
+
/** Provider name (e.g. "github") */
|
|
25
|
+
providerName: string;
|
|
26
|
+
/** OpenAPI operation tags (e.g. ["repos", "issues"]) */
|
|
27
|
+
tags: string[];
|
|
28
|
+
/** Transport method (e.g. "GET", "POST") */
|
|
29
|
+
method: string;
|
|
30
|
+
/** Route template with {param} placeholders */
|
|
31
|
+
routeTemplate: string;
|
|
32
|
+
/** Content-Type for requests */
|
|
33
|
+
contentType: string;
|
|
34
|
+
/** Parameter keys that appear in the URL path */
|
|
35
|
+
pathParamKeys: string[];
|
|
36
|
+
/** Parameter keys that should go in query string for GET/DELETE */
|
|
37
|
+
queryParamKeys: string[];
|
|
38
|
+
/** Auth provider for this provider */
|
|
39
|
+
auth: AuthProvider | undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface UtdkPackageJson {
|
|
43
|
+
name: string;
|
|
44
|
+
utdk?: {
|
|
45
|
+
provider: string;
|
|
46
|
+
auth?: UtdkAuthConfig[];
|
|
47
|
+
openapi?: {
|
|
48
|
+
title?: string;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface TransportCallTemplate {
|
|
54
|
+
call_template_type: string;
|
|
55
|
+
http_method?: string;
|
|
56
|
+
url?: string;
|
|
57
|
+
content_type?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Extract path parameter keys from a route template like "/repos/{owner}/{repo}".
|
|
62
|
+
*/
|
|
63
|
+
function extractPathParams(routeTemplate: string): string[] {
|
|
64
|
+
const matches = routeTemplate.match(/\{([^}]+)\}/g) ?? [];
|
|
65
|
+
return matches.map((m) => m.slice(1, -1));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Convert a UTCP tool name to a safe MCP tool name.
|
|
70
|
+
* MCP tool names must match [a-zA-Z0-9_-]+ .
|
|
71
|
+
* e.g. "github.repos/list" → "github__repos_list"
|
|
72
|
+
*/
|
|
73
|
+
function toMcpToolName(utcpName: string): string {
|
|
74
|
+
return utcpName.replace(/\./g, "__").replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Load a provider package's OpenAPI document and package metadata.
|
|
79
|
+
* Returns null if the package cannot be found or loaded.
|
|
80
|
+
*/
|
|
81
|
+
async function loadProviderPackage(providerName: string): Promise<{
|
|
82
|
+
openApiDoc: Record<string, unknown>;
|
|
83
|
+
packageJson: UtdkPackageJson;
|
|
84
|
+
} | null> {
|
|
85
|
+
const packageName = `@utdk/${providerName}`;
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
// Dynamically import the provider's openapi.json via the package path
|
|
89
|
+
// Provider packages expose their openapi.json as a JSON import
|
|
90
|
+
const [openApiMod, pkgMod] = await Promise.all([
|
|
91
|
+
import(`${packageName}/openapi.json`, { with: { type: "json" } }).catch(() => null),
|
|
92
|
+
import(`${packageName}/package.json`, { with: { type: "json" } }).catch(() => null),
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
if (!openApiMod || !pkgMod) {
|
|
96
|
+
// Try loading the whole package and see if it exposes the doc
|
|
97
|
+
process.stderr.write(
|
|
98
|
+
`[mcp-server] Warning: could not import openapi.json or package.json for ${packageName}\n`,
|
|
99
|
+
);
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
openApiDoc: (openApiMod as { default: Record<string, unknown> }).default as Record<string, unknown>,
|
|
105
|
+
packageJson: (pkgMod as { default: UtdkPackageJson }).default as UtdkPackageJson,
|
|
106
|
+
};
|
|
107
|
+
} catch (err) {
|
|
108
|
+
process.stderr.write(
|
|
109
|
+
`[mcp-server] Warning: failed to load provider ${providerName}: ${err}\n`,
|
|
110
|
+
);
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Convert a loaded provider's OpenAPI doc into MCP-ready tool definitions.
|
|
117
|
+
*/
|
|
118
|
+
function buildProviderTools(
|
|
119
|
+
providerName: string,
|
|
120
|
+
openApiDoc: Record<string, unknown>,
|
|
121
|
+
auth: AuthProvider | undefined,
|
|
122
|
+
): ProviderTool[] {
|
|
123
|
+
const converter = new OpenApiConverter(openApiDoc, {
|
|
124
|
+
callTemplateName: providerName,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const manual = converter.convert();
|
|
128
|
+
const tools: ProviderTool[] = [];
|
|
129
|
+
|
|
130
|
+
for (const tool of manual.tools) {
|
|
131
|
+
const template = tool.tool_call_template as TransportCallTemplate | undefined;
|
|
132
|
+
if (!template) continue;
|
|
133
|
+
|
|
134
|
+
const routeTemplate = template.url ?? "/";
|
|
135
|
+
const method = (template.http_method ?? "GET").toUpperCase();
|
|
136
|
+
const contentType = template.content_type ?? "application/json";
|
|
137
|
+
const pathParamKeys = extractPathParams(routeTemplate);
|
|
138
|
+
|
|
139
|
+
// Determine which input properties go in query params vs body
|
|
140
|
+
// For GET/HEAD/DELETE, non-path params → query string
|
|
141
|
+
// For POST/PUT/PATCH, non-path params → body
|
|
142
|
+
const inputProperties = (tool.inputs?.properties ?? {}) as Record<string, unknown>;
|
|
143
|
+
const allInputKeys = Object.keys(inputProperties);
|
|
144
|
+
const isBodyMethod = ["POST", "PUT", "PATCH"].includes(method);
|
|
145
|
+
const queryParamKeys = isBodyMethod ? [] : allInputKeys.filter((k) => !pathParamKeys.includes(k));
|
|
146
|
+
|
|
147
|
+
tools.push({
|
|
148
|
+
mcpName: toMcpToolName(tool.name),
|
|
149
|
+
utcpName: tool.name,
|
|
150
|
+
description: tool.description ?? `${method} ${routeTemplate}`,
|
|
151
|
+
inputSchema: tool.inputs as Record<string, unknown>,
|
|
152
|
+
providerName,
|
|
153
|
+
tags: (tool.tags as string[] | undefined) ?? [],
|
|
154
|
+
method,
|
|
155
|
+
routeTemplate,
|
|
156
|
+
contentType,
|
|
157
|
+
pathParamKeys,
|
|
158
|
+
queryParamKeys,
|
|
159
|
+
auth,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return tools;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Load all tools for a list of provider names.
|
|
168
|
+
* Providers that fail to load are skipped with a warning.
|
|
169
|
+
*/
|
|
170
|
+
export async function loadProviders(providerNames: string[]): Promise<ProviderTool[]> {
|
|
171
|
+
const allTools: ProviderTool[] = [];
|
|
172
|
+
|
|
173
|
+
await Promise.all(
|
|
174
|
+
providerNames.map(async (providerName) => {
|
|
175
|
+
const pkg = await loadProviderPackage(providerName);
|
|
176
|
+
if (!pkg) return;
|
|
177
|
+
|
|
178
|
+
const authConfigs = pkg.packageJson.utdk?.auth ?? [];
|
|
179
|
+
const auth = buildAuthProvider(providerName, authConfigs);
|
|
180
|
+
|
|
181
|
+
const tools = buildProviderTools(providerName, pkg.openApiDoc, auth);
|
|
182
|
+
process.stderr.write(
|
|
183
|
+
`[mcp-server] Loaded ${tools.length} tools from @utdk/${providerName}\n`,
|
|
184
|
+
);
|
|
185
|
+
allTools.push(...tools);
|
|
186
|
+
}),
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
return allTools;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Parse the UTDK_PROVIDERS environment variable.
|
|
194
|
+
* Returns an array of provider names (e.g. ["github", "slack", "stripe"]).
|
|
195
|
+
*/
|
|
196
|
+
export function parseProviderNames(envValue: string | undefined): string[] {
|
|
197
|
+
if (!envValue?.trim()) return [];
|
|
198
|
+
return envValue
|
|
199
|
+
.split(",")
|
|
200
|
+
.map((name) => name.trim().toLowerCase())
|
|
201
|
+
.filter(Boolean);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Initialize telemetry if UTDK_OTEL_EXPORTER is set.
|
|
206
|
+
*/
|
|
207
|
+
export async function initTelemetry(): Promise<void> {
|
|
208
|
+
const exporter = process.env["UTDK_OTEL_EXPORTER"];
|
|
209
|
+
if (exporter === "otlp" || exporter === "console") {
|
|
210
|
+
await configureTelemetry({ enabled: true, exporter });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Execute a tool call with telemetry and auth.
|
|
216
|
+
*/
|
|
217
|
+
export async function executeTool(
|
|
218
|
+
tool: ProviderTool,
|
|
219
|
+
args: Record<string, unknown>,
|
|
220
|
+
): Promise<unknown> {
|
|
221
|
+
return withSpan(
|
|
222
|
+
{
|
|
223
|
+
provider: tool.providerName,
|
|
224
|
+
operation: tool.utcpName,
|
|
225
|
+
spanName: `utdk.mcp.tool_call`,
|
|
226
|
+
},
|
|
227
|
+
async (span) => {
|
|
228
|
+
span.setAttribute("utdk.provider", tool.providerName);
|
|
229
|
+
span.setAttribute("utdk.tool", tool.utcpName);
|
|
230
|
+
span.setAttribute("mcp.tool_name", tool.mcpName);
|
|
231
|
+
|
|
232
|
+
// Build URL by substituting path parameters
|
|
233
|
+
let url = tool.routeTemplate;
|
|
234
|
+
const remainingArgs: Record<string, unknown> = { ...args };
|
|
235
|
+
|
|
236
|
+
for (const pathKey of tool.pathParamKeys) {
|
|
237
|
+
const value = remainingArgs[pathKey];
|
|
238
|
+
if (value !== undefined) {
|
|
239
|
+
url = url.replace(`{${pathKey}}`, encodeURIComponent(String(value)));
|
|
240
|
+
delete remainingArgs[pathKey];
|
|
241
|
+
} else {
|
|
242
|
+
url = url.replace(`{${pathKey}}`, "");
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Build query string for GET-style methods
|
|
247
|
+
const isBodyMethod = ["POST", "PUT", "PATCH"].includes(tool.method);
|
|
248
|
+
let body: string | undefined;
|
|
249
|
+
const requestHeaders: Record<string, string> = {
|
|
250
|
+
"Content-Type": tool.contentType,
|
|
251
|
+
"User-Agent": "@utdk/mcp-server/0.1.0",
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
if (isBodyMethod) {
|
|
255
|
+
const bodyObj: Record<string, unknown> = {};
|
|
256
|
+
for (const [k, v] of Object.entries(remainingArgs)) {
|
|
257
|
+
bodyObj[k] = v;
|
|
258
|
+
}
|
|
259
|
+
if (Object.keys(bodyObj).length > 0) {
|
|
260
|
+
body = JSON.stringify(bodyObj);
|
|
261
|
+
}
|
|
262
|
+
} else {
|
|
263
|
+
// Query params
|
|
264
|
+
const urlObj = new URL(url);
|
|
265
|
+
for (const [k, v] of Object.entries(remainingArgs)) {
|
|
266
|
+
if (v !== undefined && v !== null) {
|
|
267
|
+
urlObj.searchParams.append(k, String(v));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
url = urlObj.toString();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Apply auth
|
|
274
|
+
if (tool.auth) {
|
|
275
|
+
await tool.auth.authenticate(requestHeaders);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
span.setAttribute("http.method", tool.method);
|
|
279
|
+
span.setAttribute("http.url", url);
|
|
280
|
+
|
|
281
|
+
const response = await fetch(url, {
|
|
282
|
+
method: tool.method,
|
|
283
|
+
headers: requestHeaders,
|
|
284
|
+
body,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
span.setAttribute("http.status_code", response.status);
|
|
288
|
+
|
|
289
|
+
if (!response.ok) {
|
|
290
|
+
const errorBody = await response.text();
|
|
291
|
+
throw new Error(
|
|
292
|
+
`Tool call failed: ${response.status} ${response.statusText}${errorBody ? `\n${errorBody}` : ""}`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Parse response
|
|
297
|
+
if (response.status === 204 || response.status === 205) {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
302
|
+
if (contentType.includes("json")) {
|
|
303
|
+
return response.json();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return response.text();
|
|
307
|
+
},
|
|
308
|
+
);
|
|
309
|
+
}
|
package/src/search.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search and tokenization utilities for the MCP server wrapper tools.
|
|
3
|
+
*
|
|
4
|
+
* Extracted into a separate module so they can be unit-tested without
|
|
5
|
+
* importing the full server entry-point (which starts stdio transport).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ProviderTool } from "./loader.js";
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Tokenization
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Tokenize text into lowercase words, splitting on non-alphanumeric characters.
|
|
16
|
+
* Also splits camelCase boundaries for better matching of MCP tool names.
|
|
17
|
+
*
|
|
18
|
+
* Examples:
|
|
19
|
+
* "github__repos_list" → ["github", "repos", "list"]
|
|
20
|
+
* "List public repositories" → ["list", "public", "repositories"]
|
|
21
|
+
* "security-advisories" → ["security", "advisories"]
|
|
22
|
+
*/
|
|
23
|
+
export function tokenize(text: string): string[] {
|
|
24
|
+
return text
|
|
25
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2") // split camelCase boundaries
|
|
26
|
+
.toLowerCase()
|
|
27
|
+
.split(/[^a-z0-9]+/)
|
|
28
|
+
.filter(Boolean);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// TF-IDF scoring
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build a TF-IDF scoring function over a corpus of tools.
|
|
37
|
+
*
|
|
38
|
+
* Field weights applied during term-frequency calculation:
|
|
39
|
+
* - name × 3 (most signal)
|
|
40
|
+
* - tags × 2 (category signal)
|
|
41
|
+
* - description × 1
|
|
42
|
+
*
|
|
43
|
+
* IDF formula: log((N + 1) / df) — smoothed to avoid division-by-zero.
|
|
44
|
+
*
|
|
45
|
+
* Returns a function `score(tool, queryWords) → number` that can be called
|
|
46
|
+
* for each candidate. Build once per search call, reuse across candidates.
|
|
47
|
+
*/
|
|
48
|
+
export function buildTfIdf(
|
|
49
|
+
tools: ProviderTool[],
|
|
50
|
+
): (tool: ProviderTool, words: string[]) => number {
|
|
51
|
+
const N = tools.length;
|
|
52
|
+
if (N === 0) return () => 0;
|
|
53
|
+
|
|
54
|
+
type TokenBag = Map<string, number>;
|
|
55
|
+
|
|
56
|
+
// Precompute weighted token bags for each tool
|
|
57
|
+
const bags: TokenBag[] = tools.map((tool) => {
|
|
58
|
+
const bag: TokenBag = new Map();
|
|
59
|
+
const add = (tokens: string[], weight: number) => {
|
|
60
|
+
for (const tok of tokens) {
|
|
61
|
+
bag.set(tok, (bag.get(tok) ?? 0) + weight);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
add(tokenize(tool.mcpName), 3);
|
|
65
|
+
for (const tag of tool.tags) {
|
|
66
|
+
add(tokenize(tag), 2);
|
|
67
|
+
}
|
|
68
|
+
add(tokenize(tool.description), 1);
|
|
69
|
+
return bag;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Document frequency: how many tools contain each token
|
|
73
|
+
const df = new Map<string, number>();
|
|
74
|
+
for (const bag of bags) {
|
|
75
|
+
for (const tok of bag.keys()) {
|
|
76
|
+
df.set(tok, (df.get(tok) ?? 0) + 1);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const bagIndex = new Map<ProviderTool, TokenBag>(tools.map((t, i) => [t, bags[i]!]));
|
|
81
|
+
|
|
82
|
+
return (tool: ProviderTool, words: string[]): number => {
|
|
83
|
+
const bag = bagIndex.get(tool);
|
|
84
|
+
if (!bag) return 0;
|
|
85
|
+
const totalWeight = [...bag.values()].reduce((s, v) => s + v, 0) || 1;
|
|
86
|
+
let score = 0;
|
|
87
|
+
for (const word of words) {
|
|
88
|
+
const tf = (bag.get(word) ?? 0) / totalWeight;
|
|
89
|
+
const docFreq = df.get(word) ?? 0;
|
|
90
|
+
if (docFreq === 0) continue;
|
|
91
|
+
const idf = Math.log((N + 1) / docFreq);
|
|
92
|
+
score += tf * idf;
|
|
93
|
+
}
|
|
94
|
+
return score;
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// searchTools
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Search tools by keyword using TF-IDF relevance ranking.
|
|
104
|
+
*
|
|
105
|
+
* Matching: ALL query words must appear in at least one of name, tags, or
|
|
106
|
+
* description (case-insensitive substring match used for filtering; TF-IDF
|
|
107
|
+
* token scoring used for ranking).
|
|
108
|
+
*
|
|
109
|
+
* Returns up to `limit` results in descending relevance order.
|
|
110
|
+
*/
|
|
111
|
+
export function searchTools(
|
|
112
|
+
tools: ProviderTool[],
|
|
113
|
+
query: string,
|
|
114
|
+
provider: string | undefined,
|
|
115
|
+
limit: number,
|
|
116
|
+
): Array<{ name: string; description: string; tags: string[] }> {
|
|
117
|
+
const words = tokenize(query);
|
|
118
|
+
if (words.length === 0) return [];
|
|
119
|
+
|
|
120
|
+
const source = provider ? tools.filter((t) => t.providerName === provider) : tools;
|
|
121
|
+
const scoreFn = buildTfIdf(source);
|
|
122
|
+
|
|
123
|
+
const candidates: Array<{ tool: ProviderTool; score: number }> = [];
|
|
124
|
+
|
|
125
|
+
for (const tool of source) {
|
|
126
|
+
const nameLower = tool.mcpName.toLowerCase();
|
|
127
|
+
const tagsText = tool.tags.join(" ").toLowerCase();
|
|
128
|
+
const descLower = tool.description.toLowerCase();
|
|
129
|
+
|
|
130
|
+
const allMatch = words.every(
|
|
131
|
+
(word) =>
|
|
132
|
+
nameLower.includes(word) || tagsText.includes(word) || descLower.includes(word),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
if (allMatch) {
|
|
136
|
+
candidates.push({ tool, score: scoreFn(tool, words) });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
141
|
+
|
|
142
|
+
return candidates.slice(0, limit).map(({ tool }) => ({
|
|
143
|
+
name: tool.mcpName,
|
|
144
|
+
description: tool.description,
|
|
145
|
+
tags: tool.tags,
|
|
146
|
+
}));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// groupTools
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Group tools by provider name or by OpenAPI tag.
|
|
155
|
+
* Tools with no tags fall back to grouping under their provider name.
|
|
156
|
+
*/
|
|
157
|
+
export function groupTools(
|
|
158
|
+
tools: ProviderTool[],
|
|
159
|
+
by: "provider" | "tag",
|
|
160
|
+
): Record<string, string[]> {
|
|
161
|
+
const grouped: Record<string, string[]> = {};
|
|
162
|
+
|
|
163
|
+
for (const tool of tools) {
|
|
164
|
+
if (by === "provider") {
|
|
165
|
+
(grouped[tool.providerName] ??= []).push(tool.mcpName);
|
|
166
|
+
} else {
|
|
167
|
+
const keys = tool.tags.length > 0 ? tool.tags : [tool.providerName];
|
|
168
|
+
for (const key of keys) {
|
|
169
|
+
(grouped[key] ??= []).push(tool.mcpName);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return grouped;
|
|
175
|
+
}
|