@utdk/mcp-core 0.1.0-dev.37053ca
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/dist/index.d.ts +11 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/loader.d.ts +72 -0
- package/dist/loader.js +103 -0
- package/dist/loader.js.map +1 -0
- package/dist/meta-tools.d.ts +201 -0
- package/dist/meta-tools.js +183 -0
- package/dist/meta-tools.js.map +1 -0
- package/dist/search.d.ts +44 -0
- package/dist/search.js +137 -0
- package/dist/search.js.map +1 -0
- package/package.json +53 -0
- package/src/__tests__/loader.test.ts +128 -0
- package/src/__tests__/meta-tools.test.ts +222 -0
- package/src/__tests__/search.test.ts +243 -0
- package/src/index.ts +11 -0
- package/src/loader.ts +203 -0
- package/src/meta-tools.ts +285 -0
- package/src/search.ts +168 -0
- package/tsconfig.json +13 -0
- package/vitest.config.ts +8 -0
package/src/loader.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { OpenApiConverter } from "@utcp/http";
|
|
2
|
+
import type { AuthProvider } from "@utdk/common";
|
|
3
|
+
|
|
4
|
+
export interface ProviderTool {
|
|
5
|
+
/** MCP tool name (e.g. "github__repos_list") */
|
|
6
|
+
mcpName: string;
|
|
7
|
+
/** Original UTCP tool name (e.g. "github.repos/list") */
|
|
8
|
+
utcpName: string;
|
|
9
|
+
description: string;
|
|
10
|
+
/** JSON Schema for the tool's inputs */
|
|
11
|
+
inputSchema: Record<string, unknown>;
|
|
12
|
+
/** Provider name (e.g. "github") */
|
|
13
|
+
providerName: string;
|
|
14
|
+
/** OpenAPI operation tags (e.g. ["repos", "issues"]) */
|
|
15
|
+
tags: string[];
|
|
16
|
+
/** Transport method (e.g. "GET", "POST") */
|
|
17
|
+
method: string;
|
|
18
|
+
/** Route template with {param} placeholders */
|
|
19
|
+
routeTemplate: string;
|
|
20
|
+
/** Content-Type for requests */
|
|
21
|
+
contentType: string;
|
|
22
|
+
/** Parameter keys that appear in the URL path */
|
|
23
|
+
pathParamKeys: string[];
|
|
24
|
+
/** Parameter keys that should go in query string for GET/DELETE */
|
|
25
|
+
queryParamKeys: string[];
|
|
26
|
+
/** Auth provider for this provider */
|
|
27
|
+
auth: AuthProvider | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface UtdkAuthConfig {
|
|
31
|
+
auth_type: string;
|
|
32
|
+
api_key?: string;
|
|
33
|
+
var_name?: string;
|
|
34
|
+
location?: string;
|
|
35
|
+
token_url?: string;
|
|
36
|
+
client_id?: string;
|
|
37
|
+
client_secret?: string;
|
|
38
|
+
scope?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface UtdkPackageJson {
|
|
42
|
+
name: string;
|
|
43
|
+
utdk?: {
|
|
44
|
+
provider: string;
|
|
45
|
+
auth?: UtdkAuthConfig[];
|
|
46
|
+
openapi?: {
|
|
47
|
+
title?: string;
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface TransportCallTemplate {
|
|
53
|
+
call_template_type: string;
|
|
54
|
+
http_method?: string;
|
|
55
|
+
url?: string;
|
|
56
|
+
content_type?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function extractPathParams(routeTemplate: string): string[] {
|
|
60
|
+
const matches = routeTemplate.match(/\{([^}]+)\}/g) ?? [];
|
|
61
|
+
return matches.map((m) => m.slice(1, -1));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function toMcpToolName(utcpName: string): string {
|
|
65
|
+
return utcpName.replace(/\./g, "__").replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Load a provider package's OpenAPI document and package metadata.
|
|
70
|
+
* This is the single choke point for the openapi.json dynamic-import contract.
|
|
71
|
+
* Returns null if the package cannot be found or loaded.
|
|
72
|
+
*/
|
|
73
|
+
export async function loadProviderPackage(providerName: string): Promise<{
|
|
74
|
+
openApiDoc: Record<string, unknown>;
|
|
75
|
+
packageJson: UtdkPackageJson;
|
|
76
|
+
} | null> {
|
|
77
|
+
const packageName = `@utdk/${providerName}`;
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const [openApiMod, pkgMod] = await Promise.all([
|
|
81
|
+
import(`${packageName}/openapi.json`, { with: { type: "json" } }).catch(() => null),
|
|
82
|
+
import(`${packageName}/package.json`, { with: { type: "json" } }).catch(() => null),
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
if (!openApiMod || !pkgMod) {
|
|
86
|
+
process.stderr.write(
|
|
87
|
+
`[mcp-core] Warning: could not import openapi.json or package.json for ${packageName}\n`,
|
|
88
|
+
);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
openApiDoc: (openApiMod as { default: Record<string, unknown> }).default as Record<
|
|
94
|
+
string,
|
|
95
|
+
unknown
|
|
96
|
+
>,
|
|
97
|
+
packageJson: (pkgMod as { default: UtdkPackageJson }).default as UtdkPackageJson,
|
|
98
|
+
};
|
|
99
|
+
} catch (err) {
|
|
100
|
+
process.stderr.write(
|
|
101
|
+
`[mcp-core] Warning: failed to load provider ${providerName}: ${err}\n`,
|
|
102
|
+
);
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function buildProviderTools(
|
|
108
|
+
providerName: string,
|
|
109
|
+
openApiDoc: Record<string, unknown>,
|
|
110
|
+
auth: AuthProvider | undefined,
|
|
111
|
+
): ProviderTool[] {
|
|
112
|
+
const converter = new OpenApiConverter(openApiDoc, {
|
|
113
|
+
callTemplateName: providerName,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const manual = converter.convert();
|
|
117
|
+
const tools: ProviderTool[] = [];
|
|
118
|
+
|
|
119
|
+
for (const tool of manual.tools) {
|
|
120
|
+
const template = tool.tool_call_template as TransportCallTemplate | undefined;
|
|
121
|
+
if (!template) continue;
|
|
122
|
+
|
|
123
|
+
const routeTemplate = template.url ?? "/";
|
|
124
|
+
const method = (template.http_method ?? "GET").toUpperCase();
|
|
125
|
+
const contentType = template.content_type ?? "application/json";
|
|
126
|
+
const pathParamKeys = extractPathParams(routeTemplate);
|
|
127
|
+
|
|
128
|
+
const inputProperties = (tool.inputs?.properties ?? {}) as Record<string, unknown>;
|
|
129
|
+
const allInputKeys = Object.keys(inputProperties);
|
|
130
|
+
const isBodyMethod = ["POST", "PUT", "PATCH"].includes(method);
|
|
131
|
+
const queryParamKeys = isBodyMethod
|
|
132
|
+
? []
|
|
133
|
+
: allInputKeys.filter((k) => !pathParamKeys.includes(k));
|
|
134
|
+
|
|
135
|
+
tools.push({
|
|
136
|
+
mcpName: toMcpToolName(tool.name),
|
|
137
|
+
utcpName: tool.name,
|
|
138
|
+
description: tool.description ?? `${method} ${routeTemplate}`,
|
|
139
|
+
inputSchema: tool.inputs as Record<string, unknown>,
|
|
140
|
+
providerName,
|
|
141
|
+
tags: (tool.tags as string[] | undefined) ?? [],
|
|
142
|
+
method,
|
|
143
|
+
routeTemplate,
|
|
144
|
+
contentType,
|
|
145
|
+
pathParamKeys,
|
|
146
|
+
queryParamKeys,
|
|
147
|
+
auth,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return tools;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Callback for authenticating and executing a tool call.
|
|
156
|
+
* The stdio server passes a fetch-based implementation; the gateway passes its own executor.
|
|
157
|
+
*/
|
|
158
|
+
export type AuthFactory = (
|
|
159
|
+
providerName: string,
|
|
160
|
+
authConfigs: UtdkAuthConfig[],
|
|
161
|
+
) => AuthProvider | undefined;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Load all tools for a list of provider names.
|
|
165
|
+
* Providers that fail to load are skipped with a warning.
|
|
166
|
+
* Pass `buildAuth` to attach credentials; omit it for unauthenticated tool loading.
|
|
167
|
+
*/
|
|
168
|
+
export async function loadProviders(
|
|
169
|
+
providerNames: string[],
|
|
170
|
+
buildAuth?: AuthFactory,
|
|
171
|
+
): Promise<ProviderTool[]> {
|
|
172
|
+
const allTools: ProviderTool[] = [];
|
|
173
|
+
|
|
174
|
+
await Promise.all(
|
|
175
|
+
providerNames.map(async (providerName) => {
|
|
176
|
+
const pkg = await loadProviderPackage(providerName);
|
|
177
|
+
if (!pkg) return;
|
|
178
|
+
|
|
179
|
+
const authConfigs = pkg.packageJson.utdk?.auth ?? [];
|
|
180
|
+
const auth = buildAuth?.(providerName, authConfigs);
|
|
181
|
+
|
|
182
|
+
const tools = buildProviderTools(providerName, pkg.openApiDoc, auth);
|
|
183
|
+
process.stderr.write(
|
|
184
|
+
`[mcp-core] Loaded ${tools.length} tools from @utdk/${providerName}\n`,
|
|
185
|
+
);
|
|
186
|
+
allTools.push(...tools);
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
return allTools;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Parse the UTDK_PROVIDERS environment variable.
|
|
195
|
+
* Returns an array of provider names (e.g. ["github", "slack", "stripe"]).
|
|
196
|
+
*/
|
|
197
|
+
export function parseProviderNames(envValue: string | undefined): string[] {
|
|
198
|
+
if (!envValue?.trim()) return [];
|
|
199
|
+
return envValue
|
|
200
|
+
.split(",")
|
|
201
|
+
.map((name) => name.trim().toLowerCase())
|
|
202
|
+
.filter(Boolean);
|
|
203
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { searchTools, groupTools } from "./search.js";
|
|
2
|
+
import type { ProviderTool } from "./loader.js";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Result shape (structurally compatible with MCP SDK CallToolResult)
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A text content block. Structurally compatible with the MCP SDK's text
|
|
10
|
+
* `ContentBlock` so handlers can be returned directly from a tools/call
|
|
11
|
+
* handler.
|
|
12
|
+
*/
|
|
13
|
+
export interface TextContent {
|
|
14
|
+
type: "text";
|
|
15
|
+
text: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Result of a tools/call handler. Structurally compatible with the MCP SDK's
|
|
20
|
+
* `CallToolResult` so values returned from these handlers can be passed
|
|
21
|
+
* straight back to the SDK without an adapter: the stdio server returns them
|
|
22
|
+
* directly from its `tools/call` request handler, and the hosted gateway
|
|
23
|
+
* (Phase 2) serializes the same shape over HTTP. mcp-core deliberately does
|
|
24
|
+
* not depend on `@modelcontextprotocol/sdk`; the handlers return fresh object
|
|
25
|
+
* literals whose inferred types are assignable to the SDK's `ServerResult`
|
|
26
|
+
* union, and this interface is the documented shape of those literals.
|
|
27
|
+
*/
|
|
28
|
+
export interface CallToolResult {
|
|
29
|
+
content: TextContent[];
|
|
30
|
+
isError?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Schema normalization
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Ensure the input schema is a valid MCP tool inputSchema (type: "object").
|
|
39
|
+
*/
|
|
40
|
+
export function normalizeInputSchema(schema: Record<string, unknown>): {
|
|
41
|
+
type: "object";
|
|
42
|
+
properties?: Record<string, object>;
|
|
43
|
+
required?: string[];
|
|
44
|
+
} {
|
|
45
|
+
return {
|
|
46
|
+
type: "object",
|
|
47
|
+
...(schema["properties"]
|
|
48
|
+
? { properties: schema["properties"] as Record<string, object> }
|
|
49
|
+
: {}),
|
|
50
|
+
...(schema["required"] ? { required: schema["required"] as string[] } : {}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Meta-tool definitions (served by tools/list)
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
export const META_TOOLS = [
|
|
59
|
+
{
|
|
60
|
+
name: "list_tools",
|
|
61
|
+
description:
|
|
62
|
+
"List available tool names without full schemas. Pass an optional provider to filter results, or group_by to organize by category.",
|
|
63
|
+
inputSchema: {
|
|
64
|
+
type: "object" as const,
|
|
65
|
+
properties: {
|
|
66
|
+
provider: {
|
|
67
|
+
type: "string",
|
|
68
|
+
description: "Filter results to tools from this provider name (e.g. 'github')",
|
|
69
|
+
},
|
|
70
|
+
group_by: {
|
|
71
|
+
type: "string",
|
|
72
|
+
enum: ["provider", "tag"],
|
|
73
|
+
description:
|
|
74
|
+
"Group results by 'provider' or by OpenAPI 'tag'. When omitted, returns a flat list of names.",
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: "search_tools",
|
|
81
|
+
description:
|
|
82
|
+
"Find tools by keyword. Searches tool names, OpenAPI tags, and descriptions using TF-IDF relevance ranking so the LLM can locate relevant tools without browsing the full catalog.",
|
|
83
|
+
inputSchema: {
|
|
84
|
+
type: "object" as const,
|
|
85
|
+
properties: {
|
|
86
|
+
query: {
|
|
87
|
+
type: "string",
|
|
88
|
+
description:
|
|
89
|
+
"Natural language or keyword search (all words must appear in name, tags, or description)",
|
|
90
|
+
},
|
|
91
|
+
provider: {
|
|
92
|
+
type: "string",
|
|
93
|
+
description: "Restrict search to tools from this provider name (e.g. 'github')",
|
|
94
|
+
},
|
|
95
|
+
limit: {
|
|
96
|
+
type: "number",
|
|
97
|
+
description: "Maximum number of results to return (default 10)",
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
required: ["query"],
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: "tool_info",
|
|
105
|
+
description:
|
|
106
|
+
"Get the full schema and metadata for a specific tool by name. Use this after list_tools or search_tools to retrieve the complete input schema before calling the tool.",
|
|
107
|
+
inputSchema: {
|
|
108
|
+
type: "object" as const,
|
|
109
|
+
properties: {
|
|
110
|
+
tool_name: {
|
|
111
|
+
type: "string",
|
|
112
|
+
description: "The MCP tool name (e.g. 'github__repos_list')",
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
required: ["tool_name"],
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: "call_tool",
|
|
120
|
+
description:
|
|
121
|
+
"Execute any registered tool by name with the provided arguments. Use tool_info first to get the correct argument schema.",
|
|
122
|
+
inputSchema: {
|
|
123
|
+
type: "object" as const,
|
|
124
|
+
properties: {
|
|
125
|
+
tool_name: {
|
|
126
|
+
type: "string",
|
|
127
|
+
description: "The MCP tool name to execute (e.g. 'github__repos_list')",
|
|
128
|
+
},
|
|
129
|
+
arguments: {
|
|
130
|
+
type: "object",
|
|
131
|
+
description: "Arguments to pass to the tool",
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
required: ["tool_name", "arguments"],
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Meta-tool call handlers
|
|
141
|
+
//
|
|
142
|
+
// Each handler returns a fresh object literal (no explicit return annotation)
|
|
143
|
+
// so the inferred type stays assignable to the MCP SDK's `ServerResult`
|
|
144
|
+
// union, which includes an index-signature envelope that named interface types
|
|
145
|
+
// cannot satisfy. `type: "text" as const` keeps the content block's `type`
|
|
146
|
+
// literal so the result is still assignable to {@link CallToolResult}.
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
export function handleListTools(tools: ProviderTool[], args: Record<string, unknown>) {
|
|
150
|
+
const providerFilter =
|
|
151
|
+
typeof args["provider"] === "string" ? args["provider"] : undefined;
|
|
152
|
+
const groupBy =
|
|
153
|
+
args["group_by"] === "provider" || args["group_by"] === "tag"
|
|
154
|
+
? (args["group_by"] as "provider" | "tag")
|
|
155
|
+
: undefined;
|
|
156
|
+
const filtered = providerFilter
|
|
157
|
+
? tools.filter((t) => t.providerName === providerFilter)
|
|
158
|
+
: tools;
|
|
159
|
+
const result =
|
|
160
|
+
groupBy === "provider" || groupBy === "tag"
|
|
161
|
+
? groupTools(filtered, groupBy)
|
|
162
|
+
: filtered.map((t) => t.mcpName);
|
|
163
|
+
return {
|
|
164
|
+
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function handleSearchTools(tools: ProviderTool[], args: Record<string, unknown>) {
|
|
169
|
+
const query = typeof args["query"] === "string" ? args["query"] : "";
|
|
170
|
+
const providerFilter =
|
|
171
|
+
typeof args["provider"] === "string" ? args["provider"] : undefined;
|
|
172
|
+
const limit =
|
|
173
|
+
typeof args["limit"] === "number" && args["limit"] > 0 ? Math.floor(args["limit"]) : 10;
|
|
174
|
+
const results = searchTools(tools, query, providerFilter, limit);
|
|
175
|
+
return {
|
|
176
|
+
content: [{ type: "text" as const, text: JSON.stringify(results, null, 2) }],
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function handleToolInfo(
|
|
181
|
+
toolMap: Map<string, ProviderTool>,
|
|
182
|
+
args: Record<string, unknown>,
|
|
183
|
+
) {
|
|
184
|
+
const requestedName = typeof args["tool_name"] === "string" ? args["tool_name"] : "";
|
|
185
|
+
const tool = toolMap.get(requestedName);
|
|
186
|
+
if (!tool) {
|
|
187
|
+
return {
|
|
188
|
+
isError: true,
|
|
189
|
+
content: [{ type: "text" as const, text: `Unknown tool: ${requestedName}` }],
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
content: [
|
|
194
|
+
{
|
|
195
|
+
type: "text" as const,
|
|
196
|
+
text: JSON.stringify(
|
|
197
|
+
{
|
|
198
|
+
name: tool.mcpName,
|
|
199
|
+
description: tool.description,
|
|
200
|
+
provider: tool.providerName,
|
|
201
|
+
inputSchema: normalizeInputSchema(tool.inputSchema),
|
|
202
|
+
},
|
|
203
|
+
null,
|
|
204
|
+
2,
|
|
205
|
+
),
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// execute() contract for handleCallTool
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Payload handed to an {@link Execute} callback: the resolved tool plus the
|
|
217
|
+
* arguments extracted from the `call_tool` meta-tool's `arguments` field.
|
|
218
|
+
*/
|
|
219
|
+
export interface ExecuteInput {
|
|
220
|
+
tool: ProviderTool;
|
|
221
|
+
args: Record<string, unknown>;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Transport-agnostic result of executing a tool.
|
|
226
|
+
*
|
|
227
|
+
* - `ok: true` carries the parsed tool output (`data`); mcp-core serializes it
|
|
228
|
+
* as the `text` content of the {@link CallToolResult}.
|
|
229
|
+
* - `ok: false` carries an `error` message; mcp-core surfaces it as an
|
|
230
|
+
* `isError` {@link CallToolResult}.
|
|
231
|
+
*
|
|
232
|
+
* The stdio server wraps its `fetch`-based executor (which throws on HTTP
|
|
233
|
+
* errors) into this discriminated union; the hosted gateway (Phase 2) maps its
|
|
234
|
+
* `IsolateResult` onto the same shape. Keeping errors as values (instead of
|
|
235
|
+
* throwing) lets both transports share a single handler.
|
|
236
|
+
*/
|
|
237
|
+
export type ExecuteResult = { ok: true; data: unknown } | { ok: false; error: string };
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Callback that performs the actual tool execution. Injected into
|
|
241
|
+
* {@link handleCallTool} so the handler stays transport-agnostic: the stdio
|
|
242
|
+
* server passes a `fetch`-based implementation, the gateway passes its own
|
|
243
|
+
* executor.
|
|
244
|
+
*/
|
|
245
|
+
export type Execute = (input: ExecuteInput) => Promise<ExecuteResult>;
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Handle a `call_tool` meta-tool invocation.
|
|
249
|
+
*
|
|
250
|
+
* `tool` is the *resolved* tool (the caller looks it up in its tool map, which
|
|
251
|
+
* for the gateway is the per-caller filtered catalog); `undefined` produces an
|
|
252
|
+
* "Unknown tool" result, matching the prior stdio behavior. The injected
|
|
253
|
+
* `execute` callback is invoked with `{ tool, args }` (the arguments extracted
|
|
254
|
+
* from `args["arguments"]`) and its {@link ExecuteResult} is wrapped in a
|
|
255
|
+
* {@link CallToolResult}.
|
|
256
|
+
*/
|
|
257
|
+
export async function handleCallTool(
|
|
258
|
+
tool: ProviderTool | undefined,
|
|
259
|
+
args: Record<string, unknown>,
|
|
260
|
+
execute: Execute,
|
|
261
|
+
) {
|
|
262
|
+
const requestedName = typeof args["tool_name"] === "string" ? args["tool_name"] : "";
|
|
263
|
+
if (!tool) {
|
|
264
|
+
return {
|
|
265
|
+
isError: true,
|
|
266
|
+
content: [{ type: "text" as const, text: `Unknown tool: ${requestedName}` }],
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
const toolArgs =
|
|
270
|
+
typeof args["arguments"] === "object" && args["arguments"] !== null
|
|
271
|
+
? (args["arguments"] as Record<string, unknown>)
|
|
272
|
+
: {};
|
|
273
|
+
const result = await execute({ tool, args: toolArgs });
|
|
274
|
+
if (!result.ok) {
|
|
275
|
+
return {
|
|
276
|
+
isError: true,
|
|
277
|
+
content: [
|
|
278
|
+
{ type: "text" as const, text: `Error calling ${tool.mcpName}: ${result.error}` },
|
|
279
|
+
],
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
const text =
|
|
283
|
+
typeof result.data === "string" ? result.data : JSON.stringify(result.data, null, 2);
|
|
284
|
+
return { content: [{ type: "text" as const, text }] };
|
|
285
|
+
}
|
package/src/search.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { ProviderTool } from "./loader.js";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Tokenization
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Tokenize text into lowercase words, splitting on non-alphanumeric characters.
|
|
9
|
+
* Also splits camelCase boundaries for better matching of MCP tool names.
|
|
10
|
+
*
|
|
11
|
+
* Examples:
|
|
12
|
+
* "github__repos_list" → ["github", "repos", "list"]
|
|
13
|
+
* "List public repositories" → ["list", "public", "repositories"]
|
|
14
|
+
* "security-advisories" → ["security", "advisories"]
|
|
15
|
+
*/
|
|
16
|
+
export function tokenize(text: string): string[] {
|
|
17
|
+
return text
|
|
18
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2") // split camelCase boundaries
|
|
19
|
+
.toLowerCase()
|
|
20
|
+
.split(/[^a-z0-9]+/)
|
|
21
|
+
.filter(Boolean);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// TF-IDF scoring
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build a TF-IDF scoring function over a corpus of tools.
|
|
30
|
+
*
|
|
31
|
+
* Field weights applied during term-frequency calculation:
|
|
32
|
+
* - name × 3 (most signal)
|
|
33
|
+
* - tags × 2 (category signal)
|
|
34
|
+
* - description × 1
|
|
35
|
+
*
|
|
36
|
+
* IDF formula: log((N + 1) / df) — smoothed to avoid division-by-zero.
|
|
37
|
+
*
|
|
38
|
+
* Returns a function `score(tool, queryWords) → number` that can be called
|
|
39
|
+
* for each candidate. Build once per search call, reuse across candidates.
|
|
40
|
+
*/
|
|
41
|
+
export function buildTfIdf(
|
|
42
|
+
tools: ProviderTool[],
|
|
43
|
+
): (tool: ProviderTool, words: string[]) => number {
|
|
44
|
+
const N = tools.length;
|
|
45
|
+
if (N === 0) return () => 0;
|
|
46
|
+
|
|
47
|
+
type TokenBag = Map<string, number>;
|
|
48
|
+
|
|
49
|
+
// Precompute weighted token bags for each tool
|
|
50
|
+
const bags: TokenBag[] = tools.map((tool) => {
|
|
51
|
+
const bag: TokenBag = new Map();
|
|
52
|
+
const add = (tokens: string[], weight: number) => {
|
|
53
|
+
for (const tok of tokens) {
|
|
54
|
+
bag.set(tok, (bag.get(tok) ?? 0) + weight);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
add(tokenize(tool.mcpName), 3);
|
|
58
|
+
for (const tag of tool.tags) {
|
|
59
|
+
add(tokenize(tag), 2);
|
|
60
|
+
}
|
|
61
|
+
add(tokenize(tool.description), 1);
|
|
62
|
+
return bag;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Document frequency: how many tools contain each token
|
|
66
|
+
const df = new Map<string, number>();
|
|
67
|
+
for (const bag of bags) {
|
|
68
|
+
for (const tok of bag.keys()) {
|
|
69
|
+
df.set(tok, (df.get(tok) ?? 0) + 1);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const bagIndex = new Map<ProviderTool, TokenBag>(tools.map((t, i) => [t, bags[i]!]));
|
|
74
|
+
|
|
75
|
+
return (tool: ProviderTool, words: string[]): number => {
|
|
76
|
+
const bag = bagIndex.get(tool);
|
|
77
|
+
if (!bag) return 0;
|
|
78
|
+
const totalWeight = [...bag.values()].reduce((s, v) => s + v, 0) || 1;
|
|
79
|
+
let score = 0;
|
|
80
|
+
for (const word of words) {
|
|
81
|
+
const tf = (bag.get(word) ?? 0) / totalWeight;
|
|
82
|
+
const docFreq = df.get(word) ?? 0;
|
|
83
|
+
if (docFreq === 0) continue;
|
|
84
|
+
const idf = Math.log((N + 1) / docFreq);
|
|
85
|
+
score += tf * idf;
|
|
86
|
+
}
|
|
87
|
+
return score;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// searchTools
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Search tools by keyword using TF-IDF relevance ranking.
|
|
97
|
+
*
|
|
98
|
+
* Matching: ALL query words must appear in at least one of name, tags, or
|
|
99
|
+
* description (case-insensitive substring match used for filtering; TF-IDF
|
|
100
|
+
* token scoring used for ranking).
|
|
101
|
+
*
|
|
102
|
+
* Returns up to `limit` results in descending relevance order.
|
|
103
|
+
*/
|
|
104
|
+
export function searchTools(
|
|
105
|
+
tools: ProviderTool[],
|
|
106
|
+
query: string,
|
|
107
|
+
provider: string | undefined,
|
|
108
|
+
limit: number,
|
|
109
|
+
): Array<{ name: string; description: string; tags: string[] }> {
|
|
110
|
+
const words = tokenize(query);
|
|
111
|
+
if (words.length === 0) return [];
|
|
112
|
+
|
|
113
|
+
const source = provider ? tools.filter((t) => t.providerName === provider) : tools;
|
|
114
|
+
const scoreFn = buildTfIdf(source);
|
|
115
|
+
|
|
116
|
+
const candidates: Array<{ tool: ProviderTool; score: number }> = [];
|
|
117
|
+
|
|
118
|
+
for (const tool of source) {
|
|
119
|
+
const nameLower = tool.mcpName.toLowerCase();
|
|
120
|
+
const tagsText = tool.tags.join(" ").toLowerCase();
|
|
121
|
+
const descLower = tool.description.toLowerCase();
|
|
122
|
+
|
|
123
|
+
const allMatch = words.every(
|
|
124
|
+
(word) =>
|
|
125
|
+
nameLower.includes(word) || tagsText.includes(word) || descLower.includes(word),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
if (allMatch) {
|
|
129
|
+
candidates.push({ tool, score: scoreFn(tool, words) });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
134
|
+
|
|
135
|
+
return candidates.slice(0, limit).map(({ tool }) => ({
|
|
136
|
+
name: tool.mcpName,
|
|
137
|
+
description: tool.description,
|
|
138
|
+
tags: tool.tags,
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// groupTools
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Group tools by provider name or by OpenAPI tag.
|
|
148
|
+
* Tools with no tags fall back to grouping under their provider name.
|
|
149
|
+
*/
|
|
150
|
+
export function groupTools(
|
|
151
|
+
tools: ProviderTool[],
|
|
152
|
+
by: "provider" | "tag",
|
|
153
|
+
): Record<string, string[]> {
|
|
154
|
+
const grouped: Record<string, string[]> = {};
|
|
155
|
+
|
|
156
|
+
for (const tool of tools) {
|
|
157
|
+
if (by === "provider") {
|
|
158
|
+
(grouped[tool.providerName] ??= []).push(tool.mcpName);
|
|
159
|
+
} else {
|
|
160
|
+
const keys = tool.tags.length > 0 ? tool.tags : [tool.providerName];
|
|
161
|
+
for (const key of keys) {
|
|
162
|
+
(grouped[key] ??= []).push(tool.mcpName);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return grouped;
|
|
168
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"moduleResolution": "Bundler",
|
|
5
|
+
"outDir": "dist",
|
|
6
|
+
"rootDir": "src",
|
|
7
|
+
"declaration": true,
|
|
8
|
+
"sourceMap": true,
|
|
9
|
+
"resolveJsonModule": true
|
|
10
|
+
},
|
|
11
|
+
"include": ["src/**/*.ts"],
|
|
12
|
+
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
|
13
|
+
}
|