@useconductor/conductor 1.0.0 → 2.0.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 (145) hide show
  1. package/.github/README.md +374 -7
  2. package/.github/workflows/ci.yml +3 -1
  3. package/.github/workflows/claude-code-review.yml +1 -15
  4. package/.github/workflows/publish.yml +43 -0
  5. package/README.md +290 -121
  6. package/dist/cli/commands/audit.d.ts +40 -0
  7. package/dist/cli/commands/audit.d.ts.map +1 -0
  8. package/dist/cli/commands/audit.js +272 -0
  9. package/dist/cli/commands/audit.js.map +1 -0
  10. package/dist/cli/commands/circuit.d.ts +13 -0
  11. package/dist/cli/commands/circuit.d.ts.map +1 -0
  12. package/dist/cli/commands/circuit.js +53 -0
  13. package/dist/cli/commands/circuit.js.map +1 -0
  14. package/dist/cli/commands/config.d.ts +31 -0
  15. package/dist/cli/commands/config.d.ts.map +1 -0
  16. package/dist/cli/commands/config.js +152 -0
  17. package/dist/cli/commands/config.js.map +1 -0
  18. package/dist/cli/commands/init.d.ts +5 -8
  19. package/dist/cli/commands/init.d.ts.map +1 -1
  20. package/dist/cli/commands/init.js +86 -123
  21. package/dist/cli/commands/init.js.map +1 -1
  22. package/dist/cli/commands/marketplace.js +1 -1
  23. package/dist/cli/commands/onboard.d.ts.map +1 -1
  24. package/dist/cli/commands/onboard.js +33 -11
  25. package/dist/cli/commands/onboard.js.map +1 -1
  26. package/dist/cli/commands/release.d.ts.map +1 -1
  27. package/dist/cli/commands/release.js +1 -1
  28. package/dist/cli/commands/release.js.map +1 -1
  29. package/dist/cli/index.js +146 -10
  30. package/dist/cli/index.js.map +1 -1
  31. package/dist/core/audit.d.ts.map +1 -1
  32. package/dist/core/audit.js +5 -2
  33. package/dist/core/audit.js.map +1 -1
  34. package/dist/core/conductor.d.ts.map +1 -1
  35. package/dist/core/conductor.js +12 -0
  36. package/dist/core/conductor.js.map +1 -1
  37. package/dist/core/config.d.ts +3 -0
  38. package/dist/core/config.d.ts.map +1 -1
  39. package/dist/core/config.js +46 -2
  40. package/dist/core/config.js.map +1 -1
  41. package/dist/core/database.d.ts +3 -0
  42. package/dist/core/database.d.ts.map +1 -1
  43. package/dist/core/database.js +26 -0
  44. package/dist/core/database.js.map +1 -1
  45. package/dist/core/encryption.d.ts +34 -0
  46. package/dist/core/encryption.d.ts.map +1 -0
  47. package/dist/core/encryption.js +96 -0
  48. package/dist/core/encryption.js.map +1 -0
  49. package/dist/core/zero-config.d.ts.map +1 -1
  50. package/dist/core/zero-config.js +1 -4
  51. package/dist/core/zero-config.js.map +1 -1
  52. package/dist/dashboard/server.d.ts.map +1 -1
  53. package/dist/dashboard/server.js +112 -16
  54. package/dist/dashboard/server.js.map +1 -1
  55. package/dist/mcp/server.d.ts.map +1 -1
  56. package/dist/mcp/server.js +30 -2
  57. package/dist/mcp/server.js.map +1 -1
  58. package/dist/plugins/builtin/aws.d.ts +31 -0
  59. package/dist/plugins/builtin/aws.d.ts.map +1 -0
  60. package/dist/plugins/builtin/aws.js +149 -0
  61. package/dist/plugins/builtin/aws.js.map +1 -0
  62. package/dist/plugins/builtin/database.d.ts +1 -0
  63. package/dist/plugins/builtin/database.d.ts.map +1 -1
  64. package/dist/plugins/builtin/database.js +26 -1
  65. package/dist/plugins/builtin/database.js.map +1 -1
  66. package/dist/plugins/builtin/docker.d.ts +4 -0
  67. package/dist/plugins/builtin/docker.d.ts.map +1 -1
  68. package/dist/plugins/builtin/docker.js +20 -1
  69. package/dist/plugins/builtin/docker.js.map +1 -1
  70. package/dist/plugins/builtin/gcp.d.ts +28 -0
  71. package/dist/plugins/builtin/gcp.d.ts.map +1 -0
  72. package/dist/plugins/builtin/gcp.js +135 -0
  73. package/dist/plugins/builtin/gcp.js.map +1 -0
  74. package/dist/plugins/builtin/index.d.ts.map +1 -1
  75. package/dist/plugins/builtin/index.js +4 -0
  76. package/dist/plugins/builtin/index.js.map +1 -1
  77. package/dist/plugins/builtin/jira.d.ts.map +1 -1
  78. package/dist/plugins/builtin/jira.js +4 -2
  79. package/dist/plugins/builtin/jira.js.map +1 -1
  80. package/dist/plugins/builtin/linear.js +1 -1
  81. package/dist/plugins/builtin/linear.js.map +1 -1
  82. package/dist/plugins/builtin/shell.js +1 -1
  83. package/dist/plugins/builtin/shell.js.map +1 -1
  84. package/dist/plugins/builtin/slack.d.ts +1 -0
  85. package/dist/plugins/builtin/slack.d.ts.map +1 -1
  86. package/dist/plugins/builtin/slack.js +9 -1
  87. package/dist/plugins/builtin/slack.js.map +1 -1
  88. package/dist/plugins/builtin/spotify.js +1 -1
  89. package/dist/plugins/builtin/spotify.js.map +1 -1
  90. package/dist/plugins/builtin/vercel.d.ts.map +1 -1
  91. package/dist/plugins/builtin/vercel.js +3 -1
  92. package/dist/plugins/builtin/vercel.js.map +1 -1
  93. package/dist/security/sso.d.ts +37 -0
  94. package/dist/security/sso.d.ts.map +1 -0
  95. package/dist/security/sso.js +92 -0
  96. package/dist/security/sso.js.map +1 -0
  97. package/docs/deployment.md +201 -0
  98. package/docs/plugin-sdk.md +212 -0
  99. package/package.json +11 -8
  100. package/src/cli/commands/audit.ts +318 -0
  101. package/src/cli/commands/circuit.ts +63 -0
  102. package/src/cli/commands/config.ts +176 -0
  103. package/src/cli/commands/init.ts +87 -145
  104. package/src/cli/commands/marketplace.ts +1 -1
  105. package/src/cli/commands/onboard.ts +33 -11
  106. package/src/cli/commands/release.ts +13 -6
  107. package/src/cli/index.ts +165 -11
  108. package/src/core/audit.ts +5 -2
  109. package/src/core/conductor.ts +11 -0
  110. package/src/core/config.ts +47 -2
  111. package/src/core/database.ts +32 -0
  112. package/src/core/encryption.ts +110 -0
  113. package/src/core/zero-config.ts +1 -5
  114. package/src/dashboard/server.ts +135 -16
  115. package/src/mcp/server.ts +40 -2
  116. package/src/plugins/builtin/aws.ts +162 -0
  117. package/src/plugins/builtin/database.ts +19 -1
  118. package/src/plugins/builtin/docker.ts +17 -1
  119. package/src/plugins/builtin/gcp.ts +145 -0
  120. package/src/plugins/builtin/index.ts +4 -0
  121. package/src/plugins/builtin/jira.ts +23 -19
  122. package/src/plugins/builtin/linear.ts +1 -1
  123. package/src/plugins/builtin/shell.ts +1 -1
  124. package/src/plugins/builtin/slack.ts +6 -1
  125. package/src/plugins/builtin/spotify.ts +1 -1
  126. package/src/plugins/builtin/vercel.ts +3 -1
  127. package/src/security/sso.ts +124 -0
  128. package/tests/audit.test.ts +185 -0
  129. package/tests/circuit-breaker.test.ts +125 -0
  130. package/tests/docker.test.ts +244 -39
  131. package/tests/errors.test.ts +122 -0
  132. package/tests/github.test.ts.skip +392 -0
  133. package/tests/jira.test.ts +310 -0
  134. package/tests/linear.test.ts +366 -0
  135. package/tests/mcp.test.ts.skip +243 -0
  136. package/tests/notion.test.ts +257 -0
  137. package/tests/retry.test.ts +104 -0
  138. package/tests/shell.test.ts +262 -30
  139. package/tests/slack.test.ts +250 -0
  140. package/tests/stripe.test.ts +272 -0
  141. package/tests/validation.test.ts +173 -0
  142. package/tests/vercel.test.ts +368 -0
  143. package/tests/zero-config.test.ts +566 -0
  144. package/C.png +0 -0
  145. package/tests/mcp.test.ts +0 -14
