@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.
@@ -0,0 +1,128 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { parseProviderNames, loadProviders } from "../loader.js";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // parseProviderNames
6
+ // ---------------------------------------------------------------------------
7
+
8
+ describe("parseProviderNames", () => {
9
+ it("parses comma-separated provider names", () => {
10
+ expect(parseProviderNames("github,slack,stripe")).toEqual(["github", "slack", "stripe"]);
11
+ });
12
+
13
+ it("trims whitespace", () => {
14
+ expect(parseProviderNames("github, slack , stripe")).toEqual(["github", "slack", "stripe"]);
15
+ });
16
+
17
+ it("lowercases names", () => {
18
+ expect(parseProviderNames("GitHub,Slack")).toEqual(["github", "slack"]);
19
+ });
20
+
21
+ it("returns empty array for undefined", () => {
22
+ expect(parseProviderNames(undefined)).toEqual([]);
23
+ });
24
+
25
+ it("returns empty array for empty string", () => {
26
+ expect(parseProviderNames("")).toEqual([]);
27
+ });
28
+
29
+ it("filters empty entries from double commas", () => {
30
+ expect(parseProviderNames("github,,slack")).toEqual(["github", "slack"]);
31
+ });
32
+ });
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // loadProviders
36
+ // ---------------------------------------------------------------------------
37
+
38
+ describe("loadProviders", () => {
39
+ it("returns an empty array for an unknown provider", async () => {
40
+ const tools = await loadProviders(["__nonexistent_provider__"]);
41
+ expect(tools).toEqual([]);
42
+ });
43
+
44
+ it("returns an empty array for an empty provider list", async () => {
45
+ const tools = await loadProviders([]);
46
+ expect(tools).toEqual([]);
47
+ });
48
+
49
+ it("loads tools from github provider (integration)", async () => {
50
+ // This test requires the @utdk/github package to be present in the workspace.
51
+ // It verifies that the loader correctly converts the OpenAPI doc to MCP tools.
52
+ const tools = await loadProviders(["github"]);
53
+
54
+ expect(tools.length).toBeGreaterThan(0);
55
+
56
+ // All tools should have required fields
57
+ for (const tool of tools) {
58
+ expect(tool.mcpName).toMatch(/^[a-zA-Z0-9_-]+$/);
59
+ expect(tool.providerName).toBe("github");
60
+ expect(tool.description).toBeTruthy();
61
+ expect(tool.method).toMatch(/^(GET|POST|PUT|PATCH|DELETE)$/);
62
+ expect(tool.routeTemplate).toMatch(/^https?:\/\//);
63
+ expect(tool.inputSchema).toBeDefined();
64
+ }
65
+ });
66
+
67
+ it("loads tools from stripe provider (integration)", async () => {
68
+ const tools = await loadProviders(["stripe"]);
69
+
70
+ expect(tools.length).toBeGreaterThan(0);
71
+
72
+ const tool = tools[0];
73
+ expect(tool).toBeDefined();
74
+ if (!tool) return;
75
+
76
+ expect(tool.providerName).toBe("stripe");
77
+ expect(tool.inputSchema["type"]).toBe("object");
78
+ });
79
+
80
+ it("loads tools from multiple providers simultaneously", async () => {
81
+ const tools = await loadProviders(["github", "stripe"]);
82
+
83
+ expect(tools.length).toBeGreaterThan(0);
84
+
85
+ const githubTools = tools.filter((t) => t.providerName === "github");
86
+ const stripeTools = tools.filter((t) => t.providerName === "stripe");
87
+
88
+ expect(githubTools.length).toBeGreaterThan(0);
89
+ expect(stripeTools.length).toBeGreaterThan(0);
90
+ });
91
+
92
+ it("tool names are MCP-safe (no dots or slashes)", async () => {
93
+ const tools = await loadProviders(["github"]);
94
+ for (const tool of tools) {
95
+ expect(tool.mcpName).not.toContain(".");
96
+ expect(tool.mcpName).not.toContain("/");
97
+ }
98
+ });
99
+
100
+ it("input schemas have type: object", async () => {
101
+ const tools = await loadProviders(["stripe"]);
102
+ for (const tool of tools) {
103
+ const schema = tool.inputSchema;
104
+ expect(schema["type"]).toBe("object");
105
+ }
106
+ });
107
+
108
+ it("populates tags from OpenAPI operation tags", async () => {
109
+ const tools = await loadProviders(["github"]);
110
+ expect(tools.length).toBeGreaterThan(0);
111
+
112
+ // Every tool should have a tags array (may be empty for untagged operations)
113
+ for (const tool of tools) {
114
+ expect(Array.isArray(tool.tags)).toBe(true);
115
+ }
116
+
117
+ // At least some tools should have non-empty tags
118
+ const taggedTools = tools.filter((t) => t.tags.length > 0);
119
+ expect(taggedTools.length).toBeGreaterThan(0);
120
+
121
+ // Tags should be strings
122
+ for (const tool of taggedTools) {
123
+ for (const tag of tool.tags) {
124
+ expect(typeof tag).toBe("string");
125
+ }
126
+ }
127
+ });
128
+ });
@@ -0,0 +1,222 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import {
3
+ META_TOOLS,
4
+ handleListTools,
5
+ handleSearchTools,
6
+ handleToolInfo,
7
+ handleCallTool,
8
+ type CallToolResult,
9
+ type ExecuteInput,
10
+ type ExecuteResult,
11
+ type ProviderTool,
12
+ } from "../index.js";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Shared test fixtures
16
+ // ---------------------------------------------------------------------------
17
+
18
+ function makeTool(overrides: Partial<ProviderTool> & { mcpName: string }): ProviderTool {
19
+ return {
20
+ utcpName: overrides.mcpName.replace(/__/g, "."),
21
+ description: "",
22
+ inputSchema: { type: "object" },
23
+ providerName: "testprovider",
24
+ tags: [],
25
+ method: "GET",
26
+ routeTemplate: "https://api.example.com/test",
27
+ contentType: "application/json",
28
+ pathParamKeys: [],
29
+ queryParamKeys: [],
30
+ auth: undefined,
31
+ ...overrides,
32
+ };
33
+ }
34
+
35
+ const reposTool = makeTool({
36
+ mcpName: "github__repos_list",
37
+ providerName: "github",
38
+ description: "List public repositories for the specified user.",
39
+ tags: ["repos"],
40
+ });
41
+
42
+ const issuesTool = makeTool({
43
+ mcpName: "github__issues_list",
44
+ providerName: "github",
45
+ description: "List issues assigned to the authenticated user across all visible repositories.",
46
+ tags: ["issues"],
47
+ });
48
+
49
+ const prTool = makeTool({
50
+ mcpName: "github__pulls_list",
51
+ providerName: "github",
52
+ description: "List pull requests in a repository.",
53
+ tags: ["pulls"],
54
+ });
55
+
56
+ const slackTool = makeTool({
57
+ mcpName: "slack__messages_send",
58
+ providerName: "slack",
59
+ description: "Send a message to a Slack channel.",
60
+ tags: ["messaging"],
61
+ });
62
+
63
+ const ALL_TOOLS = [reposTool, issuesTool, prTool, slackTool];
64
+
65
+ function parseText(result: CallToolResult): unknown {
66
+ return JSON.parse(result.content[0]!.text);
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // handleListTools
71
+ // ---------------------------------------------------------------------------
72
+
73
+ describe("handleListTools", () => {
74
+ it("returns the names of every tool when given no filter (4 entries)", () => {
75
+ const result: CallToolResult = handleListTools(ALL_TOOLS, {});
76
+ const names = parseText(result) as string[];
77
+ expect(names).toHaveLength(4);
78
+ expect(names).toEqual(
79
+ expect.arrayContaining([
80
+ "github__repos_list",
81
+ "github__issues_list",
82
+ "github__pulls_list",
83
+ "slack__messages_send",
84
+ ]),
85
+ );
86
+ expect(result.isError).toBeFalsy();
87
+ });
88
+
89
+ it("filters to a single provider", () => {
90
+ const result: CallToolResult = handleListTools(ALL_TOOLS, { provider: "slack" });
91
+ expect(parseText(result)).toEqual(["slack__messages_send"]);
92
+ });
93
+ });
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // handleSearchTools
97
+ // ---------------------------------------------------------------------------
98
+
99
+ describe("handleSearchTools", () => {
100
+ it("returns ranked matches for a keyword query", () => {
101
+ const result: CallToolResult = handleSearchTools(ALL_TOOLS, { query: "repos" });
102
+ const matches = parseText(result) as Array<{ name: string }>;
103
+ expect(matches.length).toBeGreaterThan(0);
104
+ // The tool whose name contains "repos" ranks first.
105
+ expect(matches[0]!.name).toBe("github__repos_list");
106
+ });
107
+ });
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // handleToolInfo
111
+ // ---------------------------------------------------------------------------
112
+
113
+ describe("handleToolInfo", () => {
114
+ it("returns the resolved tool's schema and metadata", () => {
115
+ const toolMap = new Map(ALL_TOOLS.map((t) => [t.mcpName, t]));
116
+ const result: CallToolResult = handleToolInfo(toolMap, { tool_name: "github__repos_list" });
117
+ const info = parseText(result) as {
118
+ name: string;
119
+ description: string;
120
+ provider: string;
121
+ inputSchema: { type: string };
122
+ };
123
+ expect(info.name).toBe("github__repos_list");
124
+ expect(info.description).toBe("List public repositories for the specified user.");
125
+ expect(info.provider).toBe("github");
126
+ expect(info.inputSchema.type).toBe("object");
127
+ expect(result.isError).toBeFalsy();
128
+ });
129
+
130
+ it("returns an error result for an unknown tool", () => {
131
+ const toolMap = new Map(ALL_TOOLS.map((t) => [t.mcpName, t]));
132
+ const result: CallToolResult = handleToolInfo(toolMap, { tool_name: "nope__does_not_exist" });
133
+ expect(result.isError).toBe(true);
134
+ expect(result.content[0]!.text).toContain("Unknown tool: nope__does_not_exist");
135
+ });
136
+ });
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // handleCallTool
140
+ // ---------------------------------------------------------------------------
141
+
142
+ describe("handleCallTool", () => {
143
+ it("invokes the injected execute callback with the correct payload and wraps the result in CallToolResult", async () => {
144
+ const execute = vi.fn(async (_input: ExecuteInput): Promise<ExecuteResult> => ({
145
+ ok: true as const,
146
+ data: { id: 1, name: "repo1" },
147
+ }));
148
+
149
+ const result: CallToolResult = await handleCallTool(
150
+ reposTool,
151
+ { tool_name: "github__repos_list", arguments: { username: "octocat" } },
152
+ execute,
153
+ );
154
+
155
+ // execute was called once with { tool, args } where args is the extracted arguments
156
+ expect(execute).toHaveBeenCalledOnce();
157
+ expect(execute.mock.calls[0]![0]).toEqual({
158
+ tool: reposTool,
159
+ args: { username: "octocat" },
160
+ });
161
+
162
+ // result is a CallToolResult wrapping the data as JSON text
163
+ expect(result.isError).toBeFalsy();
164
+ expect(result.content[0]!.type).toBe("text");
165
+ expect(result.content[0]!.text).toBe(JSON.stringify({ id: 1, name: "repo1" }, null, 2));
166
+ });
167
+
168
+ it("wraps a string result directly without JSON-encoding", async () => {
169
+ const execute = vi.fn(async (): Promise<ExecuteResult> => ({
170
+ ok: true as const,
171
+ data: "plain text output",
172
+ }));
173
+ const result: CallToolResult = await handleCallTool(
174
+ reposTool,
175
+ { tool_name: "github__repos_list", arguments: {} },
176
+ execute,
177
+ );
178
+ expect(result.content[0]!.text).toBe("plain text output");
179
+ });
180
+
181
+ it("surfaces an execute error as an isError CallToolResult", async () => {
182
+ const execute = vi.fn(async (): Promise<ExecuteResult> => ({
183
+ ok: false as const,
184
+ error: "404 Not Found",
185
+ }));
186
+ const result: CallToolResult = await handleCallTool(
187
+ reposTool,
188
+ { tool_name: "github__repos_list", arguments: {} },
189
+ execute,
190
+ );
191
+ expect(result.isError).toBe(true);
192
+ expect(result.content[0]!.text).toBe("Error calling github__repos_list: 404 Not Found");
193
+ });
194
+
195
+ it("returns an Unknown tool error when the tool is undefined", async () => {
196
+ const execute = vi.fn(async (): Promise<ExecuteResult> => ({ ok: true as const, data: null }));
197
+ const result: CallToolResult = await handleCallTool(
198
+ undefined,
199
+ { tool_name: "nope__missing" },
200
+ execute,
201
+ );
202
+ expect(execute).not.toHaveBeenCalled();
203
+ expect(result.isError).toBe(true);
204
+ expect(result.content[0]!.text).toBe("Unknown tool: nope__missing");
205
+ });
206
+ });
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // META_TOOLS sanity
210
+ // ---------------------------------------------------------------------------
211
+
212
+ describe("META_TOOLS", () => {
213
+ it("defines the 4 meta-tools", () => {
214
+ expect(META_TOOLS).toHaveLength(4);
215
+ expect(META_TOOLS.map((t) => t.name).sort()).toEqual([
216
+ "call_tool",
217
+ "list_tools",
218
+ "search_tools",
219
+ "tool_info",
220
+ ]);
221
+ });
222
+ });
@@ -0,0 +1,243 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { tokenize, buildTfIdf, searchTools, groupTools } from "../search.js";
3
+ import type { ProviderTool } from "../loader.js";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Shared test fixtures
7
+ // ---------------------------------------------------------------------------
8
+
9
+ function makeTool(overrides: Partial<ProviderTool> & { mcpName: string }): ProviderTool {
10
+ return {
11
+ utcpName: overrides.mcpName.replace(/__/g, "."),
12
+ description: "",
13
+ inputSchema: { type: "object" },
14
+ providerName: "testprovider",
15
+ tags: [],
16
+ method: "GET",
17
+ routeTemplate: "https://api.example.com/test",
18
+ contentType: "application/json",
19
+ pathParamKeys: [],
20
+ queryParamKeys: [],
21
+ auth: undefined,
22
+ ...overrides,
23
+ };
24
+ }
25
+
26
+ const reposTool = makeTool({
27
+ mcpName: "github__repos_list",
28
+ providerName: "github",
29
+ description: "List public repositories for the specified user.",
30
+ tags: ["repos"],
31
+ });
32
+
33
+ const issuesTool = makeTool({
34
+ mcpName: "github__issues_list",
35
+ providerName: "github",
36
+ description: "List issues assigned to the authenticated user across all visible repositories.",
37
+ tags: ["issues"],
38
+ });
39
+
40
+ const prTool = makeTool({
41
+ mcpName: "github__pulls_list",
42
+ providerName: "github",
43
+ description: "List pull requests in a repository.",
44
+ tags: ["pulls"],
45
+ });
46
+
47
+ const slackTool = makeTool({
48
+ mcpName: "slack__messages_send",
49
+ providerName: "slack",
50
+ description: "Send a message to a Slack channel.",
51
+ tags: ["messaging"],
52
+ });
53
+
54
+ const ALL_TOOLS = [reposTool, issuesTool, prTool, slackTool];
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // tokenize
58
+ // ---------------------------------------------------------------------------
59
+
60
+ describe("tokenize", () => {
61
+ it("lowercases and splits on non-alphanumeric chars", () => {
62
+ expect(tokenize("hello world")).toEqual(["hello", "world"]);
63
+ });
64
+
65
+ it("splits snake_case / double-underscore MCP names", () => {
66
+ expect(tokenize("github__repos_list")).toEqual(["github", "repos", "list"]);
67
+ });
68
+
69
+ it("splits camelCase boundaries", () => {
70
+ expect(tokenize("listForUser")).toEqual(["list", "for", "user"]);
71
+ });
72
+
73
+ it("strips punctuation", () => {
74
+ expect(tokenize("security-advisories")).toEqual(["security", "advisories"]);
75
+ });
76
+
77
+ it("returns empty array for empty string", () => {
78
+ expect(tokenize("")).toEqual([]);
79
+ });
80
+
81
+ it("filters empty tokens", () => {
82
+ expect(tokenize(" hello world ")).toEqual(["hello", "world"]);
83
+ });
84
+ });
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // buildTfIdf
88
+ // ---------------------------------------------------------------------------
89
+
90
+ describe("buildTfIdf", () => {
91
+ it("returns zero score for empty corpus", () => {
92
+ const score = buildTfIdf([]);
93
+ expect(score(reposTool, ["repos"])).toBe(0);
94
+ });
95
+
96
+ it("scores tool with name match higher than description-only match", () => {
97
+ const tools = [reposTool, issuesTool];
98
+ const score = buildTfIdf(tools);
99
+ // "repos" is in reposTool name AND tags but NOT in issuesTool name or tags
100
+ const reposScore = score(reposTool, ["repos"]);
101
+ const issuesScore = score(issuesTool, ["repos"]);
102
+ expect(reposScore).toBeGreaterThan(0);
103
+ expect(issuesScore).toBe(0);
104
+ });
105
+
106
+ it("returns higher score for rare term (IDF effect)", () => {
107
+ // "list" appears in all github tools but "pull" only in prTool
108
+ const tools = [reposTool, issuesTool, prTool];
109
+ const score = buildTfIdf(tools);
110
+ const scoreOnPullRare = score(prTool, ["pull"]);
111
+ const scoreOnListCommon = score(prTool, ["list"]);
112
+ // "pull" is rarer than "list" so IDF is higher → pull score > list score
113
+ expect(scoreOnPullRare).toBeGreaterThan(scoreOnListCommon);
114
+ });
115
+
116
+ it("returns positive score for a matching term in description", () => {
117
+ const tools = [slackTool];
118
+ const score = buildTfIdf(tools);
119
+ expect(score(slackTool, ["channel"])).toBeGreaterThan(0);
120
+ });
121
+ });
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // searchTools
125
+ // ---------------------------------------------------------------------------
126
+
127
+ describe("searchTools", () => {
128
+ it("returns empty array for empty query", () => {
129
+ expect(searchTools(ALL_TOOLS, "", undefined, 10)).toEqual([]);
130
+ });
131
+
132
+ it("returns empty array for blank query", () => {
133
+ expect(searchTools(ALL_TOOLS, " ", undefined, 10)).toEqual([]);
134
+ });
135
+
136
+ it("finds tools by name keyword", () => {
137
+ const results = searchTools(ALL_TOOLS, "repos", undefined, 10);
138
+ expect(results.map((r) => r.name)).toContain("github__repos_list");
139
+ });
140
+
141
+ it("finds tools by tag", () => {
142
+ const results = searchTools(ALL_TOOLS, "issues", undefined, 10);
143
+ expect(results.map((r) => r.name)).toContain("github__issues_list");
144
+ });
145
+
146
+ it("finds tools by description keyword", () => {
147
+ const results = searchTools(ALL_TOOLS, "channel", undefined, 10);
148
+ expect(results.map((r) => r.name)).toContain("slack__messages_send");
149
+ });
150
+
151
+ it("excludes tools where a query word does not appear anywhere", () => {
152
+ const results = searchTools(ALL_TOOLS, "nonexistent_xyz", undefined, 10);
153
+ expect(results).toHaveLength(0);
154
+ });
155
+
156
+ it("requires ALL query words to match", () => {
157
+ // "repos" matches reposTool, "channel" matches slackTool → no tool matches both
158
+ const results = searchTools(ALL_TOOLS, "repos channel", undefined, 10);
159
+ expect(results).toHaveLength(0);
160
+ });
161
+
162
+ it("respects provider filter", () => {
163
+ const results = searchTools(ALL_TOOLS, "list", "slack", 10);
164
+ // slackTool description has "list" via "List" — but depends on description
165
+ // More importantly, github tools should NOT appear
166
+ expect(results.every((r) => r.name.startsWith("slack__"))).toBe(true);
167
+ });
168
+
169
+ it("respects limit", () => {
170
+ const results = searchTools(ALL_TOOLS, "list", undefined, 2);
171
+ expect(results.length).toBeLessThanOrEqual(2);
172
+ });
173
+
174
+ it("returns tags in results", () => {
175
+ const results = searchTools(ALL_TOOLS, "repos", undefined, 10);
176
+ const reposResult = results.find((r) => r.name === "github__repos_list");
177
+ expect(reposResult?.tags).toEqual(["repos"]);
178
+ });
179
+
180
+ it("ranks name matches above description-only matches", () => {
181
+ // "repos" is in the name of reposTool but only in description of issuesTool
182
+ const tools = [
183
+ reposTool,
184
+ makeTool({
185
+ mcpName: "github__something_else",
186
+ providerName: "github",
187
+ description: "Access repos for any organization.",
188
+ tags: [],
189
+ }),
190
+ ];
191
+ const results = searchTools(tools, "repos", undefined, 10);
192
+ // reposTool (name match) should come before the description-only match
193
+ expect(results[0]?.name).toBe("github__repos_list");
194
+ });
195
+ });
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // groupTools
199
+ // ---------------------------------------------------------------------------
200
+
201
+ describe("groupTools", () => {
202
+ it("groups by provider", () => {
203
+ const grouped = groupTools(ALL_TOOLS, "provider");
204
+ expect(grouped["github"]).toEqual(
205
+ expect.arrayContaining(["github__repos_list", "github__issues_list", "github__pulls_list"]),
206
+ );
207
+ expect(grouped["slack"]).toEqual(["slack__messages_send"]);
208
+ });
209
+
210
+ it("groups by tag", () => {
211
+ const grouped = groupTools(ALL_TOOLS, "tag");
212
+ expect(grouped["repos"]).toEqual(["github__repos_list"]);
213
+ expect(grouped["issues"]).toEqual(["github__issues_list"]);
214
+ expect(grouped["pulls"]).toEqual(["github__pulls_list"]);
215
+ expect(grouped["messaging"]).toEqual(["slack__messages_send"]);
216
+ });
217
+
218
+ it("falls back to provider name for tools with no tags when grouping by tag", () => {
219
+ const noTagTool = makeTool({
220
+ mcpName: "provider__notag_tool",
221
+ providerName: "myprovider",
222
+ tags: [],
223
+ });
224
+ const grouped = groupTools([noTagTool], "tag");
225
+ expect(grouped["myprovider"]).toEqual(["provider__notag_tool"]);
226
+ });
227
+
228
+ it("returns empty object for empty tool list", () => {
229
+ expect(groupTools([], "provider")).toEqual({});
230
+ expect(groupTools([], "tag")).toEqual({});
231
+ });
232
+
233
+ it("assigns multi-tag tool to all its tag groups", () => {
234
+ const multiTagTool = makeTool({
235
+ mcpName: "github__multi_tool",
236
+ providerName: "github",
237
+ tags: ["repos", "admin"],
238
+ });
239
+ const grouped = groupTools([multiTagTool], "tag");
240
+ expect(grouped["repos"]).toContain("github__multi_tool");
241
+ expect(grouped["admin"]).toContain("github__multi_tool");
242
+ });
243
+ });
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Public API of @utdk/mcp-core.
3
+ *
4
+ * Shared tool catalog + meta-tool handlers consumed by both the stdio MCP
5
+ * server (@utdk/mcp) and the hosted gateway (Phase 2). Nothing here depends on
6
+ * a specific transport: tool execution is injected via the {@link Execute}
7
+ * callback in {@link handleCallTool}.
8
+ */
9
+ export * from "./loader.js";
10
+ export * from "./search.js";
11
+ export * from "./meta-tools.js";