mcp-rubber-duck 1.9.5 → 1.11.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 (92) hide show
  1. package/.eslintrc.json +3 -1
  2. package/CHANGELOG.md +19 -0
  3. package/README.md +62 -10
  4. package/assets/ext-apps-compare.png +0 -0
  5. package/assets/ext-apps-debate.png +0 -0
  6. package/assets/ext-apps-usage-stats.png +0 -0
  7. package/assets/ext-apps-vote.png +0 -0
  8. package/audit-ci.json +2 -1
  9. package/dist/providers/enhanced-manager.d.ts +7 -0
  10. package/dist/providers/enhanced-manager.d.ts.map +1 -1
  11. package/dist/providers/enhanced-manager.js +36 -0
  12. package/dist/providers/enhanced-manager.js.map +1 -1
  13. package/dist/providers/manager.d.ts +1 -0
  14. package/dist/providers/manager.d.ts.map +1 -1
  15. package/dist/providers/manager.js +33 -0
  16. package/dist/providers/manager.js.map +1 -1
  17. package/dist/server.d.ts +2 -0
  18. package/dist/server.d.ts.map +1 -1
  19. package/dist/server.js +154 -36
  20. package/dist/server.js.map +1 -1
  21. package/dist/services/progress.d.ts +27 -0
  22. package/dist/services/progress.d.ts.map +1 -0
  23. package/dist/services/progress.js +50 -0
  24. package/dist/services/progress.js.map +1 -0
  25. package/dist/services/task-manager.d.ts +56 -0
  26. package/dist/services/task-manager.d.ts.map +1 -0
  27. package/dist/services/task-manager.js +134 -0
  28. package/dist/services/task-manager.js.map +1 -0
  29. package/dist/tools/compare-ducks.d.ts +2 -1
  30. package/dist/tools/compare-ducks.d.ts.map +1 -1
  31. package/dist/tools/compare-ducks.js +26 -3
  32. package/dist/tools/compare-ducks.js.map +1 -1
  33. package/dist/tools/duck-council.d.ts +2 -1
  34. package/dist/tools/duck-council.d.ts.map +1 -1
  35. package/dist/tools/duck-council.js +7 -3
  36. package/dist/tools/duck-council.js.map +1 -1
  37. package/dist/tools/duck-debate.d.ts +2 -1
  38. package/dist/tools/duck-debate.d.ts.map +1 -1
  39. package/dist/tools/duck-debate.js +43 -1
  40. package/dist/tools/duck-debate.js.map +1 -1
  41. package/dist/tools/duck-iterate.d.ts +2 -1
  42. package/dist/tools/duck-iterate.d.ts.map +1 -1
  43. package/dist/tools/duck-iterate.js +13 -1
  44. package/dist/tools/duck-iterate.js.map +1 -1
  45. package/dist/tools/duck-vote.d.ts +2 -1
  46. package/dist/tools/duck-vote.d.ts.map +1 -1
  47. package/dist/tools/duck-vote.js +30 -3
  48. package/dist/tools/duck-vote.js.map +1 -1
  49. package/dist/tools/get-usage-stats.d.ts.map +1 -1
  50. package/dist/tools/get-usage-stats.js +13 -0
  51. package/dist/tools/get-usage-stats.js.map +1 -1
  52. package/dist/ui/compare-ducks/mcp-app.html +187 -0
  53. package/dist/ui/duck-debate/mcp-app.html +182 -0
  54. package/dist/ui/duck-vote/mcp-app.html +168 -0
  55. package/dist/ui/usage-stats/mcp-app.html +192 -0
  56. package/jest.config.js +1 -0
  57. package/package.json +7 -3
  58. package/src/providers/enhanced-manager.ts +49 -0
  59. package/src/providers/manager.ts +45 -0
  60. package/src/server.ts +187 -34
  61. package/src/services/progress.ts +59 -0
  62. package/src/services/task-manager.ts +162 -0
  63. package/src/tools/compare-ducks.ts +34 -3
  64. package/src/tools/duck-council.ts +15 -4
  65. package/src/tools/duck-debate.ts +58 -1
  66. package/src/tools/duck-iterate.ts +20 -1
  67. package/src/tools/duck-vote.ts +38 -3
  68. package/src/tools/get-usage-stats.ts +14 -0
  69. package/src/ui/compare-ducks/app.ts +88 -0
  70. package/src/ui/compare-ducks/mcp-app.html +102 -0
  71. package/src/ui/duck-debate/app.ts +111 -0
  72. package/src/ui/duck-debate/mcp-app.html +97 -0
  73. package/src/ui/duck-vote/app.ts +128 -0
  74. package/src/ui/duck-vote/mcp-app.html +83 -0
  75. package/src/ui/usage-stats/app.ts +156 -0
  76. package/src/ui/usage-stats/mcp-app.html +107 -0
  77. package/tests/duck-debate.test.ts +83 -1
  78. package/tests/duck-iterate.test.ts +81 -0
  79. package/tests/duck-vote.test.ts +73 -1
  80. package/tests/providers.test.ts +121 -0
  81. package/tests/services/progress.test.ts +137 -0
  82. package/tests/services/task-manager.test.ts +344 -0
  83. package/tests/tools/compare-ducks-ui.test.ts +135 -0
  84. package/tests/tools/compare-ducks.test.ts +22 -1
  85. package/tests/tools/duck-council.test.ts +19 -0
  86. package/tests/tools/duck-debate-ui.test.ts +234 -0
  87. package/tests/tools/duck-vote-ui.test.ts +172 -0
  88. package/tests/tools/get-usage-stats.test.ts +3 -1
  89. package/tests/tools/usage-stats-ui.test.ts +130 -0
  90. package/tests/ui-build.test.ts +53 -0
  91. package/tsconfig.json +1 -1
  92. package/vite.config.ts +19 -0