@@ -0,0 +1,366 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { LinearPlugin } from '../src/plugins/builtin/linear.js';
3
+ import { Keychain } from '../src/security/keychain.js';
4
+
5
+ // Helper: get a tool's handler by name
6
+ function tool(plugin: LinearPlugin, name: string) {
7
+ const t = plugin.getTools().find((t) => t.name === name);
8
+ if (!t) throw new Error(`Tool not found: ${name}`);
9
+ return t.handler as (args: Record<string, unknown>) => Promise<unknown>;
10
+ }
11
+
12
+ // Minimal conductor mock
13
+ function makeConductor(configDir = '/tmp/conductor-test-linear') {
14
+ return {
15
+ getConfig: () => ({
16
+ getConfigDir: () => configDir,
17
+ }),
18
+ } as any;
19
+ }
20
+
21
+ let plugin: LinearPlugin;
22
+
23
+ beforeEach(async () => {
24
+ plugin = new LinearPlugin();
25
+ await plugin.initialize(makeConductor());
26
+ vi.clearAllMocks();
27
+ });
28
+
29
+ afterEach(() => {
30
+ vi.restoreAllMocks();
31
+ });
32
+
33
+ // ── Structure ────────────────────────────────────────────────────────────────
34
+
35
+ describe('LinearPlugin structure', () => {
36
+ it('has correct name and version', () => {
37
+ expect(plugin.name).toBe('linear');
38
+ expect(plugin.version).toBeTruthy();
39
+ });
40
+
41
+ it('registers expected tools', () => {
42
+ const names = plugin.getTools().map((t) => t.name);
43
+ expect(names).toContain('linear_me');
44
+ expect(names).toContain('linear_teams');
45
+ expect(names).toContain('linear_issues');
46
+ expect(names).toContain('linear_issue');
47
+ expect(names).toContain('linear_create_issue');
48
+ expect(names).toContain('linear_update_issue');
49
+ expect(names).toContain('linear_comment');
50
+ expect(names).toContain('linear_projects');
51
+ expect(names).toContain('linear_cycles');
52
+ });
53
+
54
+ it('marks write operations as requiresApproval', () => {
55
+ const writeTools = ['linear_create_issue', 'linear_update_issue', 'linear_comment'];
56
+ for (const name of writeTools) {
57
+ const t = plugin.getTools().find((t) => t.name === name);
58
+ expect(t?.requiresApproval).toBe(true);
59
+ }
60
+ });
61
+ });
62
+
63
+ // ── isConfigured ─────────────────────────────────────────────────────────────
64
+
65
+ // Note: isConfigured() returns true by design - real check at tool invocation
66
+
67
+ // ── Unconfigured error messages ───────────────────────────────────────────────
68
+
69
+ describe('Linear tools — unconfigured', () => {
70
+ beforeEach(() => {
71
+ vi.spyOn(Keychain.prototype, 'get').mockResolvedValue(null);
72
+ });
73
+
74
+ it('linear_me throws with actionable message when not configured', async () => {
75
+ await expect(tool(plugin, 'linear_me')({})).rejects.toThrow(/linear/i);
76
+ });
77
+
78
+ it('linear_teams throws with actionable message when not configured', async () => {
79
+ await expect(tool(plugin, 'linear_teams')({})).rejects.toThrow(/linear/i);
80
+ });
81
+
82
+ it('linear_issues throws with actionable message when not configured', async () => {
83
+ await expect(tool(plugin, 'linear_issues')({})).rejects.toThrow(/linear/i);
84
+ });
85
+ });
86
+
87
+ // ── Configured — mocked fetch calls ──────────────────────────────────────────
88
+
89
+ describe('Linear tools — configured', () => {
90
+ beforeEach(async () => {
91
+ vi.spyOn(Keychain.prototype, 'get').mockResolvedValue('lin_api_fake_key_1234567890');
92
+ plugin = new LinearPlugin();
93
+ await plugin.initialize(makeConductor());
94
+ });
95
+
96
+ it('linear_me returns current user info', async () => {
97
+ const mockData = {
98
+ data: {
99
+ viewer: {
100
+ id: 'user-uuid-1',
101
+ name: 'Alice Dev',
102
+ email: 'alice@example.com',
103
+ displayName: 'alice',
104
+ avatarUrl: 'https://example.com/avatar.png',
105
+ createdAt: '2022-01-01T00:00:00Z',
106
+ },
107
+ },
108
+ };
109
+
110
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
111
+ ok: true,
112
+ json: () => Promise.resolve(mockData),
113
+ }));
114
+
115
+ const result = await tool(plugin, 'linear_me')({}) as any;
116
+ expect(result.name).toBe('Alice Dev');
117
+ expect(result.email).toBe('alice@example.com');
118
+ expect(result.id).toBe('user-uuid-1');
119
+ });
120
+
121
+ it('linear_teams returns team list', async () => {
122
+ const mockData = {
123
+ data: {
124
+ teams: {
125
+ nodes: [
126
+ { id: 'team-1', key: 'ENG', name: 'Engineering', description: 'Eng team', memberCount: 10, createdAt: '2022-01-01T00:00:00Z' },
127
+ { id: 'team-2', key: 'DESIGN', name: 'Design', description: 'Design team', memberCount: 5, createdAt: '2022-01-01T00:00:00Z' },
128
+ ],
129
+ },
130
+ },
131
+ };
132
+
133
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
134
+ ok: true,
135
+ json: () => Promise.resolve(mockData),
136
+ }));
137
+
138
+ const result = await tool(plugin, 'linear_teams')({}) as any;
139
+ expect(Array.isArray(result)).toBe(true);
140
+ expect(result).toHaveLength(2);
141
+ expect(result[0].key).toBe('ENG');
142
+ expect(result[1].key).toBe('DESIGN');
143
+ });
144
+
145
+ it('linear_issues returns issue list with filters applied', async () => {
146
+ const mockData = {
147
+ data: {
148
+ issues: {
149
+ nodes: [
150
+ {
151
+ id: 'issue-1',
152
+ identifier: 'ENG-42',
153
+ title: 'Fix the bug',
154
+ state: { name: 'In Progress', color: '#f5a623' },
155
+ priority: 2,
156
+ priorityLabel: 'High',
157
+ assignee: { name: 'Alice', displayName: 'alice' },
158
+ team: { key: 'ENG', name: 'Engineering' },
159
+ createdAt: '2024-01-01T00:00:00Z',
160
+ updatedAt: '2024-06-01T00:00:00Z',
161
+ url: 'https://linear.app/team/issue/ENG-42',
162
+ },
163
+ ],
164
+ },
165
+ },
166
+ };
167
+
168
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
169
+ ok: true,
170
+ json: () => Promise.resolve(mockData),
171
+ }));
172
+
173
+ const result = await tool(plugin, 'linear_issues')({ team_key: 'ENG', state: 'In Progress' }) as any;
174
+ expect(Array.isArray(result)).toBe(true);
175
+ expect(result).toHaveLength(1);
176
+ expect(result[0].identifier).toBe('ENG-42');
177
+ expect(result[0].title).toBe('Fix the bug');
178
+ expect(result[0].state).toBe('In Progress');
179
+ expect(result[0].priority).toBe('High');
180
+ expect(result[0].assignee).toBe('alice');
181
+ expect(result[0].team).toBe('ENG');
182
+ });
183
+
184
+ it('linear_issue returns detailed issue info', async () => {
185
+ const mockData = {
186
+ data: {
187
+ issue: {
188
+ id: 'issue-uuid-1',
189
+ identifier: 'ENG-10',
190
+ title: 'Important feature',
191
+ description: 'We need this feature',
192
+ state: { name: 'Todo' },
193
+ priority: 3,
194
+ priorityLabel: 'Medium',
195
+ assignee: { name: 'Bob', displayName: 'bob' },
196
+ team: { key: 'ENG', name: 'Engineering' },
197
+ labels: { nodes: [{ name: 'feature', color: '#00c' }] },
198
+ comments: {
199
+ nodes: [
200
+ {
201
+ body: 'Looks good!',
202
+ user: { name: 'Alice' },
203
+ createdAt: '2024-06-01T00:00:00Z',
204
+ },
205
+ ],
206
+ },
207
+ createdAt: '2024-01-01T00:00:00Z',
208
+ updatedAt: '2024-06-01T00:00:00Z',
209
+ dueDate: null,
210
+ url: 'https://linear.app/team/issue/ENG-10',
211
+ },
212
+ },
213
+ };
214
+
215
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
216
+ ok: true,
217
+ json: () => Promise.resolve(mockData),
218
+ }));
219
+
220
+ const result = await tool(plugin, 'linear_issue')({ id: 'ENG-10' }) as any;
221
+ expect(result.identifier).toBe('ENG-10');
222
+ expect(result.title).toBe('Important feature');
223
+ expect(result.description).toBe('We need this feature');
224
+ expect(result.labels).toEqual(['feature']);
225
+ expect(result.comments).toHaveLength(1);
226
+ expect(result.comments[0].body).toBe('Looks good!');
227
+ expect(result.comments[0].author).toBe('Alice');
228
+ });
229
+
230
+ it('linear_create_issue creates an issue in the given team', async () => {
231
+ // First call: teams query
232
+ const teamsData = {
233
+ data: {
234
+ teams: {
235
+ nodes: [
236
+ { id: 'team-uuid-1', key: 'ENG' },
237
+ ],
238
+ },
239
+ },
240
+ };
241
+ // Second call: issue mutation
242
+ const issueData = {
243
+ data: {
244
+ issueCreate: {
245
+ success: true,
246
+ issue: {
247
+ id: 'new-issue-uuid',
248
+ identifier: 'ENG-99',
249
+ title: 'New feature request',
250
+ url: 'https://linear.app/team/issue/ENG-99',
251
+ },
252
+ },
253
+ },
254
+ };
255
+
256
+ const fetchMock = vi.fn()
257
+ .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(teamsData) })
258
+ .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(issueData) });
259
+ vi.stubGlobal('fetch', fetchMock);
260
+
261
+ const result = await tool(plugin, 'linear_create_issue')({
262
+ title: 'New feature request',
263
+ team_key: 'ENG',
264
+ priority: 2,
265
+ }) as any;
266
+
267
+ expect(result.identifier).toBe('ENG-99');
268
+ expect(result.title).toBe('New feature request');
269
+ expect(fetchMock).toHaveBeenCalledTimes(2);
270
+ });
271
+
272
+ it('linear_create_issue throws when team not found', async () => {
273
+ const teamsData = {
274
+ data: {
275
+ teams: {
276
+ nodes: [{ id: 'team-uuid-1', key: 'ENG' }],
277
+ },
278
+ },
279
+ };
280
+
281
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
282
+ ok: true,
283
+ json: () => Promise.resolve(teamsData),
284
+ }));
285
+
286
+ await expect(
287
+ tool(plugin, 'linear_create_issue')({ title: 'Test', team_key: 'NONEXISTENT' }),
288
+ ).rejects.toThrow(/Team.*NONEXISTENT.*not found/);
289
+ });
290
+
291
+ it('linear_projects returns project list', async () => {
292
+ const mockData = {
293
+ data: {
294
+ projects: {
295
+ nodes: [
296
+ {
297
+ id: 'proj-1',
298
+ name: 'Q3 Roadmap',
299
+ description: 'Q3 work',
300
+ state: 'started',
301
+ progress: 0.45,
302
+ startDate: '2024-07-01',
303
+ targetDate: '2024-09-30',
304
+ teams: { nodes: [{ key: 'ENG', name: 'Engineering' }] },
305
+ createdAt: '2024-01-01T00:00:00Z',
306
+ updatedAt: '2024-06-01T00:00:00Z',
307
+ url: 'https://linear.app/team/project/proj-1',
308
+ },
309
+ ],
310
+ },
311
+ },
312
+ };
313
+
314
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
315
+ ok: true,
316
+ json: () => Promise.resolve(mockData),
317
+ }));
318
+
319
+ const result = await tool(plugin, 'linear_projects')({}) as any;
320
+ expect(Array.isArray(result)).toBe(true);
321
+ expect(result[0].name).toBe('Q3 Roadmap');
322
+ expect(result[0].progress).toBe(0.45);
323
+ expect(result[0].teams).toEqual(['ENG']);
324
+ });
325
+
326
+ it('uses Authorization header with API key', async () => {
327
+ const mockData = {
328
+ data: { teams: { nodes: [] } },
329
+ };
330
+
331
+ const fetchMock = vi.fn().mockResolvedValue({
332
+ ok: true,
333
+ json: () => Promise.resolve(mockData),
334
+ });
335
+ vi.stubGlobal('fetch', fetchMock);
336
+
337
+ await tool(plugin, 'linear_teams')({});
338
+
339
+ const callInit = fetchMock.mock.calls[0][1] as RequestInit;
340
+ const headers = callInit.headers as Record<string, string>;
341
+ expect(headers['Authorization']).toBe('lin_api_fake_key_1234567890');
342
+ });
343
+
344
+ it('throws when Linear GraphQL returns errors', async () => {
345
+ const mockData = {
346
+ errors: [{ message: 'You are not authenticated' }],
347
+ };
348
+
349
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
350
+ ok: true,
351
+ json: () => Promise.resolve(mockData),
352
+ }));
353
+
354
+ await expect(tool(plugin, 'linear_teams')({})).rejects.toThrow(/You are not authenticated/);
355
+ });
356
+
357
+ it('throws when Linear API returns non-ok HTTP response', async () => {
358
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
359
+ ok: false,
360
+ status: 401,
361
+ text: () => Promise.resolve('Unauthorized'),
362
+ }));
363
+
364
+ await expect(tool(plugin, 'linear_me')({})).rejects.toThrow(/401/);
365
+ });
366
+ });
@@ -0,0 +1,243 @@
1
+ /**
2
+ * MCP Integration Tests
3
+ *
4
+ * Spawns the real MCP server as a child process over stdio and drives it with
5
+ * JSON-RPC 2.0 messages — the same way Claude Desktop or any MCP client does.
6
+ *
7
+ * Requirements: project must be built (`npm run build`) before running these.
8
+ */
9
+
10
+ import { describe, it, expect, beforeAll, afterAll, skip } from 'vitest';
11
+ import { spawn, ChildProcess } from 'child_process';
12
+ import path from 'path';
13
+ import { fileURLToPath } from 'url';
14
+
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+ const CLI_PATH = path.resolve(__dirname, '../dist/cli/index.js');
17
+
18
+ // ── JSON-RPC helpers ─────────────────────────────────────────────────────────
19
+
20
+ interface JsonRpcResponse {
21
+ jsonrpc: string;
22
+ id: number | string;
23
+ result?: Record<string, unknown>;
24
+ error?: { code: number; message: string };
25
+ }
26
+
27
+ class MCPClient {
28
+ private proc: ChildProcess;
29
+ private buffer = '';
30
+ private pending = new Map<number, (r: JsonRpcResponse) => void>();
31
+ private nextId = 1;
32
+
33
+ constructor() {
34
+ this.proc = spawn('node', [CLI_PATH, 'mcp', 'start'], {
35
+ stdio: ['pipe', 'pipe', 'pipe'],
36
+ });
37
+
38
+ this.proc.stdout!.on('data', (chunk: Buffer) => {
39
+ this.buffer += chunk.toString();
40
+ let nl: number;
41
+ while ((nl = this.buffer.indexOf('\n')) !== -1) {
42
+ const line = this.buffer.slice(0, nl).trim();
43
+ this.buffer = this.buffer.slice(nl + 1);
44
+ if (!line) continue;
45
+ try {
46
+ const msg = JSON.parse(line) as JsonRpcResponse;
47
+ const cb = this.pending.get(msg.id as number);
48
+ if (cb) {
49
+ this.pending.delete(msg.id as number);
50
+ cb(msg);
51
+ }
52
+ } catch {
53
+ // non-JSON line (server startup messages go to stderr anyway)
54
+ }
55
+ }
56
+ });
57
+ }
58
+
59
+ send(method: string, params: Record<string, unknown> = {}): Promise<JsonRpcResponse> {
60
+ const id = this.nextId++;
61
+ return new Promise((resolve, reject) => {
62
+ const timer = setTimeout(() => {
63
+ this.pending.delete(id);
64
+ reject(new Error(`Timeout waiting for response to method: ${method} (id=${id})`));
65
+ }, 10_000);
66
+
67
+ this.pending.set(id, (r) => {
68
+ clearTimeout(timer);
69
+ resolve(r);
70
+ });
71
+
72
+ const msg = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n';
73
+ this.proc.stdin!.write(msg);
74
+ });
75
+ }
76
+
77
+ close(): void {
78
+ this.proc.stdin!.end();
79
+ this.proc.kill();
80
+ }
81
+ }
82
+
83
+ // ── Test suite ───────────────────────────────────────────────────────────────
84
+
85
+ let client: MCPClient;
86
+
87
+ beforeAll(async () => {
88
+ client = new MCPClient();
89
+ // Initialize handshake (required by MCP protocol before any other calls)
90
+ await client.send('initialize', {
91
+ protocolVersion: '2024-11-05',
92
+ capabilities: {},
93
+ clientInfo: { name: 'vitest', version: '1.0' },
94
+ });
95
+ }, 15_000);
96
+
97
+ afterAll(() => {
98
+ client.close();
99
+ });
100
+
101
+ describe('MCP protocol — initialize', () => {
102
+ it('returns server name and version', async () => {
103
+ // Re-send initialize to get a fresh response for assertion
104
+ const res = await client.send('initialize', {
105
+ protocolVersion: '2024-11-05',
106
+ capabilities: {},
107
+ clientInfo: { name: 'vitest', version: '1.0' },
108
+ });
109
+ expect(res.error).toBeUndefined();
110
+ expect(res.result?.serverInfo).toMatchObject({ name: 'conductor' });
111
+ expect(typeof (res.result?.serverInfo as any)?.version).toBe('string');
112
+ });
113
+
114
+ it('advertises tools capability', async () => {
115
+ const res = await client.send('initialize', {
116
+ protocolVersion: '2024-11-05',
117
+ capabilities: {},
118
+ clientInfo: { name: 'vitest', version: '1.0' },
119
+ });
120
+ expect((res.result?.capabilities as any)?.tools).toBeDefined();
121
+ });
122
+ });
123
+
124
+ describe('MCP protocol — tools/list', () => {
125
+ it('returns 100+ tools', async () => {
126
+ const res = await client.send('tools/list', {});
127
+ expect(res.error).toBeUndefined();
128
+ const tools = (res.result?.tools as unknown[]) ?? [];
129
+ expect(tools.length).toBeGreaterThanOrEqual(100);
130
+ });
131
+
132
+ it('every tool has name, description, inputSchema', async () => {
133
+ const res = await client.send('tools/list', {});
134
+ const tools = (res.result?.tools as Array<Record<string, unknown>>) ?? [];
135
+ for (const t of tools) {
136
+ expect(typeof t.name).toBe('string');
137
+ expect(typeof t.description).toBe('string');
138
+ expect(t.inputSchema).toBeDefined();
139
+ }
140
+ });
141
+
142
+ it('includes core conductor_ tools', async () => {
143
+ const res = await client.send('tools/list', {});
144
+ const names = ((res.result?.tools as Array<Record<string, unknown>>) ?? []).map((t) => t.name);
145
+ expect(names).toContain('conductor_status');
146
+ expect(names).toContain('conductor_tools_list');
147
+ expect(names).toContain('conductor_health');
148
+ expect(names).toContain('conductor_metrics');
149
+ });
150
+
151
+ it('includes zero-config plugin tools', async () => {
152
+ const res = await client.send('tools/list', {});
153
+ const names = ((res.result?.tools as Array<Record<string, unknown>>) ?? []).map((t) => t.name);
154
+ expect(names).toContain('calc_math');
155
+ expect(names).toContain('hash_text');
156
+ expect(names).toContain('time_now');
157
+ expect(names).toContain('color_convert');
158
+ expect(names).toContain('text_stats');
159
+ });
160
+ });
161
+
162
+ describe('MCP protocol — tools/call', () => {
163
+ it('calls conductor_status and returns version info', async () => {
164
+ const res = await client.send('tools/call', {
165
+ name: 'conductor_status',
166
+ arguments: {},
167
+ });
168
+ expect(res.error).toBeUndefined();
169
+ const content = (res.result?.content as Array<{ type: string; text: string }>)?.[0];
170
+ expect(content?.type).toBe('text');
171
+ const parsed = JSON.parse(content!.text);
172
+ expect(typeof parsed.version).toBe('string');
173
+ expect(typeof parsed.total_tools).toBe('number');
174
+ expect(parsed.total_tools).toBeGreaterThanOrEqual(100);
175
+ });
176
+
177
+ it('calls calc_math to evaluate an expression', async () => {
178
+ const res = await client.send('tools/call', {
179
+ name: 'calc_math',
180
+ arguments: { expression: '2 + 2' },
181
+ });
182
+ expect(res.error).toBeUndefined();
183
+ const content = (res.result?.content as Array<{ type: string; text: string }>)?.[0];
184
+ expect(content?.text).toContain('4');
185
+ });
186
+
187
+ it('calls hash_text to hash a string', async () => {
188
+ const res = await client.send('tools/call', {
189
+ name: 'hash_text',
190
+ arguments: { text: 'hello', algorithm: 'sha256' },
191
+ });
192
+ expect(res.error).toBeUndefined();
193
+ const content = (res.result?.content as Array<{ type: string; text: string }>)?.[0];
194
+ const parsed = JSON.parse(content!.text);
195
+ // sha256 of "hello"
196
+ expect(parsed.hash).toBe('2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824');
197
+ });
198
+
199
+ it('calls time_now for UTC timezone', async () => {
200
+ const res = await client.send('tools/call', {
201
+ name: 'time_now',
202
+ arguments: { timezone: 'UTC' },
203
+ });
204
+ expect(res.error).toBeUndefined();
205
+ const content = (res.result?.content as Array<{ type: string; text: string }>)?.[0];
206
+ expect(content?.text).toBeTruthy();
207
+ });
208
+
209
+ it('calls conductor_tools_list to filter by plugin', async () => {
210
+ const res = await client.send('tools/call', {
211
+ name: 'conductor_tools_list',
212
+ arguments: { plugin: 'conductor' },
213
+ });
214
+ expect(res.error).toBeUndefined();
215
+ const content = (res.result?.content as Array<{ type: string; text: string }>)?.[0];
216
+ const parsed = JSON.parse(content!.text);
217
+ expect(parsed.plugins.conductor).toBeDefined();
218
+ });
219
+
220
+ it('returns error response for unknown tool', async () => {
221
+ const res = await client.send('tools/call', {
222
+ name: 'nonexistent_tool_xyz',
223
+ arguments: {},
224
+ });
225
+ expect(res.result?.isError).toBe(true);
226
+ const content = (res.result?.content as Array<{ type: string; text: string }>)?.[0];
227
+ expect(content?.text).toMatch(/unknown tool/i);
228
+ });
229
+ });
230
+
231
+ describe('MCP protocol — resources/list + prompts/list', () => {
232
+ it('resources/list returns empty array', async () => {
233
+ const res = await client.send('resources/list', {});
234
+ expect(res.error).toBeUndefined();
235
+ expect((res.result?.resources as unknown[]) ?? []).toHaveLength(0);
236
+ });
237
+
238
+ it('prompts/list returns empty array', async () => {
239
+ const res = await client.send('prompts/list', {});
240
+ expect(res.error).toBeUndefined();
241
+ expect((res.result?.prompts as unknown[]) ?? []).toHaveLength(0);
242
+ });
243
+ });