@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.
Files changed (106) hide show
  1. package/.claude/settings.local.json +18 -0
  2. package/.claude/skills/mcpx.md +165 -0
  3. package/.claude/worktrees/elastic-jennings/.claude/settings.local.json +18 -0
  4. package/.claude/worktrees/elastic-jennings/.claude/skills/mcpcli.md +93 -0
  5. package/.claude/worktrees/elastic-jennings/.github/workflows/auto-release.yml +117 -0
  6. package/.claude/worktrees/elastic-jennings/.github/workflows/ci.yml +18 -0
  7. package/.claude/worktrees/elastic-jennings/.prettierignore +4 -0
  8. package/.claude/worktrees/elastic-jennings/.prettierrc +7 -0
  9. package/.claude/worktrees/elastic-jennings/CLAUDE.md +19 -0
  10. package/.claude/worktrees/elastic-jennings/LICENSE +21 -0
  11. package/.claude/worktrees/elastic-jennings/README.md +487 -0
  12. package/.claude/worktrees/elastic-jennings/bun.lock +381 -0
  13. package/.claude/worktrees/elastic-jennings/install.sh +55 -0
  14. package/.claude/worktrees/elastic-jennings/package.json +56 -0
  15. package/.claude/worktrees/elastic-jennings/src/cli.ts +39 -0
  16. package/.claude/worktrees/elastic-jennings/src/client/http.ts +100 -0
  17. package/.claude/worktrees/elastic-jennings/src/client/manager.ts +266 -0
  18. package/.claude/worktrees/elastic-jennings/src/client/oauth.ts +299 -0
  19. package/.claude/worktrees/elastic-jennings/src/client/stdio.ts +12 -0
  20. package/.claude/worktrees/elastic-jennings/src/commands/add.ts +155 -0
  21. package/.claude/worktrees/elastic-jennings/src/commands/auth.ts +114 -0
  22. package/.claude/worktrees/elastic-jennings/src/commands/exec.ts +91 -0
  23. package/.claude/worktrees/elastic-jennings/src/commands/index.ts +62 -0
  24. package/.claude/worktrees/elastic-jennings/src/commands/info.ts +38 -0
  25. package/.claude/worktrees/elastic-jennings/src/commands/list.ts +30 -0
  26. package/.claude/worktrees/elastic-jennings/src/commands/remove.ts +67 -0
  27. package/.claude/worktrees/elastic-jennings/src/commands/search.ts +45 -0
  28. package/.claude/worktrees/elastic-jennings/src/commands/skill.ts +70 -0
  29. package/.claude/worktrees/elastic-jennings/src/config/env.ts +41 -0
  30. package/.claude/worktrees/elastic-jennings/src/config/loader.ts +156 -0
  31. package/.claude/worktrees/elastic-jennings/src/config/schemas.ts +137 -0
  32. package/.claude/worktrees/elastic-jennings/src/context.ts +53 -0
  33. package/.claude/worktrees/elastic-jennings/src/output/formatter.ts +316 -0
  34. package/.claude/worktrees/elastic-jennings/src/output/logger.ts +114 -0
  35. package/.claude/worktrees/elastic-jennings/src/search/index.ts +69 -0
  36. package/.claude/worktrees/elastic-jennings/src/search/indexer.ts +92 -0
  37. package/.claude/worktrees/elastic-jennings/src/search/keyword.ts +86 -0
  38. package/.claude/worktrees/elastic-jennings/src/search/semantic.ts +75 -0
  39. package/.claude/worktrees/elastic-jennings/src/search/staleness.ts +8 -0
  40. package/.claude/worktrees/elastic-jennings/src/validation/schema.ts +77 -0
  41. package/.claude/worktrees/elastic-jennings/test/cli.test.ts +51 -0
  42. package/.claude/worktrees/elastic-jennings/test/client/manager.test.ts +249 -0
  43. package/.claude/worktrees/elastic-jennings/test/client/oauth.test.ts +328 -0
  44. package/.claude/worktrees/elastic-jennings/test/commands/add-remove.test.ts +253 -0
  45. package/.claude/worktrees/elastic-jennings/test/commands/exec.test.ts +105 -0
  46. package/.claude/worktrees/elastic-jennings/test/commands/info.test.ts +48 -0
  47. package/.claude/worktrees/elastic-jennings/test/commands/list.test.ts +39 -0
  48. package/.claude/worktrees/elastic-jennings/test/commands/skill.test.ts +98 -0
  49. package/.claude/worktrees/elastic-jennings/test/config/env.test.ts +61 -0
  50. package/.claude/worktrees/elastic-jennings/test/config/loader.test.ts +139 -0
  51. package/.claude/worktrees/elastic-jennings/test/fixtures/.keep +0 -0
  52. package/.claude/worktrees/elastic-jennings/test/fixtures/auth.json +10 -0
  53. package/.claude/worktrees/elastic-jennings/test/fixtures/mock-config/.keep +0 -0
  54. package/.claude/worktrees/elastic-jennings/test/fixtures/mock-config/servers.json +8 -0
  55. package/.claude/worktrees/elastic-jennings/test/fixtures/mock-server.ts +113 -0
  56. package/.claude/worktrees/elastic-jennings/test/fixtures/search.json +15 -0
  57. package/.claude/worktrees/elastic-jennings/test/fixtures/servers.json +18 -0
  58. package/.claude/worktrees/elastic-jennings/test/integration/stdio-server.test.ts +149 -0
  59. package/.claude/worktrees/elastic-jennings/test/output/formatter.test.ts +54 -0
  60. package/.claude/worktrees/elastic-jennings/test/output/logger.test.ts +89 -0
  61. package/.claude/worktrees/elastic-jennings/test/search/indexer.test.ts +32 -0
  62. package/.claude/worktrees/elastic-jennings/test/search/keyword.test.ts +80 -0
  63. package/.claude/worktrees/elastic-jennings/test/search/semantic.test.ts +32 -0
  64. package/.claude/worktrees/elastic-jennings/test/validation/schema.test.ts +113 -0
  65. package/.claude/worktrees/elastic-jennings/tsconfig.json +29 -0
  66. package/.cursor/rules/mcpx.mdc +165 -0
  67. package/LICENSE +21 -0
  68. package/README.md +627 -0
  69. package/package.json +58 -0
  70. package/src/cli.ts +72 -0
  71. package/src/client/browser.ts +24 -0
  72. package/src/client/debug-fetch.ts +81 -0
  73. package/src/client/elicitation.ts +368 -0
  74. package/src/client/http.ts +25 -0
  75. package/src/client/manager.ts +566 -0
  76. package/src/client/oauth.ts +314 -0
  77. package/src/client/sse.ts +17 -0
  78. package/src/client/stdio.ts +12 -0
  79. package/src/client/trace.ts +184 -0
  80. package/src/commands/add.ts +179 -0
  81. package/src/commands/auth.ts +114 -0
  82. package/src/commands/exec.ts +156 -0
  83. package/src/commands/index.ts +62 -0
  84. package/src/commands/info.ts +63 -0
  85. package/src/commands/list.ts +64 -0
  86. package/src/commands/ping.ts +69 -0
  87. package/src/commands/prompt.ts +60 -0
  88. package/src/commands/remove.ts +67 -0
  89. package/src/commands/resource.ts +46 -0
  90. package/src/commands/search.ts +49 -0
  91. package/src/commands/servers.ts +66 -0
  92. package/src/commands/skill.ts +112 -0
  93. package/src/commands/task.ts +82 -0
  94. package/src/config/env.ts +41 -0
  95. package/src/config/loader.ts +156 -0
  96. package/src/config/schemas.ts +152 -0
  97. package/src/context.ts +62 -0
  98. package/src/lib/input.ts +36 -0
  99. package/src/output/formatter.ts +884 -0
  100. package/src/output/logger.ts +173 -0
  101. package/src/search/index.ts +69 -0
  102. package/src/search/indexer.ts +92 -0
  103. package/src/search/keyword.ts +86 -0
  104. package/src/search/semantic.ts +75 -0
  105. package/src/search/staleness.ts +8 -0
  106. package/src/validation/schema.ts +103 -0
