@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/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
+ }