@aprovan/mcp-app-server 0.1.0-dev.4d82df8
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 +23 -0
- package/E2E_TESTING.md +224 -0
- package/LICENSE +373 -0
- package/README.md +164 -0
- package/dist/index.d.ts +128 -0
- package/dist/index.js +990 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/dist/runtime/assets/index-tRNuU9ap.js +1059 -0
- package/dist/runtime/index.html +38 -0
- package/dist/server.d.ts +6 -0
- package/dist/server.js +1511 -0
- package/dist/server.js.map +1 -0
- package/dist/shell/shell.js +67 -0
- package/docs/widget-preview.png +0 -0
- package/e2e/__snapshots__/.gitkeep +0 -0
- package/e2e/global-setup.ts +114 -0
- package/e2e/global-teardown.ts +15 -0
- package/e2e/visual-regression.test.ts +86 -0
- package/e2e/widget-smoke.test.ts +109 -0
- package/index.html +32 -0
- package/package.json +51 -0
- package/playwright.config.ts +43 -0
- package/src/__tests__/live-update.test.ts +158 -0
- package/src/__tests__/local-backend.test.ts +100 -0
- package/src/__tests__/memory-backend.ts +144 -0
- package/src/__tests__/registry-backend.test.ts +256 -0
- package/src/__tests__/services.test.ts +153 -0
- package/src/__tests__/shim.test.ts +60 -0
- package/src/__tests__/widget-store.test.ts +188 -0
- package/src/e2e-visual.ts +148 -0
- package/src/index.ts +608 -0
- package/src/live-update.ts +150 -0
- package/src/logger.ts +44 -0
- package/src/reference-widgets/live-dashboard.ts +162 -0
- package/src/registry-backend.ts +306 -0
- package/src/runtime/index.html +38 -0
- package/src/runtime/main.ts +164 -0
- package/src/scripts/export-artifacts.ts +51 -0
- package/src/server.ts +398 -0
- package/src/services.ts +307 -0
- package/src/shell/main.ts +172 -0
- package/src/shim.ts +106 -0
- package/src/tunnel.ts +123 -0
- package/src/widget-store/index.ts +10 -0
- package/src/widget-store/local-backend.ts +77 -0
- package/src/widget-store/store.ts +242 -0
- package/src/widget-store/types.ts +53 -0
- package/tsconfig.json +10 -0
- package/tsup.config.ts +14 -0
- package/vite.runtime.config.ts +28 -0
- package/vite.shell.config.ts +30 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
6
|
+
import { LocalFileBackend } from "../widget-store/local-backend.js";
|
|
7
|
+
|
|
8
|
+
let testDir: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
testDir = resolve(tmpdir(), `widget-store-test-${randomUUID()}`);
|
|
12
|
+
await mkdir(testDir, { recursive: true });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
await rm(testDir, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("LocalFileBackend", () => {
|
|
20
|
+
it("writes and reads files", async () => {
|
|
21
|
+
const backend = new LocalFileBackend(testDir);
|
|
22
|
+
await backend.writeFile("test.txt", "hello");
|
|
23
|
+
const content = await backend.readFile("test.txt");
|
|
24
|
+
expect(content).toBe("hello");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("creates parent directories on write", async () => {
|
|
28
|
+
const backend = new LocalFileBackend(testDir);
|
|
29
|
+
await backend.writeFile("sub/dir/file.txt", "nested");
|
|
30
|
+
const content = await backend.readFile("sub/dir/file.txt");
|
|
31
|
+
expect(content).toBe("nested");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("checks file existence", async () => {
|
|
35
|
+
const backend = new LocalFileBackend(testDir);
|
|
36
|
+
expect(await backend.exists("missing.txt")).toBe(false);
|
|
37
|
+
await backend.writeFile("exists.txt", "yes");
|
|
38
|
+
expect(await backend.exists("exists.txt")).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("deletes files", async () => {
|
|
42
|
+
const backend = new LocalFileBackend(testDir);
|
|
43
|
+
await backend.writeFile("todelete.txt", "bye");
|
|
44
|
+
await backend.unlink("todelete.txt");
|
|
45
|
+
expect(await backend.exists("todelete.txt")).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("stats files and directories", async () => {
|
|
49
|
+
const backend = new LocalFileBackend(testDir);
|
|
50
|
+
await backend.writeFile("file.txt", "content");
|
|
51
|
+
const fileStat = await backend.stat("file.txt");
|
|
52
|
+
expect(fileStat.isFile()).toBe(true);
|
|
53
|
+
expect(fileStat.size).toBe(7);
|
|
54
|
+
|
|
55
|
+
await backend.mkdir("mydir");
|
|
56
|
+
const dirStat = await backend.stat("mydir");
|
|
57
|
+
expect(dirStat.isDirectory()).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("lists directory entries", async () => {
|
|
61
|
+
const backend = new LocalFileBackend(testDir);
|
|
62
|
+
await backend.writeFile("a.txt", "a");
|
|
63
|
+
await backend.writeFile("b.txt", "b");
|
|
64
|
+
await backend.mkdir("subdir");
|
|
65
|
+
|
|
66
|
+
const entries = await backend.readdir("");
|
|
67
|
+
const names = entries.map((e) => e.name);
|
|
68
|
+
expect(names).toContain("a.txt");
|
|
69
|
+
expect(names).toContain("b.txt");
|
|
70
|
+
expect(names).toContain("subdir");
|
|
71
|
+
|
|
72
|
+
const subdirEntry = entries.find((e) => e.name === "subdir");
|
|
73
|
+
expect(subdirEntry!.isDirectory()).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("removes directories recursively", async () => {
|
|
77
|
+
const backend = new LocalFileBackend(testDir);
|
|
78
|
+
await backend.mkdir("sub/nested", { recursive: true });
|
|
79
|
+
await backend.writeFile("sub/nested/file.txt", "data");
|
|
80
|
+
|
|
81
|
+
await backend.rmdir("sub", { recursive: true });
|
|
82
|
+
expect(await backend.exists("sub")).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("survives widget store pattern (save + load)", async () => {
|
|
86
|
+
const backend = new LocalFileBackend(testDir);
|
|
87
|
+
|
|
88
|
+
await backend.writeFile("widgets/my-widget/abc123/view.html", "<html>test</html>");
|
|
89
|
+
await backend.writeFile(
|
|
90
|
+
"widgets/my-widget/abc123/manifest.json",
|
|
91
|
+
JSON.stringify({ name: "my-widget", version: "1.0", hash: "abc123" }),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const html = await backend.readFile("widgets/my-widget/abc123/view.html");
|
|
95
|
+
expect(html).toBe("<html>test</html>");
|
|
96
|
+
|
|
97
|
+
const manifest = JSON.parse(await backend.readFile("widgets/my-widget/abc123/manifest.json"));
|
|
98
|
+
expect(manifest.name).toBe("my-widget");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { DirEntry, FileStats, FSProvider } from "../widget-store/types.js";
|
|
2
|
+
|
|
3
|
+
function createDirEntry(name: string, isDir: boolean): DirEntry {
|
|
4
|
+
return {
|
|
5
|
+
name,
|
|
6
|
+
isFile: () => !isDir,
|
|
7
|
+
isDirectory: () => isDir,
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function createFileStats(size: number, mtime: Date, isDir: boolean): FileStats {
|
|
12
|
+
return {
|
|
13
|
+
size,
|
|
14
|
+
mtime,
|
|
15
|
+
isFile: () => !isDir,
|
|
16
|
+
isDirectory: () => isDir,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface FileEntry {
|
|
21
|
+
content: string;
|
|
22
|
+
mtime: Date;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class MemoryBackend implements FSProvider {
|
|
26
|
+
private files = new Map<string, FileEntry>();
|
|
27
|
+
private dirs = new Set<string>([""]);
|
|
28
|
+
|
|
29
|
+
async readFile(path: string): Promise<string> {
|
|
30
|
+
const normalized = path.replace(/^\/+|\/+$/g, "");
|
|
31
|
+
const entry = this.files.get(normalized);
|
|
32
|
+
if (!entry) throw new Error(`ENOENT: ${path}`);
|
|
33
|
+
return entry.content;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async writeFile(path: string, content: string): Promise<void> {
|
|
37
|
+
const normalized = path.replace(/^\/+|\/+$/g, "");
|
|
38
|
+
const dir = normalized.split("/").slice(0, -1).join("/");
|
|
39
|
+
if (dir && !this.dirs.has(dir)) {
|
|
40
|
+
await this.mkdir(dir, { recursive: true });
|
|
41
|
+
}
|
|
42
|
+
this.files.set(normalized, { content, mtime: new Date() });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async unlink(path: string): Promise<void> {
|
|
46
|
+
const normalized = path.replace(/^\/+|\/+$/g, "");
|
|
47
|
+
if (!this.files.delete(normalized)) {
|
|
48
|
+
throw new Error(`ENOENT: ${path}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async stat(path: string): Promise<FileStats> {
|
|
53
|
+
const normalized = path.replace(/^\/+|\/+$/g, "");
|
|
54
|
+
const entry = this.files.get(normalized);
|
|
55
|
+
if (entry) {
|
|
56
|
+
return createFileStats(entry.content.length, entry.mtime, false);
|
|
57
|
+
}
|
|
58
|
+
if (this.dirs.has(normalized)) {
|
|
59
|
+
return createFileStats(0, new Date(), true);
|
|
60
|
+
}
|
|
61
|
+
throw new Error(`ENOENT: ${path}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async mkdir(path: string, options?: { recursive?: boolean }): Promise<void> {
|
|
65
|
+
const normalized = path.replace(/^\/+|\/+$/g, "");
|
|
66
|
+
if (this.dirs.has(normalized)) return;
|
|
67
|
+
|
|
68
|
+
const parent = normalized.split("/").slice(0, -1).join("/");
|
|
69
|
+
if (parent && !this.dirs.has(parent)) {
|
|
70
|
+
if (options?.recursive) {
|
|
71
|
+
await this.mkdir(parent, options);
|
|
72
|
+
} else {
|
|
73
|
+
throw new Error(`ENOENT: ${parent}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
this.dirs.add(normalized);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async readdir(path: string): Promise<DirEntry[]> {
|
|
80
|
+
const normalized = path.replace(/^\/+|\/+$/g, "");
|
|
81
|
+
if (!this.dirs.has(normalized)) {
|
|
82
|
+
throw new Error(`ENOENT: ${path}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const prefix = normalized ? `${normalized}/` : "";
|
|
86
|
+
const entries = new Map<string, boolean>();
|
|
87
|
+
|
|
88
|
+
for (const filePath of this.files.keys()) {
|
|
89
|
+
if (filePath.startsWith(prefix)) {
|
|
90
|
+
const rest = filePath.slice(prefix.length);
|
|
91
|
+
const name = rest.split("/")[0];
|
|
92
|
+
if (name) entries.set(name, false);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const dirPath of this.dirs) {
|
|
97
|
+
if (dirPath.startsWith(prefix) && dirPath !== normalized) {
|
|
98
|
+
const rest = dirPath.slice(prefix.length);
|
|
99
|
+
const name = rest.split("/")[0];
|
|
100
|
+
if (name) entries.set(name, true);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return Array.from(entries).map(([name, isDir]) =>
|
|
105
|
+
createDirEntry(name, isDir),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async rmdir(path: string, options?: { recursive?: boolean }): Promise<void> {
|
|
110
|
+
const normalized = path.replace(/^\/+|\/+$/g, "");
|
|
111
|
+
if (!this.dirs.has(normalized)) {
|
|
112
|
+
throw new Error(`ENOENT: ${path}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const prefix = `${normalized}/`;
|
|
116
|
+
const hasChildren =
|
|
117
|
+
[...this.files.keys()].some((p) => p.startsWith(prefix)) ||
|
|
118
|
+
[...this.dirs].some((d) => d.startsWith(prefix));
|
|
119
|
+
|
|
120
|
+
if (hasChildren && !options?.recursive) {
|
|
121
|
+
throw new Error(`ENOTEMPTY: ${path}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (options?.recursive) {
|
|
125
|
+
for (const filePath of [...this.files.keys()]) {
|
|
126
|
+
if (filePath.startsWith(prefix)) {
|
|
127
|
+
this.files.delete(filePath);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
for (const dirPath of [...this.dirs]) {
|
|
131
|
+
if (dirPath.startsWith(prefix)) {
|
|
132
|
+
this.dirs.delete(dirPath);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
this.dirs.delete(normalized);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async exists(path: string): Promise<boolean> {
|
|
141
|
+
const normalized = path.replace(/^\/+|\/+$/g, "");
|
|
142
|
+
return this.files.has(normalized) || this.dirs.has(normalized);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { parseRegistryToolName, createRegistryBackend } from "../registry-backend.js";
|
|
3
|
+
import type { Mock } from "vitest";
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Module-level mocks (hoisted by vitest)
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
// Shared mutable ref so each test can configure callTool behaviour.
|
|
9
|
+
let callToolImpl: Mock = vi.fn();
|
|
10
|
+
|
|
11
|
+
vi.mock("@modelcontextprotocol/sdk/client/stdio.js", () => ({
|
|
12
|
+
StdioClientTransport: vi.fn().mockImplementation(() => ({
|
|
13
|
+
start: vi.fn().mockResolvedValue(undefined),
|
|
14
|
+
close: vi.fn().mockResolvedValue(undefined),
|
|
15
|
+
send: vi.fn().mockResolvedValue(undefined),
|
|
16
|
+
onmessage: null,
|
|
17
|
+
onclose: null,
|
|
18
|
+
onerror: null,
|
|
19
|
+
})),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock("@modelcontextprotocol/sdk/client/index.js", () => ({
|
|
23
|
+
Client: vi.fn().mockImplementation(() => ({
|
|
24
|
+
connect: vi.fn().mockResolvedValue(undefined),
|
|
25
|
+
callTool: (...args: Parameters<Mock>) => callToolImpl(...args),
|
|
26
|
+
close: vi.fn().mockResolvedValue(undefined),
|
|
27
|
+
})),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Import under test (after mocks are registered)
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Helpers
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
function textContent(text: string) {
|
|
39
|
+
return { type: "text" as const, text };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function mockSuccess(text: string) {
|
|
43
|
+
return Promise.resolve({ isError: false, content: [textContent(text)] });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function mockError(text: string) {
|
|
47
|
+
return Promise.resolve({ isError: true, content: [textContent(text)] });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build a callTool mock that serves list_tools + tool_info for two providers.
|
|
52
|
+
*/
|
|
53
|
+
function twoToolCallTool() {
|
|
54
|
+
return vi.fn().mockImplementation(({ name, arguments: args }: { name: string; arguments: Record<string, unknown> }) => {
|
|
55
|
+
if (name === "list_tools") {
|
|
56
|
+
return mockSuccess(JSON.stringify(["github__repos_list", "stripe__charges_list"]));
|
|
57
|
+
}
|
|
58
|
+
if (name === "tool_info") {
|
|
59
|
+
const schemas: Record<string, object> = {
|
|
60
|
+
github__repos_list: {
|
|
61
|
+
name: "github__repos_list",
|
|
62
|
+
description: "List GitHub repositories",
|
|
63
|
+
inputSchema: {
|
|
64
|
+
type: "object",
|
|
65
|
+
properties: { per_page: { type: "number" } },
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
stripe__charges_list: {
|
|
69
|
+
name: "stripe__charges_list",
|
|
70
|
+
description: "List Stripe charges",
|
|
71
|
+
inputSchema: {
|
|
72
|
+
type: "object",
|
|
73
|
+
properties: { limit: { type: "number" } },
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
const schema = schemas[args["tool_name"] as string] ?? { name: args["tool_name"] };
|
|
78
|
+
return mockSuccess(JSON.stringify(schema));
|
|
79
|
+
}
|
|
80
|
+
return mockError("unexpected call");
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// parseRegistryToolName
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
describe("parseRegistryToolName", () => {
|
|
89
|
+
it("splits on first double-underscore", () => {
|
|
90
|
+
expect(parseRegistryToolName("github__repos_list")).toEqual({
|
|
91
|
+
namespace: "github",
|
|
92
|
+
procedure: "repos_list",
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("handles procedure with multiple underscores", () => {
|
|
97
|
+
expect(parseRegistryToolName("stripe__payment_intents_create")).toEqual({
|
|
98
|
+
namespace: "stripe",
|
|
99
|
+
procedure: "payment_intents_create",
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("handles provider with hyphen (e.g. google-cloud-run)", () => {
|
|
104
|
+
expect(parseRegistryToolName("google-cloud-run__jobs_list")).toEqual({
|
|
105
|
+
namespace: "google-cloud-run",
|
|
106
|
+
procedure: "jobs_list",
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("returns fallback procedure when no separator present", () => {
|
|
111
|
+
expect(parseRegistryToolName("unknown")).toEqual({
|
|
112
|
+
namespace: "unknown",
|
|
113
|
+
procedure: "call",
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("handles short names like provider__op", () => {
|
|
118
|
+
expect(parseRegistryToolName("slack__conversations_info")).toEqual({
|
|
119
|
+
namespace: "slack",
|
|
120
|
+
procedure: "conversations_info",
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// createRegistryBackend
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
describe("createRegistryBackend", () => {
|
|
130
|
+
beforeEach(() => {
|
|
131
|
+
// Reset to a fresh mock before each test so tests are independent.
|
|
132
|
+
callToolImpl = twoToolCallTool();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("loads tool infos from the Registry and maps them to ServiceToolInfo", async () => {
|
|
136
|
+
const backend = await createRegistryBackend({
|
|
137
|
+
command: "npx",
|
|
138
|
+
args: ["@utdk/mcp"],
|
|
139
|
+
providers: "github,stripe",
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const tools = backend.getToolInfos();
|
|
143
|
+
expect(tools).toHaveLength(2);
|
|
144
|
+
|
|
145
|
+
const githubTool = tools.find((t) => t.namespace === "github");
|
|
146
|
+
expect(githubTool).toBeDefined();
|
|
147
|
+
expect(githubTool?.procedure).toBe("repos_list");
|
|
148
|
+
expect(githubTool?.name).toBe("github.repos_list");
|
|
149
|
+
expect(githubTool?.description).toBe("List GitHub repositories");
|
|
150
|
+
|
|
151
|
+
const stripeTool = tools.find((t) => t.namespace === "stripe");
|
|
152
|
+
expect(stripeTool).toBeDefined();
|
|
153
|
+
expect(stripeTool?.procedure).toBe("charges_list");
|
|
154
|
+
expect(stripeTool?.name).toBe("stripe.charges_list");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("calls the Registry call_tool meta-tool with the correct tool name and args", async () => {
|
|
158
|
+
const captured: { name: string; arguments: Record<string, unknown> }[] = [];
|
|
159
|
+
|
|
160
|
+
callToolImpl = vi.fn().mockImplementation(({ name, arguments: args }: { name: string; arguments: Record<string, unknown> }) => {
|
|
161
|
+
captured.push({ name, arguments: args });
|
|
162
|
+
if (name === "list_tools") return mockSuccess(JSON.stringify(["github__repos_list"]));
|
|
163
|
+
if (name === "tool_info") {
|
|
164
|
+
return mockSuccess(JSON.stringify({ name: "github__repos_list", description: "List repos" }));
|
|
165
|
+
}
|
|
166
|
+
if (name === "call_tool") {
|
|
167
|
+
return mockSuccess(JSON.stringify([{ name: "my-repo" }]));
|
|
168
|
+
}
|
|
169
|
+
return mockError("unexpected");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const backend = await createRegistryBackend({
|
|
173
|
+
command: "npx",
|
|
174
|
+
args: ["@utdk/mcp"],
|
|
175
|
+
providers: "github",
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const result = await backend.call("github", "repos_list", [{ per_page: 10 }]);
|
|
179
|
+
|
|
180
|
+
// Verify the result is parsed JSON
|
|
181
|
+
expect(result).toEqual([{ name: "my-repo" }]);
|
|
182
|
+
|
|
183
|
+
// Verify call_tool was invoked with the right tool name and arguments
|
|
184
|
+
const callToolInvocation = captured.find((c) => c.name === "call_tool");
|
|
185
|
+
expect(callToolInvocation).toBeDefined();
|
|
186
|
+
expect(callToolInvocation?.arguments["tool_name"]).toBe("github__repos_list");
|
|
187
|
+
expect(callToolInvocation?.arguments["arguments"]).toEqual({ per_page: 10 });
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("throws when the Registry returns isError = true", async () => {
|
|
191
|
+
callToolImpl = vi.fn().mockImplementation(({ name }: { name: string }) => {
|
|
192
|
+
if (name === "list_tools") return mockSuccess(JSON.stringify([]));
|
|
193
|
+
if (name === "call_tool") return mockError("Unknown tool: bad__tool");
|
|
194
|
+
return mockError("unexpected");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const backend = await createRegistryBackend({
|
|
198
|
+
command: "npx",
|
|
199
|
+
args: ["@utdk/mcp"],
|
|
200
|
+
providers: "bad",
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
await expect(backend.call("bad", "tool", [{}])).rejects.toThrow("Unknown tool: bad__tool");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("parses JSON response from call_tool automatically", async () => {
|
|
207
|
+
callToolImpl = vi.fn().mockImplementation(({ name }: { name: string }) => {
|
|
208
|
+
if (name === "list_tools") return mockSuccess(JSON.stringify([]));
|
|
209
|
+
if (name === "call_tool") {
|
|
210
|
+
return mockSuccess(JSON.stringify({ id: "ch_123", amount: 5000 }));
|
|
211
|
+
}
|
|
212
|
+
return mockError("unexpected");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const backend = await createRegistryBackend({
|
|
216
|
+
command: "npx",
|
|
217
|
+
args: ["@utdk/mcp"],
|
|
218
|
+
providers: "stripe",
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const result = await backend.call("stripe", "charges_retrieve", [{ id: "ch_123" }]);
|
|
222
|
+
expect(result).toEqual({ id: "ch_123", amount: 5000 });
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("returns plain text when response is not valid JSON", async () => {
|
|
226
|
+
callToolImpl = vi.fn().mockImplementation(({ name }: { name: string }) => {
|
|
227
|
+
if (name === "list_tools") return mockSuccess(JSON.stringify([]));
|
|
228
|
+
if (name === "call_tool") return mockSuccess("plain text response");
|
|
229
|
+
return mockError("unexpected");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const backend = await createRegistryBackend({
|
|
233
|
+
command: "npx",
|
|
234
|
+
args: ["@utdk/mcp"],
|
|
235
|
+
providers: "some",
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const result = await backend.call("some", "op", [{}]);
|
|
239
|
+
expect(result).toBe("plain text response");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("handles an empty tool list gracefully", async () => {
|
|
243
|
+
callToolImpl = vi.fn().mockImplementation(({ name }: { name: string }) => {
|
|
244
|
+
if (name === "list_tools") return mockSuccess(JSON.stringify([]));
|
|
245
|
+
return mockError("should not be called");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const backend = await createRegistryBackend({
|
|
249
|
+
command: "npx",
|
|
250
|
+
args: ["@utdk/mcp"],
|
|
251
|
+
providers: "",
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
expect(backend.getToolInfos()).toHaveLength(0);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { describe, it, expect, vi } from "vitest";
|
|
3
|
+
import { ServiceBridge, type ServiceBackend, type ServiceToolInfo } from "../services.js";
|
|
4
|
+
|
|
5
|
+
const mockBackend: ServiceBackend = {
|
|
6
|
+
call: vi.fn(async () => ({
|
|
7
|
+
result: "mock-data",
|
|
8
|
+
})),
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const mockTools: ServiceToolInfo[] = [
|
|
12
|
+
{
|
|
13
|
+
name: "weather.get_forecast",
|
|
14
|
+
namespace: "weather",
|
|
15
|
+
procedure: "get_forecast",
|
|
16
|
+
description: "Get weather forecast for a location",
|
|
17
|
+
parameters: {
|
|
18
|
+
type: "object",
|
|
19
|
+
properties: {
|
|
20
|
+
latitude: { type: "number", description: "Latitude" },
|
|
21
|
+
longitude: { type: "number", description: "Longitude" },
|
|
22
|
+
},
|
|
23
|
+
required: ["latitude", "longitude"],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "weather.get_current_conditions",
|
|
28
|
+
namespace: "weather",
|
|
29
|
+
procedure: "get_current_conditions",
|
|
30
|
+
description: "Get current weather conditions",
|
|
31
|
+
parameters: {
|
|
32
|
+
type: "object",
|
|
33
|
+
properties: {
|
|
34
|
+
location: { type: "string", description: "Location name" },
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "stripe.create_payment",
|
|
40
|
+
namespace: "stripe",
|
|
41
|
+
procedure: "create_payment",
|
|
42
|
+
description: "Create a payment intent",
|
|
43
|
+
parameters: {
|
|
44
|
+
type: "object",
|
|
45
|
+
properties: {
|
|
46
|
+
amount: { type: "number", description: "Amount in cents" },
|
|
47
|
+
currency: { type: "string", description: "Currency code" },
|
|
48
|
+
},
|
|
49
|
+
required: ["amount"],
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
describe("ServiceBridge", () => {
|
|
55
|
+
describe("constructor", () => {
|
|
56
|
+
it("stores tools from config", () => {
|
|
57
|
+
const bridge = new ServiceBridge({
|
|
58
|
+
backend: mockBackend,
|
|
59
|
+
tools: mockTools,
|
|
60
|
+
});
|
|
61
|
+
expect(bridge.getToolInfos()).toHaveLength(3);
|
|
62
|
+
expect(bridge.getNamespaces()).toEqual(["weather", "stripe"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("handles empty tools array", () => {
|
|
66
|
+
const bridge = new ServiceBridge({
|
|
67
|
+
backend: mockBackend,
|
|
68
|
+
tools: [],
|
|
69
|
+
});
|
|
70
|
+
expect(bridge.getToolInfos()).toHaveLength(0);
|
|
71
|
+
expect(bridge.getNamespaces()).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("getNamespaces", () => {
|
|
76
|
+
it("returns unique namespaces", () => {
|
|
77
|
+
const bridge = new ServiceBridge({
|
|
78
|
+
backend: mockBackend,
|
|
79
|
+
tools: mockTools,
|
|
80
|
+
});
|
|
81
|
+
const namespaces = bridge.getNamespaces();
|
|
82
|
+
expect(namespaces).toContain("weather");
|
|
83
|
+
expect(namespaces).toContain("stripe");
|
|
84
|
+
expect(namespaces).toHaveLength(2);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("has", () => {
|
|
89
|
+
it("returns true for existing tools", () => {
|
|
90
|
+
const bridge = new ServiceBridge({
|
|
91
|
+
backend: mockBackend,
|
|
92
|
+
tools: mockTools,
|
|
93
|
+
});
|
|
94
|
+
expect(bridge.has("weather", "get_forecast")).toBe(true);
|
|
95
|
+
expect(bridge.has("stripe", "create_payment")).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("returns false for missing tools", () => {
|
|
99
|
+
const bridge = new ServiceBridge({
|
|
100
|
+
backend: mockBackend,
|
|
101
|
+
tools: mockTools,
|
|
102
|
+
});
|
|
103
|
+
expect(bridge.has("weather", "nonexistent")).toBe(false);
|
|
104
|
+
expect(bridge.has("unknown", "something")).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("registerTools", () => {
|
|
109
|
+
it("registers service tools on the MCP server", () => {
|
|
110
|
+
const bridge = new ServiceBridge({
|
|
111
|
+
backend: mockBackend,
|
|
112
|
+
tools: mockTools,
|
|
113
|
+
});
|
|
114
|
+
const server = new McpServer({
|
|
115
|
+
name: "test-server",
|
|
116
|
+
version: "0.1.0",
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
bridge.registerTools(server);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("registerSearchServices", () => {
|
|
124
|
+
it("registers search_services tool on the MCP server", () => {
|
|
125
|
+
const bridge = new ServiceBridge({
|
|
126
|
+
backend: mockBackend,
|
|
127
|
+
tools: mockTools,
|
|
128
|
+
});
|
|
129
|
+
const server = new McpServer({
|
|
130
|
+
name: "test-server",
|
|
131
|
+
version: "0.1.0",
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
bridge.registerSearchServices(server);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("tool name mapping", () => {
|
|
139
|
+
it("uses __ separator for MCP tool names (not dots)", () => {
|
|
140
|
+
const bridge = new ServiceBridge({
|
|
141
|
+
backend: mockBackend,
|
|
142
|
+
tools: mockTools,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// The internal tool names use dots, but MCP tool names use __
|
|
146
|
+
// We can verify this by checking the tool infos still have dot names
|
|
147
|
+
const toolInfo = bridge.getToolInfos();
|
|
148
|
+
expect(toolInfo[0]?.name).toBe("weather.get_forecast");
|
|
149
|
+
expect(toolInfo[0]?.namespace).toBe("weather");
|
|
150
|
+
expect(toolInfo[0]?.procedure).toBe("get_forecast");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
});
|