@@ -0,0 +1,139 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { join } from "path";
3
+ import { loadConfig, saveAuth, saveSearchIndex } from "../../src/config/loader.ts";
4
+ import { tmpdir } from "os";
5
+ import { mkdtemp, rm } from "fs/promises";
6
+
7
+ const FIXTURES_DIR = join(import.meta.dir, "../fixtures");
8
+
9
+ describe("loadConfig", () => {
10
+ test("loads all three config files from a directory", async () => {
11
+ process.env.TEST_API_KEY = "test-key";
12
+ process.env.TEST_TOKEN = "test-token";
13
+
14
+ const config = await loadConfig({ configFlag: FIXTURES_DIR });
15
+
16
+ expect(config.configDir).toBe(FIXTURES_DIR);
17
+ expect(Object.keys(config.servers.mcpServers)).toEqual(["filesystem", "github", "internal"]);
18
+ expect(config.auth.github).toBeDefined();
19
+ expect(config.auth.github!.tokens.access_token).toBe("gho_test123");
20
+ expect(config.searchIndex.tools).toHaveLength(1);
21
+ expect(config.searchIndex.tools[0]!.tool).toBe("search_repositories");
22
+
23
+ delete process.env.TEST_API_KEY;
24
+ delete process.env.TEST_TOKEN;
25
+ });
26
+
27
+ test("interpolates env vars in server configs", async () => {
28
+ process.env.TEST_API_KEY = "my-api-key";
29
+ process.env.TEST_TOKEN = "my-token";
30
+
31
+ const config = await loadConfig({ configFlag: FIXTURES_DIR });
32
+ const fs = config.servers.mcpServers.filesystem!;
33
+ expect("env" in fs && fs.env?.API_KEY).toBe("my-api-key");
34
+
35
+ const internal = config.servers.mcpServers.internal!;
36
+ expect("headers" in internal && internal.headers?.Authorization).toBe("Bearer my-token");
37
+
38
+ delete process.env.TEST_API_KEY;
39
+ delete process.env.TEST_TOKEN;
40
+ });
41
+
42
+ test("returns empty defaults when no config files exist", async () => {
43
+ const tmpDir = await mkdtemp(join(tmpdir(), "mcpcli-test-"));
44
+ try {
45
+ const config = await loadConfig({ configFlag: tmpDir });
46
+ expect(Object.keys(config.servers.mcpServers)).toHaveLength(0);
47
+ expect(Object.keys(config.auth)).toHaveLength(0);
48
+ expect(config.searchIndex.tools).toHaveLength(0);
49
+ } finally {
50
+ await rm(tmpDir, { recursive: true });
51
+ }
52
+ });
53
+
54
+ test("uses MCP_CONFIG_PATH env var", async () => {
55
+ process.env.MCP_CONFIG_PATH = FIXTURES_DIR;
56
+ process.env.TEST_API_KEY = "x";
57
+ process.env.TEST_TOKEN = "x";
58
+
59
+ const config = await loadConfig();
60
+ expect(Object.keys(config.servers.mcpServers).length).toBeGreaterThan(0);
61
+
62
+ delete process.env.MCP_CONFIG_PATH;
63
+ delete process.env.TEST_API_KEY;
64
+ delete process.env.TEST_TOKEN;
65
+ });
66
+ });
67
+
68
+ describe("validateServersFile", () => {
69
+ test("rejects missing mcpServers key", async () => {
70
+ const tmpDir = await mkdtemp(join(tmpdir(), "mcpcli-test-"));
71
+ try {
72
+ await Bun.write(join(tmpDir, "servers.json"), JSON.stringify({ wrong: "shape" }));
73
+ await expect(loadConfig({ configFlag: tmpDir })).rejects.toThrow("mcpServers");
74
+ } finally {
75
+ await rm(tmpDir, { recursive: true });
76
+ }
77
+ });
78
+
79
+ test("rejects server without command or url", async () => {
80
+ const tmpDir = await mkdtemp(join(tmpdir(), "mcpcli-test-"));
81
+ try {
82
+ await Bun.write(
83
+ join(tmpDir, "servers.json"),
84
+ JSON.stringify({ mcpServers: { bad: { name: "nope" } } }),
85
+ );
86
+ await expect(loadConfig({ configFlag: tmpDir })).rejects.toThrow(
87
+ 'must have either "command"',
88
+ );
89
+ } finally {
90
+ await rm(tmpDir, { recursive: true });
91
+ }
92
+ });
93
+ });
94
+
95
+ describe("saveAuth / saveSearchIndex", () => {
96
+ let tmpDir: string;
97
+
98
+ beforeEach(async () => {
99
+ tmpDir = await mkdtemp(join(tmpdir(), "mcpcli-test-"));
100
+ });
101
+
102
+ afterEach(async () => {
103
+ await rm(tmpDir, { recursive: true });
104
+ });
105
+
106
+ test("saves and reloads auth.json", async () => {
107
+ await saveAuth(tmpDir, {
108
+ test: {
109
+ tokens: { access_token: "abc", token_type: "bearer" },
110
+ expires_at: "2099-01-01T00:00:00Z",
111
+ },
112
+ });
113
+
114
+ const raw = await Bun.file(join(tmpDir, "auth.json")).json();
115
+ expect(raw.test.tokens.access_token).toBe("abc");
116
+ });
117
+
118
+ test("saves and reloads search.json", async () => {
119
+ await saveSearchIndex(tmpDir, {
120
+ version: 1,
121
+ indexed_at: "2026-01-01T00:00:00Z",
122
+ embedding_model: "claude",
123
+ tools: [
124
+ {
125
+ server: "s",
126
+ tool: "t",
127
+ description: "d",
128
+ scenarios: [],
129
+ keywords: [],
130
+ embedding: [],
131
+ },
132
+ ],
133
+ });
134
+
135
+ const raw = await Bun.file(join(tmpDir, "search.json")).json();
136
+ expect(raw.tools).toHaveLength(1);
137
+ expect(raw.tools[0].server).toBe("s");
138
+ });
139
+ });
@@ -0,0 +1,10 @@
1
+ {
2
+ "github": {
3
+ "tokens": {
4
+ "access_token": "gho_test123",
5
+ "token_type": "bearer",
6
+ "refresh_token": "ghr_refresh456"
7
+ },
8
+ "expires_at": "2099-01-01T00:00:00Z"
9
+ }
10
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "mock": {
4
+ "command": "bun",
5
+ "args": ["run", "test/fixtures/mock-server.ts"]
6
+ }
7
+ }
8
+ }
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * Minimal MCP server over stdio for testing.
5
+ * Implements just enough of the protocol to support initialize, listTools, and callTool.
6
+ */
7
+
8
+ import { readFileSync } from "fs";
9
+
10
+ let buffer = "";
11
+
12
+ process.stdin.setEncoding("utf-8");
13
+ process.stdin.on("data", (chunk: string) => {
14
+ buffer += chunk;
15
+ processBuffer();
16
+ });
17
+
18
+ function processBuffer() {
19
+ while (true) {
20
+ const newlineIndex = buffer.indexOf("\n");
21
+ if (newlineIndex === -1) break;
22
+ const line = buffer.slice(0, newlineIndex).trim();
23
+ buffer = buffer.slice(newlineIndex + 1);
24
+ if (line) handleMessage(line);
25
+ }
26
+ }
27
+
28
+ function handleMessage(line: string) {
29
+ let msg: { jsonrpc: string; id?: number; method: string; params?: unknown };
30
+ try {
31
+ msg = JSON.parse(line);
32
+ } catch {
33
+ return;
34
+ }
35
+
36
+ if (msg.method === "initialize") {
37
+ respond(msg.id, {
38
+ protocolVersion: "2025-03-26",
39
+ capabilities: { tools: {} },
40
+ serverInfo: { name: "mock-server", version: "1.0.0" },
41
+ });
42
+ } else if (msg.method === "notifications/initialized") {
43
+ // No response needed for notifications
44
+ } else if (msg.method === "tools/list") {
45
+ respond(msg.id, {
46
+ tools: [
47
+ {
48
+ name: "echo",
49
+ description: "Echoes back the input",
50
+ inputSchema: {
51
+ type: "object",
52
+ properties: {
53
+ message: { type: "string", description: "Message to echo" },
54
+ },
55
+ required: ["message"],
56
+ },
57
+ },
58
+ {
59
+ name: "add",
60
+ description: "Adds two numbers",
61
+ inputSchema: {
62
+ type: "object",
63
+ properties: {
64
+ a: { type: "number" },
65
+ b: { type: "number" },
66
+ },
67
+ required: ["a", "b"],
68
+ },
69
+ },
70
+ {
71
+ name: "secret",
72
+ description: "A secret tool",
73
+ inputSchema: { type: "object" },
74
+ },
75
+ {
76
+ name: "noop",
77
+ description: "A tool that takes no arguments",
78
+ inputSchema: { type: "object", properties: {} },
79
+ },
80
+ ],
81
+ });
82
+ } else if (msg.method === "tools/call") {
83
+ const params = msg.params as { name: string; arguments?: Record<string, unknown> };
84
+ if (params.name === "echo") {
85
+ respond(msg.id, {
86
+ content: [{ type: "text", text: String(params.arguments?.message ?? "") }],
87
+ });
88
+ } else if (params.name === "add") {
89
+ const a = Number(params.arguments?.a ?? 0);
90
+ const b = Number(params.arguments?.b ?? 0);
91
+ respond(msg.id, {
92
+ content: [{ type: "text", text: String(a + b) }],
93
+ });
94
+ } else if (params.name === "noop") {
95
+ respond(msg.id, {
96
+ content: [{ type: "text", text: "ok" }],
97
+ });
98
+ } else {
99
+ respond(msg.id, {
100
+ content: [{ type: "text", text: `unknown tool: ${params.name}` }],
101
+ isError: true,
102
+ });
103
+ }
104
+ } else if (msg.method === "ping") {
105
+ respond(msg.id, {});
106
+ }
107
+ }
108
+
109
+ function respond(id: number | undefined, result: unknown) {
110
+ if (id === undefined) return;
111
+ const response = JSON.stringify({ jsonrpc: "2.0", id, result });
112
+ process.stdout.write(response + "\n");
113
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "version": 1,
3
+ "indexed_at": "2026-03-03T10:00:00Z",
4
+ "embedding_model": "claude",
5
+ "tools": [
6
+ {
7
+ "server": "github",
8
+ "tool": "search_repositories",
9
+ "description": "Search for repositories on GitHub",
10
+ "scenarios": ["Find open source projects"],
11
+ "keywords": ["search", "repo", "github"],
12
+ "embedding": [0.1, 0.2, 0.3]
13
+ }
14
+ ]
15
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "mcpServers": {
3
+ "filesystem": {
4
+ "command": "npx",
5
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "."],
6
+ "env": { "API_KEY": "${TEST_API_KEY}" }
7
+ },
8
+ "github": {
9
+ "url": "https://mcp.github.com"
10
+ },
11
+ "internal": {
12
+ "url": "https://mcp.internal.example.com",
13
+ "headers": { "Authorization": "Bearer ${TEST_TOKEN}" },
14
+ "allowedTools": ["read_*"],
15
+ "disabledTools": ["read_secret"]
16
+ }
17
+ }
18
+ }
@@ -0,0 +1,149 @@
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
+ /**
8
+ * Integration tests proving that mcpcli can connect to a local stdio MCP server,
9
+ * discover its tools, inspect schemas, and execute tools end-to-end.
10
+ */
11
+
12
+ function run(...args: string[]) {
13
+ return Bun.spawn(["bun", "run", CLI, "-c", CONFIG, "--json", ...args], {
14
+ stdout: "pipe",
15
+ stderr: "pipe",
16
+ cwd: join(import.meta.dir, "../.."),
17
+ });
18
+ }
19
+
20
+ async function runAndParse<T = unknown>(...args: string[]): Promise<T> {
21
+ const proc = run(...args);
22
+ const exitCode = await proc.exited;
23
+ const stdout = await new Response(proc.stdout).text();
24
+ if (exitCode !== 0) {
25
+ const stderr = await new Response(proc.stderr).text();
26
+ throw new Error(`mcpcli exited with ${exitCode}: ${stderr}\n${stdout}`);
27
+ }
28
+ return JSON.parse(stdout) as T;
29
+ }
30
+
31
+ describe("stdio MCP server integration", () => {
32
+ test("lists all tools from the mock stdio server", async () => {
33
+ const tools = await runAndParse<{ server: string; tool: string }[]>();
34
+ expect(tools).toBeInstanceOf(Array);
35
+ expect(tools.length).toBe(4);
36
+
37
+ const names = tools.map((t) => t.tool);
38
+ expect(names).toContain("echo");
39
+ expect(names).toContain("add");
40
+ expect(names).toContain("secret");
41
+ expect(names).toContain("noop");
42
+
43
+ // Every tool should be from the "mock" server
44
+ for (const t of tools) {
45
+ expect(t.server).toBe("mock");
46
+ }
47
+ });
48
+
49
+ test("lists tools with descriptions", async () => {
50
+ const tools = await runAndParse<{ server: string; tool: string; description: string }[]>("-d");
51
+ const echo = tools.find((t) => t.tool === "echo");
52
+ expect(echo).toBeDefined();
53
+ expect(echo!.description).toContain("Echoes");
54
+ });
55
+
56
+ test("inspects a specific server to list its tools", async () => {
57
+ const result = await runAndParse<{ server: string; tools: { name: string }[] }>("info", "mock");
58
+ expect(result.server).toBe("mock");
59
+ expect(result.tools.length).toBe(4);
60
+ });
61
+
62
+ test("inspects a specific tool to show its schema", async () => {
63
+ const result = await runAndParse<{
64
+ server: string;
65
+ tool: string;
66
+ inputSchema: { properties: Record<string, unknown> };
67
+ }>("info", "mock", "echo");
68
+ expect(result.server).toBe("mock");
69
+ expect(result.tool).toBe("echo");
70
+ expect(result.inputSchema.properties).toHaveProperty("message");
71
+ });
72
+
73
+ test("calls echo tool and gets response", async () => {
74
+ const result = await runAndParse<{ content: { type: string; text: string }[] }>(
75
+ "exec",
76
+ "mock",
77
+ "echo",
78
+ '{"message":"hello world"}',
79
+ );
80
+ expect(result.content).toBeInstanceOf(Array);
81
+ expect(result.content[0]!.text).toBe("hello world");
82
+ });
83
+
84
+ test("calls add tool with numeric arguments", async () => {
85
+ const result = await runAndParse<{ content: { type: string; text: string }[] }>(
86
+ "exec",
87
+ "mock",
88
+ "add",
89
+ '{"a":10,"b":32}',
90
+ );
91
+ expect(result.content[0]!.text).toBe(42);
92
+ });
93
+
94
+ test("validates tool input and rejects missing required fields", async () => {
95
+ const proc = run("exec", "mock", "echo", "{}");
96
+ const exitCode = await proc.exited;
97
+ const stderr = await new Response(proc.stderr).text();
98
+ expect(exitCode).toBe(1);
99
+ expect(stderr).toContain("message");
100
+ });
101
+
102
+ test("validates tool input and rejects wrong types", async () => {
103
+ const proc = run("exec", "mock", "add", '{"a":"not a number","b":1}');
104
+ const exitCode = await proc.exited;
105
+ const stderr = await new Response(proc.stderr).text();
106
+ expect(exitCode).toBe(1);
107
+ expect(stderr).toContain("a");
108
+ });
109
+
110
+ test("reads tool arguments from stdin", async () => {
111
+ const proc = Bun.spawn(["bun", "run", CLI, "-c", CONFIG, "--json", "exec", "mock", "echo"], {
112
+ stdout: "pipe",
113
+ stderr: "pipe",
114
+ stdin: "pipe",
115
+ cwd: join(import.meta.dir, "../.."),
116
+ });
117
+ proc.stdin.write('{"message":"from stdin"}');
118
+ proc.stdin.end();
119
+ const exitCode = await proc.exited;
120
+ const stdout = await new Response(proc.stdout).text();
121
+ expect(exitCode).toBe(0);
122
+ const result = JSON.parse(stdout) as { content: { text: string }[] };
123
+ expect(result.content[0]!.text).toBe("from stdin");
124
+ });
125
+
126
+ test("exits with error for unknown server", async () => {
127
+ const proc = run("exec", "nonexistent", "sometool", "{}");
128
+ const exitCode = await proc.exited;
129
+ expect(exitCode).toBe(1);
130
+ });
131
+
132
+ test("MCP_DEBUG=1 enables verbose output on stderr", async () => {
133
+ const proc = Bun.spawn(
134
+ ["bun", "run", CLI, "-c", CONFIG, "--json", "exec", "mock", "echo", '{"message":"test"}'],
135
+ {
136
+ stdout: "pipe",
137
+ stderr: "pipe",
138
+ env: { ...process.env, MCP_DEBUG: "1" },
139
+ cwd: join(import.meta.dir, "../.."),
140
+ },
141
+ );
142
+ const exitCode = await proc.exited;
143
+ const stdout = await new Response(proc.stdout).text();
144
+ expect(exitCode).toBe(0);
145
+ // Should still produce valid JSON on stdout
146
+ const result = JSON.parse(stdout);
147
+ expect(result.content[0]!.text).toBe("test");
148
+ });
149
+ });
@@ -0,0 +1,54 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { formatCallResult } from "../../src/output/formatter.ts";
3
+
4
+ describe("formatCallResult nested JSON parsing", () => {
5
+ test("parses JSON strings inside text content", () => {
6
+ const result = {
7
+ content: [{ type: "text", text: '{"name":"Evan","count":42}' }],
8
+ };
9
+ const parsed = JSON.parse(formatCallResult(result, {}));
10
+ expect(parsed.content[0].text).toEqual({ name: "Evan", count: 42 });
11
+ });
12
+
13
+ test("leaves plain strings as-is", () => {
14
+ const result = {
15
+ content: [{ type: "text", text: "hello world" }],
16
+ };
17
+ const parsed = JSON.parse(formatCallResult(result, {}));
18
+ expect(parsed.content[0].text).toBe("hello world");
19
+ });
20
+
21
+ test("parses nested JSON arrays", () => {
22
+ const result = {
23
+ content: [{ type: "text", text: "[1, 2, 3]" }],
24
+ };
25
+ const parsed = JSON.parse(formatCallResult(result, {}));
26
+ expect(parsed.content[0].text).toEqual([1, 2, 3]);
27
+ });
28
+
29
+ test("parses numeric strings", () => {
30
+ const result = {
31
+ content: [{ type: "text", text: "42" }],
32
+ };
33
+ const parsed = JSON.parse(formatCallResult(result, {}));
34
+ expect(parsed.content[0].text).toBe(42);
35
+ });
36
+
37
+ test("handles deeply nested JSON strings", () => {
38
+ const inner = JSON.stringify({ nested: true });
39
+ const result = {
40
+ content: [{ type: "text", text: inner }],
41
+ };
42
+ const parsed = JSON.parse(formatCallResult(result, {}));
43
+ expect(parsed.content[0].text).toEqual({ nested: true });
44
+ });
45
+
46
+ test("preserves non-string values unchanged", () => {
47
+ const result = {
48
+ content: [{ type: "text", text: "plain" }],
49
+ isError: false,
50
+ };
51
+ const parsed = JSON.parse(formatCallResult(result, {}));
52
+ expect(parsed.isError).toBe(false);
53
+ });
54
+ });
@@ -0,0 +1,89 @@
1
+ import { describe, test, expect, beforeEach, afterEach, spyOn, type Mock } from "bun:test";
2
+ import { logger } from "../../src/output/logger.ts";
3
+
4
+ describe("logger", () => {
5
+ let stderrSpy: Mock<typeof process.stderr.write>;
6
+ let origIsTTY: boolean | undefined;
7
+
8
+ beforeEach(() => {
9
+ origIsTTY = process.stderr.isTTY;
10
+ Object.defineProperty(process.stderr, "isTTY", { value: true, writable: true });
11
+ stderrSpy = spyOn(process.stderr, "write").mockReturnValue(true);
12
+ logger.configure({});
13
+ });
14
+
15
+ afterEach(() => {
16
+ stderrSpy.mockRestore();
17
+ Object.defineProperty(process.stderr, "isTTY", { value: origIsTTY, writable: true });
18
+ });
19
+
20
+ test("info() writes dim text to stderr", () => {
21
+ logger.info("hello");
22
+ expect(stderrSpy).toHaveBeenCalled();
23
+ const written = stderrSpy.mock.calls.map((c) => String(c[0])).join("");
24
+ expect(written).toContain("hello");
25
+ });
26
+
27
+ test("warn() writes to stderr", () => {
28
+ logger.warn("caution");
29
+ expect(stderrSpy).toHaveBeenCalled();
30
+ const written = stderrSpy.mock.calls.map((c) => String(c[0])).join("");
31
+ expect(written).toContain("caution");
32
+ });
33
+
34
+ test("error() writes to stderr", () => {
35
+ logger.error("failure");
36
+ expect(stderrSpy).toHaveBeenCalled();
37
+ const written = stderrSpy.mock.calls.map((c) => String(c[0])).join("");
38
+ expect(written).toContain("failure");
39
+ });
40
+
41
+ test("info() is suppressed in JSON mode", () => {
42
+ logger.configure({ json: true });
43
+ logger.info("hidden");
44
+ expect(stderrSpy).not.toHaveBeenCalled();
45
+ });
46
+
47
+ test("error() still writes in JSON mode", () => {
48
+ logger.configure({ json: true });
49
+ logger.error("visible");
50
+ expect(stderrSpy).toHaveBeenCalled();
51
+ const written = stderrSpy.mock.calls.map((c) => String(c[0])).join("");
52
+ expect(written).toContain("visible");
53
+ });
54
+
55
+ test("info() is suppressed when stderr is not a TTY", () => {
56
+ Object.defineProperty(process.stderr, "isTTY", { value: false, writable: true });
57
+ logger.configure({});
58
+ logger.info("hidden");
59
+ expect(stderrSpy).not.toHaveBeenCalled();
60
+ });
61
+
62
+ test("debug() only writes when verbose is enabled", () => {
63
+ logger.configure({});
64
+ logger.debug("no-show");
65
+ expect(stderrSpy).not.toHaveBeenCalled();
66
+
67
+ logger.configure({ verbose: true });
68
+ logger.debug("show-me");
69
+ expect(stderrSpy).toHaveBeenCalled();
70
+ const written = stderrSpy.mock.calls.map((c) => String(c[0])).join("");
71
+ expect(written).toContain("show-me");
72
+ });
73
+
74
+ test("writeRaw() writes to stderr without formatting", () => {
75
+ logger.writeRaw("raw output\n");
76
+ expect(stderrSpy).toHaveBeenCalled();
77
+ const written = stderrSpy.mock.calls.map((c) => String(c[0])).join("");
78
+ expect(written).toBe("raw output\n");
79
+ });
80
+
81
+ test("startSpinner() returns no-op in JSON mode", () => {
82
+ logger.configure({ json: true });
83
+ const spinner = logger.startSpinner("test");
84
+ // Should not throw
85
+ spinner.update("x");
86
+ spinner.success("y");
87
+ expect(stderrSpy).not.toHaveBeenCalled();
88
+ });
89
+ });
@@ -0,0 +1,32 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { extractKeywords, generateScenarios } from "../../src/search/indexer.ts";
3
+
4
+ describe("extractKeywords", () => {
5
+ test("splits on underscores", () => {
6
+ expect(extractKeywords("Gmail_SendEmail")).toEqual(["gmail", "send", "email"]);
7
+ });
8
+
9
+ test("splits camelCase", () => {
10
+ expect(extractKeywords("sendMessage")).toEqual(["send", "message"]);
11
+ });
12
+
13
+ test("splits on hyphens", () => {
14
+ expect(extractKeywords("create-pull-request")).toEqual(["create", "pull", "request"]);
15
+ });
16
+
17
+ test("filters single-char words", () => {
18
+ expect(extractKeywords("a_Send_b")).toEqual(["send"]);
19
+ });
20
+ });
21
+
22
+ describe("generateScenarios", () => {
23
+ test("includes description when short", () => {
24
+ const scenarios = generateScenarios("SendEmail", "Send an email via Gmail");
25
+ expect(scenarios).toContain("Send an email via Gmail");
26
+ });
27
+
28
+ test("includes keyword phrase from name", () => {
29
+ const scenarios = generateScenarios("Gmail_SendEmail", "Send an email");
30
+ expect(scenarios.some((s) => s.includes("gmail"))).toBe(true);
31
+ });
32
+ });