package/jest.config.js CHANGED
@@ -22,5 +22,6 @@ export default {
22
22
  'src/**/*.ts',
23
23
  '!src/**/*.d.ts',
24
24
  '!src/index.ts',
25
+ '!src/ui/**',
25
26
  ],
26
27
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-rubber-duck",
3
- "version": "1.9.5",
3
+ "version": "1.11.0",
4
4
  "description": "An MCP server that bridges to multiple OpenAI-compatible LLMs - your AI rubber duck debugging panel",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -8,7 +8,8 @@
8
8
  "mcp-rubber-duck": "./dist/index.js"
9
9
  },
10
10
  "scripts": {
11
- "build": "tsc",
11
+ "build": "tsc && npm run build:ui",
12
+ "build:ui": "VITE_UI_ENTRY=compare-ducks vite build && VITE_UI_ENTRY=duck-vote vite build && VITE_UI_ENTRY=duck-debate vite build && VITE_UI_ENTRY=usage-stats vite build",
12
13
  "dev": "tsx watch src/index.ts",
13
14
  "start": "node dist/index.js",
14
15
  "test": "NODE_OPTIONS='--experimental-vm-modules' jest",
@@ -42,6 +43,7 @@
42
43
  "access": "public"
43
44
  },
44
45
  "dependencies": {
46
+ "@modelcontextprotocol/ext-apps": "^1.0.1",
45
47
  "@modelcontextprotocol/sdk": "^1.24.0",
46
48
  "@semantic-release/npm": "^13.1.3",
47
49
  "ajv": "^8.17.1",
@@ -65,7 +67,9 @@
65
67
  "semantic-release": "^25.0.2",
66
68
  "ts-jest": "^29.0.0",
67
69
  "tsx": "^4.0.0",
68
- "typescript": "^5.0.0"
70
+ "typescript": "^5.0.0",
71
+ "vite": "^7.3.1",
72
+ "vite-plugin-singlefile": "^2.3.0"
69
73
  },
