@evantahler/mcpx 0.15.0
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/.claude/settings.local.json +18 -0
- package/.claude/skills/mcpx.md +165 -0
- package/.claude/worktrees/elastic-jennings/.claude/settings.local.json +18 -0
- package/.claude/worktrees/elastic-jennings/.claude/skills/mcpcli.md +93 -0
- package/.claude/worktrees/elastic-jennings/.github/workflows/auto-release.yml +117 -0
- package/.claude/worktrees/elastic-jennings/.github/workflows/ci.yml +18 -0
- package/.claude/worktrees/elastic-jennings/.prettierignore +4 -0
- package/.claude/worktrees/elastic-jennings/.prettierrc +7 -0
- package/.claude/worktrees/elastic-jennings/CLAUDE.md +19 -0
- package/.claude/worktrees/elastic-jennings/LICENSE +21 -0
- package/.claude/worktrees/elastic-jennings/README.md +487 -0
- package/.claude/worktrees/elastic-jennings/bun.lock +381 -0
- package/.claude/worktrees/elastic-jennings/install.sh +55 -0
- package/.claude/worktrees/elastic-jennings/package.json +56 -0
- package/.claude/worktrees/elastic-jennings/src/cli.ts +39 -0
- package/.claude/worktrees/elastic-jennings/src/client/http.ts +100 -0
- package/.claude/worktrees/elastic-jennings/src/client/manager.ts +266 -0
- package/.claude/worktrees/elastic-jennings/src/client/oauth.ts +299 -0
- package/.claude/worktrees/elastic-jennings/src/client/stdio.ts +12 -0
- package/.claude/worktrees/elastic-jennings/src/commands/add.ts +155 -0
- package/.claude/worktrees/elastic-jennings/src/commands/auth.ts +114 -0
- package/.claude/worktrees/elastic-jennings/src/commands/exec.ts +91 -0
- package/.claude/worktrees/elastic-jennings/src/commands/index.ts +62 -0
- package/.claude/worktrees/elastic-jennings/src/commands/info.ts +38 -0
- package/.claude/worktrees/elastic-jennings/src/commands/list.ts +30 -0
- package/.claude/worktrees/elastic-jennings/src/commands/remove.ts +67 -0
- package/.claude/worktrees/elastic-jennings/src/commands/search.ts +45 -0
- package/.claude/worktrees/elastic-jennings/src/commands/skill.ts +70 -0
- package/.claude/worktrees/elastic-jennings/src/config/env.ts +41 -0
- package/.claude/worktrees/elastic-jennings/src/config/loader.ts +156 -0
- package/.claude/worktrees/elastic-jennings/src/config/schemas.ts +137 -0
- package/.claude/worktrees/elastic-jennings/src/context.ts +53 -0
- package/.claude/worktrees/elastic-jennings/src/output/formatter.ts +316 -0
- package/.claude/worktrees/elastic-jennings/src/output/logger.ts +114 -0
- package/.claude/worktrees/elastic-jennings/src/search/index.ts +69 -0
- package/.claude/worktrees/elastic-jennings/src/search/indexer.ts +92 -0
- package/.claude/worktrees/elastic-jennings/src/search/keyword.ts +86 -0
- package/.claude/worktrees/elastic-jennings/src/search/semantic.ts +75 -0
- package/.claude/worktrees/elastic-jennings/src/search/staleness.ts +8 -0
- package/.claude/worktrees/elastic-jennings/src/validation/schema.ts +77 -0
- package/.claude/worktrees/elastic-jennings/test/cli.test.ts +51 -0
- package/.claude/worktrees/elastic-jennings/test/client/manager.test.ts +249 -0
- package/.claude/worktrees/elastic-jennings/test/client/oauth.test.ts +328 -0
- package/.claude/worktrees/elastic-jennings/test/commands/add-remove.test.ts +253 -0
- package/.claude/worktrees/elastic-jennings/test/commands/exec.test.ts +105 -0
- package/.claude/worktrees/elastic-jennings/test/commands/info.test.ts +48 -0
- package/.claude/worktrees/elastic-jennings/test/commands/list.test.ts +39 -0
- package/.claude/worktrees/elastic-jennings/test/commands/skill.test.ts +98 -0
- package/.claude/worktrees/elastic-jennings/test/config/env.test.ts +61 -0
- package/.claude/worktrees/elastic-jennings/test/config/loader.test.ts +139 -0
- package/.claude/worktrees/elastic-jennings/test/fixtures/.keep +0 -0
- package/.claude/worktrees/elastic-jennings/test/fixtures/auth.json +10 -0
- package/.claude/worktrees/elastic-jennings/test/fixtures/mock-config/.keep +0 -0
- package/.claude/worktrees/elastic-jennings/test/fixtures/mock-config/servers.json +8 -0
- package/.claude/worktrees/elastic-jennings/test/fixtures/mock-server.ts +113 -0
- package/.claude/worktrees/elastic-jennings/test/fixtures/search.json +15 -0
- package/.claude/worktrees/elastic-jennings/test/fixtures/servers.json +18 -0
- package/.claude/worktrees/elastic-jennings/test/integration/stdio-server.test.ts +149 -0
- package/.claude/worktrees/elastic-jennings/test/output/formatter.test.ts +54 -0
- package/.claude/worktrees/elastic-jennings/test/output/logger.test.ts +89 -0
- package/.claude/worktrees/elastic-jennings/test/search/indexer.test.ts +32 -0
- package/.claude/worktrees/elastic-jennings/test/search/keyword.test.ts +80 -0
- package/.claude/worktrees/elastic-jennings/test/search/semantic.test.ts +32 -0
- package/.claude/worktrees/elastic-jennings/test/validation/schema.test.ts +113 -0
- package/.claude/worktrees/elastic-jennings/tsconfig.json +29 -0
- package/.cursor/rules/mcpx.mdc +165 -0
- package/LICENSE +21 -0
- package/README.md +627 -0
- package/package.json +58 -0
- package/src/cli.ts +72 -0
- package/src/client/browser.ts +24 -0
- package/src/client/debug-fetch.ts +81 -0
- package/src/client/elicitation.ts +368 -0
- package/src/client/http.ts +25 -0
- package/src/client/manager.ts +566 -0
- package/src/client/oauth.ts +314 -0
- package/src/client/sse.ts +17 -0
- package/src/client/stdio.ts +12 -0
- package/src/client/trace.ts +184 -0
- package/src/commands/add.ts +179 -0
- package/src/commands/auth.ts +114 -0
- package/src/commands/exec.ts +156 -0
- package/src/commands/index.ts +62 -0
- package/src/commands/info.ts +63 -0
- package/src/commands/list.ts +64 -0
- package/src/commands/ping.ts +69 -0
- package/src/commands/prompt.ts +60 -0
- package/src/commands/remove.ts +67 -0
- package/src/commands/resource.ts +46 -0
- package/src/commands/search.ts +49 -0
- package/src/commands/servers.ts +66 -0
- package/src/commands/skill.ts +112 -0
- package/src/commands/task.ts +82 -0
- package/src/config/env.ts +41 -0
- package/src/config/loader.ts +156 -0
- package/src/config/schemas.ts +152 -0
- package/src/context.ts +62 -0
- package/src/lib/input.ts +36 -0
- package/src/output/formatter.ts +884 -0
- package/src/output/logger.ts +173 -0
- package/src/search/index.ts +69 -0
- package/src/search/indexer.ts +92 -0
- package/src/search/keyword.ts +86 -0
- package/src/search/semantic.ts +75 -0
- package/src/search/staleness.ts +8 -0
- package/src/validation/schema.ts +103 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { mkdtemp, rm } from "fs/promises";
|
|
5
|
+
|
|
6
|
+
const CLI = join(import.meta.dir, "../../src/cli.ts");
|
|
7
|
+
|
|
8
|
+
async function run(args: string[], cwd?: string) {
|
|
9
|
+
const proc = Bun.spawn(["bun", "run", CLI, ...args], {
|
|
10
|
+
stdout: "pipe",
|
|
11
|
+
stderr: "pipe",
|
|
12
|
+
cwd,
|
|
13
|
+
});
|
|
14
|
+
const exitCode = await proc.exited;
|
|
15
|
+
const stdout = await new Response(proc.stdout).text();
|
|
16
|
+
const stderr = await new Response(proc.stderr).text();
|
|
17
|
+
return { exitCode, stdout, stderr };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("mcpcli add", () => {
|
|
21
|
+
let tmpDir: string;
|
|
22
|
+
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
tmpDir = await mkdtemp(join(tmpdir(), "mcpcli-add-"));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(async () => {
|
|
28
|
+
await rm(tmpDir, { recursive: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("adds a stdio server", async () => {
|
|
32
|
+
const { exitCode, stdout } = await run([
|
|
33
|
+
"-c",
|
|
34
|
+
tmpDir,
|
|
35
|
+
"add",
|
|
36
|
+
"test-server",
|
|
37
|
+
"--command",
|
|
38
|
+
"echo",
|
|
39
|
+
"--args",
|
|
40
|
+
"hello,world",
|
|
41
|
+
"--no-index",
|
|
42
|
+
]);
|
|
43
|
+
expect(exitCode).toBe(0);
|
|
44
|
+
expect(stdout).toContain('Added server "test-server"');
|
|
45
|
+
|
|
46
|
+
const servers = await Bun.file(join(tmpDir, "servers.json")).json();
|
|
47
|
+
expect(servers.mcpServers["test-server"]).toEqual({
|
|
48
|
+
command: "echo",
|
|
49
|
+
args: ["hello", "world"],
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("adds an http server", async () => {
|
|
54
|
+
const { exitCode } = await run([
|
|
55
|
+
"-c",
|
|
56
|
+
tmpDir,
|
|
57
|
+
"add",
|
|
58
|
+
"my-api",
|
|
59
|
+
"--url",
|
|
60
|
+
"https://example.com/mcp",
|
|
61
|
+
"--header",
|
|
62
|
+
"Authorization:Bearer tok123",
|
|
63
|
+
"--no-index",
|
|
64
|
+
]);
|
|
65
|
+
expect(exitCode).toBe(0);
|
|
66
|
+
|
|
67
|
+
const servers = await Bun.file(join(tmpDir, "servers.json")).json();
|
|
68
|
+
expect(servers.mcpServers["my-api"]).toEqual({
|
|
69
|
+
url: "https://example.com/mcp",
|
|
70
|
+
headers: { Authorization: "Bearer tok123" },
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("adds a stdio server with env and cwd", async () => {
|
|
75
|
+
const { exitCode } = await run([
|
|
76
|
+
"-c",
|
|
77
|
+
tmpDir,
|
|
78
|
+
"add",
|
|
79
|
+
"full-server",
|
|
80
|
+
"--command",
|
|
81
|
+
"node",
|
|
82
|
+
"--args",
|
|
83
|
+
"index.js",
|
|
84
|
+
"--env",
|
|
85
|
+
"KEY=val,FOO=bar",
|
|
86
|
+
"--cwd",
|
|
87
|
+
"/tmp",
|
|
88
|
+
"--no-index",
|
|
89
|
+
]);
|
|
90
|
+
expect(exitCode).toBe(0);
|
|
91
|
+
|
|
92
|
+
const servers = await Bun.file(join(tmpDir, "servers.json")).json();
|
|
93
|
+
expect(servers.mcpServers["full-server"]).toEqual({
|
|
94
|
+
command: "node",
|
|
95
|
+
args: ["index.js"],
|
|
96
|
+
env: { KEY: "val", FOO: "bar" },
|
|
97
|
+
cwd: "/tmp",
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("adds a server with allowed and disabled tools", async () => {
|
|
102
|
+
const { exitCode } = await run([
|
|
103
|
+
"-c",
|
|
104
|
+
tmpDir,
|
|
105
|
+
"add",
|
|
106
|
+
"filtered",
|
|
107
|
+
"--command",
|
|
108
|
+
"echo",
|
|
109
|
+
"--allowed-tools",
|
|
110
|
+
"read,write",
|
|
111
|
+
"--disabled-tools",
|
|
112
|
+
"delete",
|
|
113
|
+
"--no-index",
|
|
114
|
+
]);
|
|
115
|
+
expect(exitCode).toBe(0);
|
|
116
|
+
|
|
117
|
+
const servers = await Bun.file(join(tmpDir, "servers.json")).json();
|
|
118
|
+
expect(servers.mcpServers["filtered"].allowedTools).toEqual(["read", "write"]);
|
|
119
|
+
expect(servers.mcpServers["filtered"].disabledTools).toEqual(["delete"]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("errors if server already exists without --force", async () => {
|
|
123
|
+
await run(["-c", tmpDir, "add", "dupe", "--command", "echo", "--no-index"]);
|
|
124
|
+
const { exitCode, stderr } = await run([
|
|
125
|
+
"-c",
|
|
126
|
+
tmpDir,
|
|
127
|
+
"add",
|
|
128
|
+
"dupe",
|
|
129
|
+
"--command",
|
|
130
|
+
"cat",
|
|
131
|
+
"--no-index",
|
|
132
|
+
]);
|
|
133
|
+
expect(exitCode).not.toBe(0);
|
|
134
|
+
expect(stderr).toContain("already exists");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("overwrites with --force", async () => {
|
|
138
|
+
await run(["-c", tmpDir, "add", "dupe", "--command", "echo", "--no-index"]);
|
|
139
|
+
const { exitCode } = await run([
|
|
140
|
+
"-c",
|
|
141
|
+
tmpDir,
|
|
142
|
+
"add",
|
|
143
|
+
"dupe",
|
|
144
|
+
"--command",
|
|
145
|
+
"cat",
|
|
146
|
+
"--force",
|
|
147
|
+
"--no-index",
|
|
148
|
+
]);
|
|
149
|
+
expect(exitCode).toBe(0);
|
|
150
|
+
|
|
151
|
+
const servers = await Bun.file(join(tmpDir, "servers.json")).json();
|
|
152
|
+
expect(servers.mcpServers["dupe"].command).toBe("cat");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("errors if neither --command nor --url", async () => {
|
|
156
|
+
const { exitCode, stderr } = await run(["-c", tmpDir, "add", "bad"]);
|
|
157
|
+
expect(exitCode).not.toBe(0);
|
|
158
|
+
expect(stderr).toContain("Must specify --command");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("errors if both --command and --url", async () => {
|
|
162
|
+
const { exitCode, stderr } = await run([
|
|
163
|
+
"-c",
|
|
164
|
+
tmpDir,
|
|
165
|
+
"add",
|
|
166
|
+
"bad",
|
|
167
|
+
"--command",
|
|
168
|
+
"echo",
|
|
169
|
+
"--url",
|
|
170
|
+
"https://example.com",
|
|
171
|
+
"--no-index",
|
|
172
|
+
]);
|
|
173
|
+
expect(exitCode).not.toBe(0);
|
|
174
|
+
expect(stderr).toContain("Cannot specify both");
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("mcpcli remove", () => {
|
|
179
|
+
let tmpDir: string;
|
|
180
|
+
|
|
181
|
+
beforeEach(async () => {
|
|
182
|
+
tmpDir = await mkdtemp(join(tmpdir(), "mcpcli-rm-"));
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
afterEach(async () => {
|
|
186
|
+
await rm(tmpDir, { recursive: true });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("removes a server", async () => {
|
|
190
|
+
await run(["-c", tmpDir, "add", "to-remove", "--command", "echo", "--no-index"]);
|
|
191
|
+
const { exitCode, stdout } = await run(["-c", tmpDir, "remove", "to-remove"]);
|
|
192
|
+
expect(exitCode).toBe(0);
|
|
193
|
+
expect(stdout).toContain('Removed server "to-remove"');
|
|
194
|
+
|
|
195
|
+
const servers = await Bun.file(join(tmpDir, "servers.json")).json();
|
|
196
|
+
expect(servers.mcpServers["to-remove"]).toBeUndefined();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("errors on unknown server", async () => {
|
|
200
|
+
const { exitCode, stderr } = await run(["-c", tmpDir, "remove", "nope"]);
|
|
201
|
+
expect(exitCode).not.toBe(0);
|
|
202
|
+
expect(stderr).toContain('Unknown server: "nope"');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("dry-run shows what would happen without changing files", async () => {
|
|
206
|
+
await run(["-c", tmpDir, "add", "keep-me", "--command", "echo", "--no-index"]);
|
|
207
|
+
const { exitCode, stdout } = await run(["-c", tmpDir, "remove", "keep-me", "--dry-run"]);
|
|
208
|
+
expect(exitCode).toBe(0);
|
|
209
|
+
expect(stdout).toContain("Would remove");
|
|
210
|
+
|
|
211
|
+
const servers = await Bun.file(join(tmpDir, "servers.json")).json();
|
|
212
|
+
expect(servers.mcpServers["keep-me"]).toBeDefined();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("removes auth by default", async () => {
|
|
216
|
+
// Add a server, then manually write auth for it
|
|
217
|
+
await run(["-c", tmpDir, "add", "authed", "--url", "https://example.com", "--no-index"]);
|
|
218
|
+
await Bun.write(
|
|
219
|
+
join(tmpDir, "auth.json"),
|
|
220
|
+
JSON.stringify({
|
|
221
|
+
authed: {
|
|
222
|
+
tokens: { access_token: "tok", token_type: "bearer" },
|
|
223
|
+
},
|
|
224
|
+
}),
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const { exitCode, stdout } = await run(["-c", tmpDir, "remove", "authed"]);
|
|
228
|
+
expect(exitCode).toBe(0);
|
|
229
|
+
expect(stdout).toContain("Removed auth");
|
|
230
|
+
|
|
231
|
+
const auth = await Bun.file(join(tmpDir, "auth.json")).json();
|
|
232
|
+
expect(auth.authed).toBeUndefined();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("--keep-auth preserves auth", async () => {
|
|
236
|
+
await run(["-c", tmpDir, "add", "authed", "--url", "https://example.com", "--no-index"]);
|
|
237
|
+
await Bun.write(
|
|
238
|
+
join(tmpDir, "auth.json"),
|
|
239
|
+
JSON.stringify({
|
|
240
|
+
authed: {
|
|
241
|
+
tokens: { access_token: "tok", token_type: "bearer" },
|
|
242
|
+
},
|
|
243
|
+
}),
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
const { exitCode, stdout } = await run(["-c", tmpDir, "remove", "authed", "--keep-auth"]);
|
|
247
|
+
expect(exitCode).toBe(0);
|
|
248
|
+
expect(stdout).not.toContain("Removed auth");
|
|
249
|
+
|
|
250
|
+
const auth = await Bun.file(join(tmpDir, "auth.json")).json();
|
|
251
|
+
expect(auth.authed).toBeDefined();
|
|
252
|
+
});
|
|
253
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
const CLI = join(import.meta.dir, "../../src/cli.ts");
|
|
5
|
+
const CONFIG = join(import.meta.dir, "../fixtures/mock-config");
|
|
6
|
+
|
|
7
|
+
function run(...args: string[]) {
|
|
8
|
+
return Bun.spawn(["bun", "run", CLI, "-c", CONFIG, ...args], {
|
|
9
|
+
stdout: "pipe",
|
|
10
|
+
stderr: "pipe",
|
|
11
|
+
cwd: join(import.meta.dir, "../.."),
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function runWithStdin(stdin: string, ...args: string[]) {
|
|
16
|
+
const proc = Bun.spawn(["bun", "run", CLI, "-c", CONFIG, ...args], {
|
|
17
|
+
stdout: "pipe",
|
|
18
|
+
stderr: "pipe",
|
|
19
|
+
stdin: "pipe",
|
|
20
|
+
cwd: join(import.meta.dir, "../.."),
|
|
21
|
+
});
|
|
22
|
+
proc.stdin.write(stdin);
|
|
23
|
+
proc.stdin.end();
|
|
24
|
+
return proc;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("mcpcli exec", () => {
|
|
28
|
+
test("calls a tool with inline JSON args", async () => {
|
|
29
|
+
const proc = run("exec", "mock", "echo", '{"message": "hello world"}');
|
|
30
|
+
const exitCode = await proc.exited;
|
|
31
|
+
const stdout = await new Response(proc.stdout).text();
|
|
32
|
+
expect(exitCode).toBe(0);
|
|
33
|
+
|
|
34
|
+
const result = JSON.parse(stdout);
|
|
35
|
+
expect(result.content[0].text).toBe("hello world");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("calls add tool", async () => {
|
|
39
|
+
const proc = run("exec", "mock", "add", '{"a": 10, "b": 20}');
|
|
40
|
+
const exitCode = await proc.exited;
|
|
41
|
+
const stdout = await new Response(proc.stdout).text();
|
|
42
|
+
expect(exitCode).toBe(0);
|
|
43
|
+
|
|
44
|
+
const result = JSON.parse(stdout);
|
|
45
|
+
expect(result.content[0].text).toBe(30);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("reads args from stdin", async () => {
|
|
49
|
+
const proc = runWithStdin('{"message": "from stdin"}', "exec", "mock", "echo");
|
|
50
|
+
const exitCode = await proc.exited;
|
|
51
|
+
const stdout = await new Response(proc.stdout).text();
|
|
52
|
+
expect(exitCode).toBe(0);
|
|
53
|
+
|
|
54
|
+
const result = JSON.parse(stdout);
|
|
55
|
+
expect(result.content[0].text).toBe("from stdin");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("calls a tool with no args (no {} required)", async () => {
|
|
59
|
+
const proc = run("exec", "mock", "noop");
|
|
60
|
+
const exitCode = await proc.exited;
|
|
61
|
+
const stdout = await new Response(proc.stdout).text();
|
|
62
|
+
expect(exitCode).toBe(0);
|
|
63
|
+
|
|
64
|
+
const result = JSON.parse(stdout);
|
|
65
|
+
expect(result.content[0].text).toBe("ok");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("errors on invalid JSON", async () => {
|
|
69
|
+
const proc = run("exec", "mock", "echo", "not json");
|
|
70
|
+
const exitCode = await proc.exited;
|
|
71
|
+
const stderr = await new Response(proc.stderr).text();
|
|
72
|
+
expect(exitCode).toBe(1);
|
|
73
|
+
expect(stderr).toContain("Invalid JSON");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("errors on unknown server", async () => {
|
|
77
|
+
const proc = run("exec", "nonexistent", "tool", "{}");
|
|
78
|
+
const exitCode = await proc.exited;
|
|
79
|
+
const stderr = await new Response(proc.stderr).text();
|
|
80
|
+
expect(exitCode).toBe(1);
|
|
81
|
+
expect(stderr).toContain("Unknown server");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("validates missing required field", async () => {
|
|
85
|
+
const proc = run("exec", "mock", "echo", "{}");
|
|
86
|
+
const exitCode = await proc.exited;
|
|
87
|
+
const stderr = await new Response(proc.stderr).text();
|
|
88
|
+
expect(exitCode).toBe(1);
|
|
89
|
+
expect(stderr).toContain("message");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("validates wrong type", async () => {
|
|
93
|
+
const proc = run("exec", "mock", "add", '{"a": "not a number", "b": 1}');
|
|
94
|
+
const exitCode = await proc.exited;
|
|
95
|
+
const stderr = await new Response(proc.stderr).text();
|
|
96
|
+
expect(exitCode).toBe(1);
|
|
97
|
+
expect(stderr).toContain("number");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("passes validation with correct args", async () => {
|
|
101
|
+
const proc = run("exec", "mock", "echo", '{"message": "valid"}');
|
|
102
|
+
const exitCode = await proc.exited;
|
|
103
|
+
expect(exitCode).toBe(0);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
const CLI = join(import.meta.dir, "../../src/cli.ts");
|
|
5
|
+
const CONFIG = join(import.meta.dir, "../fixtures/mock-config");
|
|
6
|
+
|
|
7
|
+
function run(...args: string[]) {
|
|
8
|
+
return Bun.spawn(["bun", "run", CLI, "-c", CONFIG, "--json", ...args], {
|
|
9
|
+
stdout: "pipe",
|
|
10
|
+
stderr: "pipe",
|
|
11
|
+
cwd: join(import.meta.dir, "../.."),
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("mcpcli info", () => {
|
|
16
|
+
test("info <server> lists tools for that server", async () => {
|
|
17
|
+
const proc = run("info", "mock");
|
|
18
|
+
const exitCode = await proc.exited;
|
|
19
|
+
const stdout = await new Response(proc.stdout).text();
|
|
20
|
+
expect(exitCode).toBe(0);
|
|
21
|
+
|
|
22
|
+
const result = JSON.parse(stdout);
|
|
23
|
+
expect(result.server).toBe("mock");
|
|
24
|
+
expect(result.tools.length).toBeGreaterThan(0);
|
|
25
|
+
expect(result.tools.map((t: { name: string }) => t.name)).toContain("echo");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("info <server> <tool> shows tool schema", async () => {
|
|
29
|
+
const proc = run("info", "mock", "echo");
|
|
30
|
+
const exitCode = await proc.exited;
|
|
31
|
+
const stdout = await new Response(proc.stdout).text();
|
|
32
|
+
expect(exitCode).toBe(0);
|
|
33
|
+
|
|
34
|
+
const result = JSON.parse(stdout);
|
|
35
|
+
expect(result.server).toBe("mock");
|
|
36
|
+
expect(result.tool).toBe("echo");
|
|
37
|
+
expect(result.inputSchema).toBeDefined();
|
|
38
|
+
expect(result.inputSchema.properties).toHaveProperty("message");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("info <server> <tool> errors on unknown tool", async () => {
|
|
42
|
+
const proc = run("info", "mock", "nonexistent");
|
|
43
|
+
const exitCode = await proc.exited;
|
|
44
|
+
const stderr = await new Response(proc.stderr).text();
|
|
45
|
+
expect(exitCode).toBe(1);
|
|
46
|
+
expect(stderr).toContain("not found");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
const CLI = join(import.meta.dir, "../../src/cli.ts");
|
|
5
|
+
const CONFIG = join(import.meta.dir, "../fixtures/mock-config");
|
|
6
|
+
|
|
7
|
+
function run(...args: string[]) {
|
|
8
|
+
return Bun.spawn(["bun", "run", CLI, "-c", CONFIG, ...args], {
|
|
9
|
+
stdout: "pipe",
|
|
10
|
+
stderr: "pipe",
|
|
11
|
+
cwd: join(import.meta.dir, "../.."),
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("mcpcli (list)", () => {
|
|
16
|
+
test("lists tools from mock server as JSON when piped", async () => {
|
|
17
|
+
const proc = run("--json");
|
|
18
|
+
const exitCode = await proc.exited;
|
|
19
|
+
const stdout = await new Response(proc.stdout).text();
|
|
20
|
+
expect(exitCode).toBe(0);
|
|
21
|
+
|
|
22
|
+
const tools = JSON.parse(stdout);
|
|
23
|
+
expect(Array.isArray(tools)).toBe(true);
|
|
24
|
+
const names = tools.map((t: { tool: string }) => t.tool);
|
|
25
|
+
expect(names).toContain("echo");
|
|
26
|
+
expect(names).toContain("add");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("lists tools with descriptions", async () => {
|
|
30
|
+
const proc = run("--json", "-d");
|
|
31
|
+
const exitCode = await proc.exited;
|
|
32
|
+
const stdout = await new Response(proc.stdout).text();
|
|
33
|
+
expect(exitCode).toBe(0);
|
|
34
|
+
|
|
35
|
+
const tools = JSON.parse(stdout);
|
|
36
|
+
const echo = tools.find((t: { tool: string }) => t.tool === "echo");
|
|
37
|
+
expect(echo.description).toContain("Echoes");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { mkdtemp, rm, readFile, mkdir, writeFile } from "fs/promises";
|
|
5
|
+
|
|
6
|
+
const CLI = join(import.meta.dir, "../../src/cli.ts");
|
|
7
|
+
|
|
8
|
+
async function run(args: string[], cwd?: string) {
|
|
9
|
+
const proc = Bun.spawn(["bun", "run", CLI, ...args], {
|
|
10
|
+
stdout: "pipe",
|
|
11
|
+
stderr: "pipe",
|
|
12
|
+
cwd,
|
|
13
|
+
});
|
|
14
|
+
const exitCode = await proc.exited;
|
|
15
|
+
const stdout = await new Response(proc.stdout).text();
|
|
16
|
+
const stderr = await new Response(proc.stderr).text();
|
|
17
|
+
return { exitCode, stdout, stderr };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("mcpcli skill install", () => {
|
|
21
|
+
let tmpDir: string;
|
|
22
|
+
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
tmpDir = await mkdtemp(join(tmpdir(), "mcpcli-skill-"));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(async () => {
|
|
28
|
+
await rm(tmpDir, { recursive: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("errors without --claude flag", async () => {
|
|
32
|
+
const { exitCode, stderr } = await run(["skill", "install"], tmpDir);
|
|
33
|
+
expect(exitCode).not.toBe(0);
|
|
34
|
+
expect(stderr).toContain("--claude");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("installs to project directory by default", async () => {
|
|
38
|
+
const { exitCode, stdout } = await run(["skill", "install", "--claude"], tmpDir);
|
|
39
|
+
expect(exitCode).toBe(0);
|
|
40
|
+
expect(stdout).toContain("Installed mcpcli skill (project):");
|
|
41
|
+
|
|
42
|
+
const dest = join(tmpDir, ".claude", "skills", "mcpcli.md");
|
|
43
|
+
const content = await readFile(dest, "utf-8");
|
|
44
|
+
expect(content).toContain("mcpcli");
|
|
45
|
+
expect(content).toContain("search");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("installs to global directory with --global", async () => {
|
|
49
|
+
// We can't write to the real ~/.claude, so just verify the --project path works
|
|
50
|
+
// and test --global + --project together using --project to confirm both targets
|
|
51
|
+
const { exitCode, stdout } = await run(["skill", "install", "--claude", "--project"], tmpDir);
|
|
52
|
+
expect(exitCode).toBe(0);
|
|
53
|
+
expect(stdout).toContain("project");
|
|
54
|
+
|
|
55
|
+
const dest = join(tmpDir, ".claude", "skills", "mcpcli.md");
|
|
56
|
+
const content = await readFile(dest, "utf-8");
|
|
57
|
+
expect(content).toContain("mcpcli");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("errors if file already exists without --force", async () => {
|
|
61
|
+
// First install
|
|
62
|
+
await run(["skill", "install", "--claude"], tmpDir);
|
|
63
|
+
|
|
64
|
+
// Second install should fail
|
|
65
|
+
const { exitCode, stderr } = await run(["skill", "install", "--claude"], tmpDir);
|
|
66
|
+
expect(exitCode).not.toBe(0);
|
|
67
|
+
expect(stderr).toContain("already exists");
|
|
68
|
+
expect(stderr).toContain("--force");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("overwrites with --force", async () => {
|
|
72
|
+
// First install
|
|
73
|
+
await run(["skill", "install", "--claude"], tmpDir);
|
|
74
|
+
|
|
75
|
+
// Overwrite the file with garbage to verify it gets replaced
|
|
76
|
+
const dest = join(tmpDir, ".claude", "skills", "mcpcli.md");
|
|
77
|
+
await writeFile(dest, "old content", "utf-8");
|
|
78
|
+
|
|
79
|
+
// Force install
|
|
80
|
+
const { exitCode, stdout } = await run(["skill", "install", "--claude", "--force"], tmpDir);
|
|
81
|
+
expect(exitCode).toBe(0);
|
|
82
|
+
expect(stdout).toContain("Installed mcpcli skill");
|
|
83
|
+
|
|
84
|
+
const content = await readFile(dest, "utf-8");
|
|
85
|
+
expect(content).not.toBe("old content");
|
|
86
|
+
expect(content).toContain("mcpcli");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("creates intermediate directories", async () => {
|
|
90
|
+
// The .claude/skills/ dir shouldn't exist yet in a fresh tmpDir
|
|
91
|
+
const { exitCode } = await run(["skill", "install", "--claude"], tmpDir);
|
|
92
|
+
expect(exitCode).toBe(0);
|
|
93
|
+
|
|
94
|
+
const dest = join(tmpDir, ".claude", "skills", "mcpcli.md");
|
|
95
|
+
const content = await readFile(dest, "utf-8");
|
|
96
|
+
expect(content.length).toBeGreaterThan(0);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { interpolateEnvString, interpolateEnv } from "../../src/config/env.ts";
|
|
3
|
+
|
|
4
|
+
describe("interpolateEnvString", () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
process.env.TEST_VAR = "hello";
|
|
7
|
+
process.env.TEST_VAR2 = "world";
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
delete process.env.TEST_VAR;
|
|
12
|
+
delete process.env.TEST_VAR2;
|
|
13
|
+
delete process.env.MCP_STRICT_ENV;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("replaces ${VAR} with env value", () => {
|
|
17
|
+
expect(interpolateEnvString("${TEST_VAR}")).toBe("hello");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("replaces multiple vars in one string", () => {
|
|
21
|
+
expect(interpolateEnvString("${TEST_VAR} ${TEST_VAR2}")).toBe("hello world");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("leaves strings without vars untouched", () => {
|
|
25
|
+
expect(interpolateEnvString("no vars here")).toBe("no vars here");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("throws on missing var in strict mode", () => {
|
|
29
|
+
expect(() => interpolateEnvString("${DOES_NOT_EXIST}")).toThrow("DOES_NOT_EXIST");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("warns and returns empty on missing var in non-strict mode", () => {
|
|
33
|
+
process.env.MCP_STRICT_ENV = "false";
|
|
34
|
+
expect(interpolateEnvString("prefix-${DOES_NOT_EXIST}-suffix")).toBe("prefix--suffix");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("interpolateEnv", () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
process.env.TEST_KEY = "val";
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
delete process.env.TEST_KEY;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("interpolates strings in objects recursively", () => {
|
|
48
|
+
const input = { a: "${TEST_KEY}", b: { c: "x-${TEST_KEY}-y" } };
|
|
49
|
+
expect(interpolateEnv(input)).toEqual({ a: "val", b: { c: "x-val-y" } });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("interpolates strings in arrays", () => {
|
|
53
|
+
const input = ["${TEST_KEY}", "plain"];
|
|
54
|
+
expect(interpolateEnv(input)).toEqual(["val", "plain"]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("passes through non-string values", () => {
|
|
58
|
+
const input = { a: 42, b: true, c: null };
|
|
59
|
+
expect(interpolateEnv(input)).toEqual({ a: 42, b: true, c: null });
|
|
60
|
+
});
|
|
61
|
+
});
|