70
74
  "overrides": {
71
75
  "js-yaml": "^4.1.1",
@@ -209,6 +209,55 @@ export class EnhancedProviderManager extends ProviderManager {
209
209
  return Promise.all(promises);
210
210
  }
211
211
 
212
+ async compareDucksWithProgressMCP(
213
+ prompt: string,
214
+ providerNames: string[] | undefined,
215
+ options: Partial<ChatOptions> | undefined,
216
+ onProviderComplete: (providerName: string, completed: number, total: number) => void
217
+ ): Promise<Array<DuckResponse & { pendingApprovals?: { id: string; message: string }[]; mcpResults?: MCPResult[] }>> {
218
+ if (!this.mcpEnabled) {
219
+ return this.compareDucksWithProgress(prompt, providerNames, options, onProviderComplete);
220
+ }
221
+
222
+ const providersToUse = providerNames
223
+ ? providerNames.map(name => this.enhancedProviders.get(name)).filter(Boolean)
224
+ : Array.from(this.enhancedProviders.values());
225
+
226
+ if (providersToUse.length === 0) {
227
+ throw new Error('No valid enhanced providers specified');
228
+ }
229
+
230
+ const total = providersToUse.length;
231
+ let completed = 0;
232
+
233
+ const promises = providersToUse.map(provider =>
234
+ provider ? this.askDuckWithMCP(provider.name, prompt, options)
235
+ .catch(error => ({
236
+ provider: provider.name,
237
+ nickname: provider.nickname,
238
+ model: '',
239
+ content: `Error: ${error instanceof Error ? error.message : String(error)}`,
240
+ latency: 0,
241
+ cached: false,
242
+ }))
243
+ .then(result => {
244
+ completed++;
245
+ onProviderComplete(provider.name, completed, total);
246
+ return result;
247
+ })
248
+ : Promise.resolve({
249
+ provider: 'unknown',
250
+ nickname: 'Unknown',
251
+ model: '',
252
+ content: 'Error: Invalid provider',
253
+ latency: 0,
254
+ cached: false,
255
+ })
256
+ );
257
+
258
+ return Promise.all(promises);
259
+ }
260
+
212
261
  async duckCouncilWithMCP(
213
262
  prompt: string,
214
263
  options?: Partial<ChatOptions>
@@ -183,6 +183,51 @@ export class ProviderManager {
183
183
  return Promise.all(promises);
184
184
  }
185
185
 
186
+ async compareDucksWithProgress(
187
+ prompt: string,
188
+ providerNames: string[] | undefined,
189
+ options: Partial<ChatOptions> | undefined,
190
+ onProviderComplete: (providerName: string, completed: number, total: number) => void
191
+ ): Promise<DuckResponse[]> {
192
+ const providersToUse = providerNames
193
+ ? providerNames.map(name => this.providers.get(name)).filter(Boolean)
194
+ : Array.from(this.providers.values());
195
+
196
+ if (providersToUse.length === 0) {
197
+ throw new Error('No valid providers specified');
198
+ }
199
+
200
+ const total = providersToUse.length;
201
+ let completed = 0;
202
+
203
+ const promises = providersToUse.map(provider =>
204
+ provider ? this.askDuck(provider.name, prompt, options)
205
+ .catch(error => ({
206
+ provider: provider.name,
207
+ nickname: provider.nickname,
208
+ model: '',
209
+ content: `Error: ${error instanceof Error ? error.message : String(error)}`,
210
+ latency: 0,
211
+ cached: false,
212
+ }))
213
+ .then(result => {
214
+ completed++;
215
+ onProviderComplete(provider.name, completed, total);
216
+ return result;
217
+ })
218
+ : Promise.resolve({
219
+ provider: 'unknown',
220
+ nickname: 'Unknown',
221
+ model: '',
222
+ content: 'Error: Invalid provider',
223
+ latency: 0,
224
+ cached: false,
225
+ })
226
+ );
227
+
228
+ return Promise.all(promises);
229
+ }
230
+
186
231
  async duckCouncil(
187
232
  prompt: string,
188
233
  options?: Partial<ChatOptions>
package/src/server.ts CHANGED
@@ -2,6 +2,14 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
4
4
  import { z } from 'zod';
5
+ import { readFileSync } from 'fs';
6
+ import { join, dirname } from 'path';
7
+ import { fileURLToPath } from 'url';
8
+ import {
9
+ registerAppTool,
10
+ registerAppResource,
11
+ RESOURCE_MIME_TYPE,
12
+ } from '@modelcontextprotocol/ext-apps/server';
5
13
 
6
14
  import { ConfigManager } from './config/config.js';
7
15
  import { ProviderManager } from './providers/manager.js';
@@ -16,6 +24,8 @@ import { DuckResponse } from './config/types.js';
16
24
  import { ApprovalService } from './services/approval.js';
17
25
  import { FunctionBridge } from './services/function-bridge.js';
18
26
  import { GuardrailsService } from './guardrails/service.js';
27
+ import { TaskManager } from './services/task-manager.js';
28
+ import { createProgressReporter } from './services/progress.js';
19
29
  import { logger } from './utils/logger.js';
20
30
  import { duckArt, getRandomDuckMessage } from './utils/ascii-art.js';
21
31
 
@@ -61,13 +71,32 @@ export class RubberDuckServer {
61
71
  private functionBridge?: FunctionBridge;
62
72
  private mcpEnabled: boolean = false;
63
73
 
74
+ // Task management
75
+ private taskManager: TaskManager;
76
+
64
77
  constructor() {
78
+ this.taskManager = new TaskManager();
79
+
65
80
  this.server = new McpServer(
66
81
  {
67
82
  name: 'mcp-rubber-duck',
68
83
  version: '1.0.0',
69
84
  },
70
- {}
85
+ {
86
+ capabilities: {
87
+ tasks: {
88
+ list: {},
89
+ cancel: {},
90
+ requests: {
91
+ tools: { call: {} },
92
+ },
93
+ },
94
+ },
95
+ taskStore: this.taskManager.taskStore,
96
+ taskMessageQueue: this.taskManager.taskMessageQueue,
97
+ defaultTaskPollInterval: this.taskManager.config.pollInterval,
98
+ maxTaskQueueSize: this.taskManager.config.maxQueueSize,
99
+ }
71
100
  );
72
101
 
73
102
  // Initialize managers
@@ -94,6 +123,7 @@ export class RubberDuckServer {
94
123
 
95
124
  this.registerTools();
96
125
  this.registerPrompts();
126
+ this.registerUIResources();
97
127
 
98
128
  // Handle errors
99
129
  this.server.server.onerror = (error) => {
@@ -288,7 +318,8 @@ export class RubberDuckServer {
288
318
  );
289
319
 
290
320
  // compare_ducks
291
- this.server.registerTool(
321
+ registerAppTool(
322
+ this.server,
292
323
  'compare_ducks',
293
324
  {
294
325
  description: 'Ask the same question to multiple ducks simultaneously',
@@ -301,13 +332,15 @@ export class RubberDuckServer {
301
332
  readOnlyHint: true,
302
333
  openWorldHint: true,
303
334
  },
335
+ _meta: { ui: { resourceUri: 'ui://rubber-duck/compare-ducks' } },
304
336
  },
305
- async (args) => {
337
+ async (args, extra) => {
306
338
  try {
339
+ const progress = createProgressReporter(extra._meta?.progressToken, extra.sendNotification);
307
340
  if (this.mcpEnabled && this.enhancedProviderManager) {
308
- return this.toolResult(await this.handleCompareDucksWithMCP(args as Record<string, unknown>));
341
+ return this.toolResult(await this.handleCompareDucksWithMCP(args as Record<string, unknown>, progress));
309
342
  }
310
- return this.toolResult(await compareDucksTool(this.providerManager, args as Record<string, unknown>));
343
+ return this.toolResult(await compareDucksTool(this.providerManager, args as Record<string, unknown>, progress));
311
344
  } catch (error) {
312
345
  return this.toolErrorResult(error);
313
346
  }
@@ -328,12 +361,13 @@ export class RubberDuckServer {
328
361
  openWorldHint: true,
329
362
  },
330
363
  },
331
- async (args) => {
364
+ async (args, extra) => {
332
365
  try {
366
+ const progress = createProgressReporter(extra._meta?.progressToken, extra.sendNotification);
333
367
  if (this.mcpEnabled && this.enhancedProviderManager) {
334
- return this.toolResult(await this.handleDuckCouncilWithMCP(args as Record<string, unknown>));
368
+ return this.toolResult(await this.handleDuckCouncilWithMCP(args as Record<string, unknown>, progress));
335
369
  }
336
- return this.toolResult(await duckCouncilTool(this.providerManager, args as Record<string, unknown>));
370
+ return this.toolResult(await duckCouncilTool(this.providerManager, args as Record<string, unknown>, progress));
337
371
  } catch (error) {
338
372
  return this.toolErrorResult(error);
339
373
  }
@@ -341,7 +375,8 @@ export class RubberDuckServer {
341
375
  );
342
376
 
343
377
  // duck_vote
344
- this.server.registerTool(
378
+ registerAppTool(
379
+ this.server,
345
380
  'duck_vote',
346
381
  {
347
382
  description: 'Have multiple ducks vote on options with reasoning. Returns vote tally, confidence scores, and consensus level.',
@@ -355,10 +390,12 @@ export class RubberDuckServer {
355
390
  readOnlyHint: true,
356
391
  openWorldHint: true,
357
392
  },
393
+ _meta: { ui: { resourceUri: 'ui://rubber-duck/duck-vote' } },
358
394
  },
359
- async (args) => {
395
+ async (args, extra) => {
360
396
  try {
361
- return this.toolResult(await duckVoteTool(this.providerManager, args as Record<string, unknown>));
397
+ const progress = createProgressReporter(extra._meta?.progressToken, extra.sendNotification);
398
+ return this.toolResult(await duckVoteTool(this.providerManager, args as Record<string, unknown>, progress));
362
399
  } catch (error) {
363
400
  return this.toolErrorResult(error);
364
401
  }
@@ -395,8 +432,8 @@ export class RubberDuckServer {
395
432
  }
396
433
  );
397
434
 
398
- // duck_iterate
399
- this.server.registerTool(
435
+ // duck_iterate (task-based: supports async execution for long-running iterations)
436
+ this.server.experimental.tasks.registerToolTask(
400
437
  'duck_iterate',
401
438
  {
402
439
  description: 'Iteratively refine a response between two ducks. One generates, the other critiques/improves, alternating for multiple rounds.',
@@ -410,18 +447,36 @@ export class RubberDuckServer {
410
447
  readOnlyHint: true,
411
448
  openWorldHint: true,
412
449
  },
450
+ execution: {
451
+ taskSupport: 'optional',
452
+ },
413
453
  },
414
- async (args) => {
415
- try {
416
- return this.toolResult(await duckIterateTool(this.providerManager, args as Record<string, unknown>));
417
- } catch (error) {
418
- return this.toolErrorResult(error);
419
- }
454
+ {
455
+ createTask: async (args, extra) => {
456
+ const task = await extra.taskStore.createTask({
457
+ ttl: this.taskManager.config.defaultTtl,
458
+ pollInterval: this.taskManager.config.pollInterval,
459
+ });
460
+ const progress = createProgressReporter(extra._meta?.progressToken, extra.sendNotification);
461
+ this.taskManager.startBackground(task.taskId, async (signal) => {
462
+ return this.toolResult(
463
+ await duckIterateTool(this.providerManager, args as Record<string, unknown>, progress, signal)
464
+ );
465
+ });
466
+ return { task };
467
+ },
468
+ getTask: async (_args, extra) => {
469
+ const task = await extra.taskStore.getTask(extra.taskId);
470
+ return task;
471
+ },
472
+ getTaskResult: async (_args, extra) => {
473
+ return await extra.taskStore.getTaskResult(extra.taskId) as CallToolResult;
474
+ },
420
475
  }
421
476
  );
422
477
 
423
- // duck_debate
424
- this.server.registerTool(
478
+ // duck_debate (task-based: supports async execution for multi-round debates)
479
+ this.server.experimental.tasks.registerToolTask(
425
480
  'duck_debate',
426
481
  {
427
482
  description: 'Structured multi-round debate between ducks. Supports oxford (pro/con), socratic (questioning), and adversarial (attack/defend) formats.',
@@ -436,18 +491,38 @@ export class RubberDuckServer {
436
491
  readOnlyHint: true,
437
492
  openWorldHint: true,
438
493
  },
494
+ _meta: { ui: { resourceUri: 'ui://rubber-duck/duck-debate' } },
495
+ execution: {
496
+ taskSupport: 'optional',
497
+ },
439
498
  },
440
- async (args) => {
441
- try {
442
- return this.toolResult(await duckDebateTool(this.providerManager, args as Record<string, unknown>));
443
- } catch (error) {
444
- return this.toolErrorResult(error);
445
- }
499
+ {
500
+ createTask: async (args, extra) => {
501
+ const task = await extra.taskStore.createTask({
502
+ ttl: this.taskManager.config.defaultTtl,
503
+ pollInterval: this.taskManager.config.pollInterval,
504
+ });
505
+ const progress = createProgressReporter(extra._meta?.progressToken, extra.sendNotification);
506
+ this.taskManager.startBackground(task.taskId, async (signal) => {
507
+ return this.toolResult(
508
+ await duckDebateTool(this.providerManager, args as Record<string, unknown>, progress, signal)
509
+ );
510
+ });
511
+ return { task };
512
+ },
513
+ getTask: async (_args, extra) => {
514
+ const task = await extra.taskStore.getTask(extra.taskId);
515
+ return task;
516
+ },
517
+ getTaskResult: async (_args, extra) => {
518
+ return await extra.taskStore.getTaskResult(extra.taskId) as CallToolResult;
519
+ },
446
520
  }
447
521
  );
448
522
 
449
523
  // get_usage_stats
450
- this.server.registerTool(
524
+ registerAppTool(
525
+ this.server,
451
526
  'get_usage_stats',
452
527
  {
453
528
  description: 'Get usage statistics for a time period. Shows token counts and costs (when pricing configured).',
@@ -458,6 +533,7 @@ export class RubberDuckServer {
458
533
  readOnlyHint: true,
459
534
  openWorldHint: false,
460
535
  },
536
+ _meta: { ui: { resourceUri: 'ui://rubber-duck/usage-stats' } },
461
537
  },
462
538
  (args) => {
463
539
  try {
@@ -581,6 +657,44 @@ export class RubberDuckServer {
581
657
  }
582
658
  }
583
659
 
660
+ private registerUIResources() {
661
+ const currentDir = dirname(fileURLToPath(import.meta.url));
662
+ const uiDir = join(currentDir, '..', 'dist', 'ui');
663
+
664
+ const uiApps = [
665
+ { name: 'Compare Ducks', uri: 'ui://rubber-duck/compare-ducks', file: 'compare-ducks/mcp-app.html' },
666
+ { name: 'Duck Vote', uri: 'ui://rubber-duck/duck-vote', file: 'duck-vote/mcp-app.html' },
667
+ { name: 'Duck Debate', uri: 'ui://rubber-duck/duck-debate', file: 'duck-debate/mcp-app.html' },
668
+ { name: 'Usage Stats', uri: 'ui://rubber-duck/usage-stats', file: 'usage-stats/mcp-app.html' },
669
+ ];
670
+
671
+ for (const app of uiApps) {
672
+ registerAppResource(
673
+ this.server,
674
+ app.name,
675
+ app.uri,
676
+ { description: `Interactive UI for ${app.name}` },
677
+ () => {
678
+ let html: string;
679
+ try {
680
+ html = readFileSync(join(uiDir, app.file), 'utf-8');
681
+ } catch {
682
+ html = `<html><body><p>UI not built. Run npm run build:ui</p></body></html>`;
683
+ }
684
+ return {
685
+ contents: [
686
+ {
687
+ uri: app.uri,
688
+ mimeType: RESOURCE_MIME_TYPE,
689
+ text: html,
690
+ },
691
+ ],
692
+ };
693
+ }
694
+ );
695
+ }
696
+ }
697
+
584
698
  // MCP-enhanced tool handlers
585
699
  private async handleAskDuckWithMCP(args: Record<string, unknown>) {
586
700
  if (!this.enhancedProviderManager || !this.cache) {
@@ -622,7 +736,7 @@ export class RubberDuckServer {
622
736
  };
623
737
  }
624
738
 
625
- private async handleCompareDucksWithMCP(args: Record<string, unknown>) {
739
+ private async handleCompareDucksWithMCP(args: Record<string, unknown>, progress?: import('./services/progress.js').ProgressReporter) {
626
740
  if (!this.enhancedProviderManager) {
627
741
  throw new Error('Enhanced provider manager not available');
628
742
  }
@@ -633,32 +747,68 @@ export class RubberDuckServer {
633
747
  model?: string;
634
748
  };
635
749
 
636
- const responses = await this.enhancedProviderManager.compareDucksWithMCP(prompt, providers, {
637
- model,
638
- });
750
+ const responses = progress
751
+ ? await this.enhancedProviderManager.compareDucksWithProgressMCP(
752
+ prompt,
753
+ providers,
754
+ { model },
755
+ (providerName, completed, total) => {
756
+ void progress.report(completed, total, `${providerName} responded (${completed}/${total})`);
757
+ }
758
+ )
759
+ : await this.enhancedProviderManager.compareDucksWithMCP(prompt, providers, { model });
639
760
 
640
761
  const formattedResponse = responses
641
762
  .map((response) => this.formatEnhancedDuckResponse(response))
642
763
  .join('\n\n═══════════════════════════════════════\n\n');
643
764
 
765
+ // Build structured data for UI consumption (same shape as compareDucksTool)
766
+ const structuredData = responses.map(r => ({
767
+ provider: r.provider,
768
+ nickname: r.nickname,
769
+ model: r.model,
770
+ content: r.content,
771
+ latency: r.latency,
772
+ tokens: r.usage ? {
773
+ prompt: r.usage.prompt_tokens,
774
+ completion: r.usage.completion_tokens,
775
+ total: r.usage.total_tokens,
776
+ } : null,
777
+ cached: r.cached,
778
+ error: r.content.startsWith('Error:') ? r.content : undefined,
779
+ }));
780
+
644
781
  return {
645
782
  content: [
646
783
  {
647
784
  type: 'text' as const,
648
785
  text: formattedResponse,
649
786
  },
787
+ {
788
+ type: 'text' as const,
789
+ text: JSON.stringify(structuredData),
790
+ },
650
791
  ],
651
792
  };
652
793
  }
653
794
 
654
- private async handleDuckCouncilWithMCP(args: Record<string, unknown>) {
795
+ private async handleDuckCouncilWithMCP(args: Record<string, unknown>, progress?: import('./services/progress.js').ProgressReporter) {
655
796
  if (!this.enhancedProviderManager) {
656
797
  throw new Error('Enhanced provider manager not available');
657
798
  }
658
799
 
659
800
  const { prompt, model } = args as { prompt: string; model?: string };
660
801
 
661
- const responses = await this.enhancedProviderManager.duckCouncilWithMCP(prompt, { model });
802
+ const responses = progress
803
+ ? await this.enhancedProviderManager.compareDucksWithProgressMCP(
804
+ prompt,
805
+ undefined,
806
+ { model },
807
+ (providerName, completed, total) => {
808
+ void progress.report(completed, total, `${providerName} responded (${completed}/${total})`);
809
+ }
810
+ )
811
+ : await this.enhancedProviderManager.duckCouncilWithMCP(prompt, { model });
662
812
 
663
813
  const header = '🦆 Duck Council in Session 🦆\n=============================';
664
814
  const formattedResponse = responses
@@ -764,6 +914,9 @@ export class RubberDuckServer {
764
914
  }
765
915
 
766
916
  async stop() {
917
+ // Cleanup task manager (cancel active tasks, clear timers)
918
+ this.taskManager.shutdown();
919
+
767
920
  // Cleanup usage service (flush pending writes)
768
921
  this.usageService.shutdown();
769
922
 
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Progress notification service for MCP tools.
3
+ *
4
+ * Provides a lightweight abstraction over MCP's stable `notifications/progress`
5
+ * mechanism. Tool functions accept an optional `ProgressReporter` to emit
6
+ * per-step progress without depending on the full `RequestHandlerExtra` type.
7
+ */
8
+
9
+ export interface ProgressReporter {
10
+ /** Report that step `current` of `total` is done, with an optional status message. */
11
+ report(current: number, total: number, message?: string): Promise<void>;
12
+ /** Whether the client actually requested progress (sent a progressToken). */
13
+ readonly enabled: boolean;
14
+ }
15
+
16
+ /**
17
+ * Creates a `ProgressReporter` from an MCP tool handler's `extra` parameter.
18
+ *
19
+ * If the client did not include a `progressToken` in `_meta`, the returned
20
+ * reporter is a no-op (`.enabled === false`), so callers can always call
21
+ * `progress.report()` unconditionally.
22
+ *
23
+ * @param progressToken Value of `extra._meta?.progressToken`
24
+ * @param sendNotification Value of `extra.sendNotification` — typed loosely to
25
+ * avoid coupling to SDK's strict discriminated union. At runtime the SDK
26
+ * accepts any valid JSON-RPC notification including `notifications/progress`.
27
+ */
28
+ export function createProgressReporter(
29
+ progressToken: string | number | undefined,
30
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
+ sendNotification: (notification: any) => Promise<void>
32
+ ): ProgressReporter {
33
+ if (progressToken === undefined) {
34
+ return {
35
+ enabled: false,
36
+ report: async () => {},
37
+ };
38
+ }
39
+
40
+ return {
41
+ enabled: true,
42
+ report: async (current: number, total: number, message?: string) => {
43
+ try {
44
+ await sendNotification({
45
+ method: 'notifications/progress',
46
+ params: {
47
+ progressToken,
48
+ progress: current,
49
+ total,
50
+ ...(message !== undefined ? { message } : {}),
51
+ },
52
+ });
53
+ } catch {
54
+ // Swallow notification errors (e.g., client disconnected).
55
+ // Progress is best-effort — tool execution should continue regardless.
56
+ }
57
+ },
58
+ };
59
+ }