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
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Task lifecycle adapter wrapping the MCP SDK's experimental Tasks API.
3
+ *
4
+ * This module isolates all direct usage of `@modelcontextprotocol/sdk/experimental`
5
+ * behind a single adapter class so that future breaking changes in the experimental
6
+ * API only require updates here.
7
+ */
8
+
9
+ import {
10
+ InMemoryTaskStore,
11
+ InMemoryTaskMessageQueue,
12
+ } from '@modelcontextprotocol/sdk/experimental';
13
+ import type {
14
+ TaskStore,
15
+ TaskMessageQueue,
16
+ } from '@modelcontextprotocol/sdk/experimental';
17
+ import type { CallToolResult, Result } from '@modelcontextprotocol/sdk/types.js';
18
+ import { logger } from '../utils/logger.js';
19
+
20
+ export interface TaskManagerConfig {
21
+ /** Time-to-live for completed task results (milliseconds). */
22
+ defaultTtl: number;
23
+ /** Suggested interval between client polls (milliseconds). */
24
+ pollInterval: number;
25
+ /** Maximum messages per task queue (prevents unbounded growth). */
26
+ maxQueueSize: number;
27
+ /** Interval for cleanup/monitoring sweep (milliseconds). */
28
+ cleanupInterval: number;
29
+ }
30
+
31
+ const DEFAULT_CONFIG: TaskManagerConfig = {
32
+ defaultTtl: 300_000, // 5 minutes
33
+ pollInterval: 2_000, // 2 seconds
34
+ maxQueueSize: 100,
35
+ cleanupInterval: 60_000, // 1 minute
36
+ };
37
+
38
+ /**
39
+ * Manages MCP task lifecycle: creation, background execution, cancellation, and cleanup.
40
+ *
41
+ * - Provides `InMemoryTaskStore` and `InMemoryTaskMessageQueue` instances for
42
+ * `McpServer`'s `ProtocolOptions`.
43
+ * - Tracks active background work via `AbortController` per task for cancellation.
44
+ * - Handles graceful shutdown (cancels active tasks, clears timers).
45
+ */
46
+ export class TaskManager {
47
+ readonly taskStore: TaskStore;
48
+ readonly taskMessageQueue: TaskMessageQueue;
49
+ readonly config: TaskManagerConfig;
50
+
51
+ /** Maps taskId → AbortController for active background work. */
52
+ private activeControllers: Map<string, AbortController> = new Map();
53
+ private cleanupTimer?: ReturnType<typeof setInterval>;
54
+
55
+ constructor(config?: Partial<TaskManagerConfig>) {
56
+ this.config = { ...DEFAULT_CONFIG, ...config };
57
+ this.taskStore = new InMemoryTaskStore();
58
+ this.taskMessageQueue = new InMemoryTaskMessageQueue();
59
+ this.startCleanup();
60
+ }
61
+
62
+ /**
63
+ * Start background work for a task.
64
+ *
65
+ * This is fire-and-forget: the returned promise resolves immediately.
66
+ * The `work` function runs asynchronously; its result is stored in the
67
+ * task store on completion. On error the task is marked `failed`.
68
+ * On abort (cancellation) the task is marked `cancelled`.
69
+ *
70
+ * @param taskId ID of the task (from `taskStore.createTask`)
71
+ * @param work Async function receiving an `AbortSignal` and returning a `CallToolResult`
72
+ */
73
+ startBackground(
74
+ taskId: string,
75
+ work: (signal: AbortSignal) => Promise<CallToolResult>
76
+ ): void {
77
+ const controller = new AbortController();
78
+ this.activeControllers.set(taskId, controller);
79
+
80
+ void (async () => {
81
+ try {
82
+ await this.taskStore.updateTaskStatus(taskId, 'working');
83
+ const result = await work(controller.signal);
84
+
85
+ if (!controller.signal.aborted) {
86
+ await this.taskStore.storeTaskResult(taskId, 'completed', result as Result);
87
+ } else {
88
+ // Work completed but cancellation was requested mid-execution.
89
+ // Mark as cancelled so the task doesn't stay stuck in 'working'.
90
+ try {
91
+ await this.taskStore.updateTaskStatus(taskId, 'cancelled', 'Task was cancelled');
92
+ } catch {
93
+ // Task may already be in a terminal state
94
+ }
95
+ }
96
+ } catch (error) {
97
+ if (controller.signal.aborted) {
98
+ try {
99
+ await this.taskStore.updateTaskStatus(taskId, 'cancelled', 'Task was cancelled');
100
+ } catch {
101
+ // Task may already be in a terminal state
102
+ }
103
+ } else {
104
+ const message = error instanceof Error ? error.message : String(error);
105
+ logger.error(`Task ${taskId} failed:`, message);
106
+ try {
107
+ await this.taskStore.storeTaskResult(taskId, 'failed', {
108
+ content: [{ type: 'text', text: `Error: ${message}` }],
109
+ isError: true,
110
+ } as Result);
111
+ } catch {
112
+ // Task store may have already cleaned up (TTL)
113
+ }
114
+ }
115
+ } finally {
116
+ this.activeControllers.delete(taskId);
117
+ }
118
+ })();
119
+ }
120
+
121
+ /** Cancel a running task by aborting its AbortController. */
122
+ cancel(taskId: string): boolean {
123
+ const controller = this.activeControllers.get(taskId);
124
+ if (controller) {
125
+ controller.abort();
126
+ return true;
127
+ }
128
+ return false;
129
+ }
130
+
131
+ /** Number of currently active background tasks. */
132
+ get activeCount(): number {
133
+ return this.activeControllers.size;
134
+ }
135
+
136
+ private startCleanup(): void {
137
+ this.cleanupTimer = setInterval(() => {
138
+ logger.debug(`Active background tasks: ${this.activeControllers.size}`);
139
+ }, this.config.cleanupInterval);
140
+ // Allow the process to exit even if the timer is still running
141
+ if (this.cleanupTimer && typeof this.cleanupTimer === 'object' && 'unref' in this.cleanupTimer) {
142
+ this.cleanupTimer.unref();
143
+ }
144
+ }
145
+
146
+ /** Graceful shutdown: cancel all active tasks and clear timers. */
147
+ shutdown(): void {
148
+ if (this.cleanupTimer) {
149
+ clearInterval(this.cleanupTimer);
150
+ this.cleanupTimer = undefined;
151
+ }
152
+
153
+ for (const [taskId, controller] of this.activeControllers) {
154
+ logger.info(`Cancelling active task ${taskId} during shutdown`);
155
+ controller.abort();
156
+ }
157
+ this.activeControllers.clear();
158
+
159
+ // Clear InMemoryTaskStore internal TTL timers
160
+ (this.taskStore as InMemoryTaskStore).cleanup();
161
+ }
162
+ }
@@ -1,10 +1,12 @@
1
1
  import { ProviderManager } from '../providers/manager.js';
2
2
  import { duckArt } from '../utils/ascii-art.js';
3
3
  import { logger } from '../utils/logger.js';
4
+ import type { ProgressReporter } from '../services/progress.js';
4
5
 
5
6
  export async function compareDucksTool(
6
7
  providerManager: ProviderManager,
7
- args: Record<string, unknown>
8
+ args: Record<string, unknown>,
9
+ progress?: ProgressReporter
8
10
  ) {
9
11
  const { prompt, providers, model } = args as {
10
12
  prompt?: string;
@@ -16,8 +18,17 @@ export async function compareDucksTool(
16
18
  throw new Error('Prompt is required');
17
19
  }
18
20
 
19
- // Get responses from multiple ducks
20
- const responses = await providerManager.compareDucks(prompt, providers, { model });
21
+ // Get responses from multiple ducks, reporting progress as each completes
22
+ const responses = progress
23
+ ? await providerManager.compareDucksWithProgress(
24
+ prompt,
25
+ providers,
26
+ { model },
27
+ (providerName, completed, total) => {
28
+ void progress.report(completed, total, `${providerName} responded (${completed}/${total})`);
29
+ }
30
+ )
31
+ : await providerManager.compareDucks(prompt, providers, { model });
21
32
 
22
33
  // Build comparison response
23
34
  let response = `${duckArt.panel}\n`;
@@ -55,12 +66,32 @@ export async function compareDucksTool(
55
66
 
56
67
  logger.info(`Compared ${responses.length} ducks, ${successCount} successful`);
57
68
 
69
+ // Build structured data for UI consumption
70
+ const structuredData = responses.map(r => ({
71
+ provider: r.provider,
72
+ nickname: r.nickname,
73
+ model: r.model,
74
+ content: r.content,
75
+ latency: r.latency,
76
+ tokens: r.usage ? {
77
+ prompt: r.usage.prompt_tokens,
78
+ completion: r.usage.completion_tokens,
79
+ total: r.usage.total_tokens,
80
+ } : null,
81
+ cached: r.cached,
82
+ error: r.content.startsWith('Error:') ? r.content : undefined,
83
+ }));
84
+
58
85
  return {
59
86
  content: [
60
87
  {
61
88
  type: 'text',
62
89
  text: response,
63
90
  },
91
+ {
92
+ type: 'text',
93
+ text: JSON.stringify(structuredData),
94
+ },
64
95
  ],
65
96
  };
66
97
  }
@@ -1,10 +1,12 @@
1
1
  import { ProviderManager } from '../providers/manager.js';
2
2
  import { duckArt, getRandomDuckMessage } from '../utils/ascii-art.js';
3
3
  import { logger } from '../utils/logger.js';
4
+ import type { ProgressReporter } from '../services/progress.js';
4
5
 
5
6
  export async function duckCouncilTool(
6
7
  providerManager: ProviderManager,
7
- args: Record<string, unknown>
8
+ args: Record<string, unknown>,
9
+ progress?: ProgressReporter
8
10
  ) {
9
11
  const { prompt, model } = args as {
10
12
  prompt?: string;
@@ -19,13 +21,22 @@ export async function duckCouncilTool(
19
21
 
20
22
  // Get all available ducks
21
23
  const allProviders = providerManager.getProviderNames();
22
-
24
+
23
25
  if (allProviders.length === 0) {
24
26
  throw new Error('No ducks available for the council!');
25
27
  }
26
28
 
27
- // Get responses from all ducks
28
- const responses = await providerManager.duckCouncil(prompt, { model });
29
+ // Get responses from all ducks, reporting progress as each completes
30
+ const responses = progress
31
+ ? await providerManager.compareDucksWithProgress(
32
+ prompt,
33
+ undefined,
34
+ { model },
35
+ (providerName, completed, total) => {
36
+ void progress.report(completed, total, `${providerName} responded (${completed}/${total})`);
37
+ }
38
+ )
39
+ : await providerManager.duckCouncil(prompt, { model });
29
40
 
30
41
  // Build council response with a panel discussion format
31
42
  let response = `${duckArt.panel}\n\n`;
@@ -7,6 +7,7 @@ import {
7
7
  DebateResult,
8
8
  } from '../config/types.js';
9
9
  import { logger } from '../utils/logger.js';
10
+ import type { ProgressReporter } from '../services/progress.js';
10
11
 
11
12
  export interface DuckDebateArgs {
12
13
  prompt: string;
@@ -20,7 +21,9 @@ const DEFAULT_ROUNDS = 3;
20
21
 
21
22
  export async function duckDebateTool(
22
23
  providerManager: ProviderManager,
23
- args: Record<string, unknown>
24
+ args: Record<string, unknown>,
25
+ progress?: ProgressReporter,
26
+ signal?: AbortSignal
24
27
  ) {
25
28
  const {
26
29
  prompt,
@@ -73,13 +76,23 @@ export async function duckDebateTool(
73
76
 
74
77
  // Run debate rounds
75
78
  const debateRounds: DebateArgument[][] = [];
79
+ const totalSteps = rounds * participants.length + 1; // +1 for synthesis
80
+ let completedSteps = 0;
76
81
 
77
82
  for (let roundNum = 1; roundNum <= rounds; roundNum++) {
83
+ if (signal?.aborted) {
84
+ throw new Error('Task cancelled');
85
+ }
86
+
78
87
  logger.info(`Debate round ${roundNum}/${rounds}`);
79
88
  const roundArguments: DebateArgument[] = [];
80
89
 
81
90
  // Each participant argues in this round
82
91
  for (const participant of participants) {
92
+ if (signal?.aborted) {
93
+ throw new Error('Task cancelled');
94
+ }
95
+
83
96
  const argumentPrompt = buildArgumentPrompt(
84
97
  prompt,
85
98
  format,
@@ -99,16 +112,33 @@ export async function duckDebateTool(
99
112
  content: response.content,
100
113
  timestamp: new Date(),
101
114
  });
115
+
116
+ completedSteps++;
117
+ if (progress) {
118
+ void progress.report(
119
+ completedSteps,
120
+ totalSteps,
121
+ `Round ${roundNum}/${rounds}: ${participant.nickname} (${participant.position})`
122
+ );
123
+ }
102
124
  }
103
125
 
104
126
  debateRounds.push(roundArguments);
105
127
  }
106
128
 
129
+ if (signal?.aborted) {
130
+ throw new Error('Task cancelled');
131
+ }
132
+
107
133
  // Generate synthesis
108
134
  const synthesizerProvider = synthesizer || debateProviders[0];
109
135
  const synthesisPrompt = buildSynthesisPrompt(prompt, format, debateRounds, participants);
110
136
  const synthesisResponse = await providerManager.askDuck(synthesizerProvider, synthesisPrompt);
111
137
 
138
+ if (progress) {
139
+ void progress.report(totalSteps, totalSteps, 'Synthesis complete');
140
+ }
141
+
112
142
  const result: DebateResult = {
113
143
  topic: prompt,
114
144
  format,
@@ -124,12 +154,39 @@ export async function duckDebateTool(
124
154
 
125
155
  logger.info(`Debate completed: ${rounds} rounds, synthesized by ${synthesizerProvider}`);
126
156
 
157
+ // Build structured data for UI consumption
158
+ const structuredData = {
159
+ topic: result.topic,
160
+ format: result.format,
161
+ totalRounds: result.totalRounds,
162
+ participants: result.participants.map(p => ({
163
+ provider: p.provider,
164
+ nickname: p.nickname,
165
+ position: p.position,
166
+ })),
167
+ rounds: result.rounds.map(roundArgs =>
168
+ roundArgs.map(arg => ({
169
+ round: arg.round,
170
+ provider: arg.provider,
171
+ nickname: arg.nickname,
172
+ position: arg.position,
173
+ content: arg.content,
174
+ }))
175
+ ),
176
+ synthesis: result.synthesis,
177
+ synthesizer: result.synthesizer,
178
+ };
179
+
127
180
  return {
128
181
  content: [
129
182
  {
130
183
  type: 'text',
131
184
  text: formattedOutput,
132
185
  },
186
+ {
187
+ type: 'text',
188
+ text: JSON.stringify(structuredData),
189
+ },
133
190
  ],
134
191
  };
135
192
  }
@@ -1,6 +1,7 @@
1
1
  import { ProviderManager } from '../providers/manager.js';
2
2
  import { IterationRound, IterationResult } from '../config/types.js';
3
3
  import { logger } from '../utils/logger.js';
4
+ import type { ProgressReporter } from '../services/progress.js';
4
5
 
5
6
  export interface DuckIterateArgs {
6
7
  prompt: string;
@@ -14,7 +15,9 @@ const CONVERGENCE_THRESHOLD = 0.8; // 80% similarity indicates convergence
14
15
 
15
16
  export async function duckIterateTool(
16
17
  providerManager: ProviderManager,
17
- args: Record<string, unknown>
18
+ args: Record<string, unknown>,
19
+ progress?: ProgressReporter,
20
+ signal?: AbortSignal
18
21
  ) {
19
22
  const {
20
23
  prompt,
@@ -54,6 +57,10 @@ export async function duckIterateTool(
54
57
  let lastResponse = '';
55
58
  let converged = false;
56
59
 
60
+ if (signal?.aborted) {
61
+ throw new Error('Task cancelled');
62
+ }
63
+
57
64
  // Round 1: Initial generation by provider A
58
65
  const initialResponse = await providerManager.askDuck(providers[0], prompt);
59
66
  const providerAInfo = providerManager.getProvider(providers[0]);
@@ -70,8 +77,16 @@ export async function duckIterateTool(
70
77
  lastResponse = initialResponse.content;
71
78
  logger.info(`Round 1: ${providers[0]} generated initial response`);
72
79
 
80
+ if (progress) {
81
+ void progress.report(1, iterations, `Round 1/${iterations}: ${providers[0]} generated`);
82
+ }
83
+
73
84
  // Subsequent rounds: Alternate between providers
74
85
  for (let i = 2; i <= iterations; i++) {
86
+ if (signal?.aborted) {
87
+ throw new Error('Task cancelled');
88
+ }
89
+
75
90
  const isProviderA = i % 2 === 1;
76
91
  const currentProvider = isProviderA ? providers[0] : providers[1];
77
92
  const providerInfo = providerManager.getProvider(currentProvider);
@@ -100,6 +115,10 @@ export async function duckIterateTool(
100
115
  lastResponse = response.content;
101
116
  logger.info(`Round ${i}: ${currentProvider} ${role === 'critic' ? 'critiqued' : 'refined'}`);
102
117
 
118
+ if (progress) {
119
+ void progress.report(i, iterations, `Round ${i}/${iterations}: ${currentProvider} ${role}`);
120
+ }
121
+
103
122
  if (converged) {
104
123
  break;
105
124
  }
@@ -2,6 +2,7 @@ import { ProviderManager } from '../providers/manager.js';
2
2
  import { ConsensusService } from '../services/consensus.js';
3
3
  import { VoteResult } from '../config/types.js';
4
4
  import { logger } from '../utils/logger.js';
5
+ import type { ProgressReporter } from '../services/progress.js';
5
6
 
6
7
  export interface DuckVoteArgs {
7
8
  question: string;
@@ -12,7 +13,8 @@ export interface DuckVoteArgs {
12
13
 
13
14
  export async function duckVoteTool(
14
15
  providerManager: ProviderManager,
15
- args: Record<string, unknown>
16
+ args: Record<string, unknown>,
17
+ progress?: ProgressReporter
16
18
  ) {
17
19
  const {
18
20
  question,
@@ -52,8 +54,17 @@ export async function duckVoteTool(
52
54
  require_reasoning
53
55
  );
54
56
 
55
- // Get votes from all ducks in parallel
56
- const responses = await providerManager.compareDucks(votePrompt, voterNames);
57
+ // Get votes from all ducks in parallel, reporting progress as each votes
58
+ const responses = progress
59
+ ? await providerManager.compareDucksWithProgress(
60
+ votePrompt,
61
+ voterNames,
62
+ undefined,
63
+ (providerName, completed, total) => {
64
+ void progress.report(completed, total, `${providerName} voted (${completed}/${total})`);
65
+ }
66
+ )
67
+ : await providerManager.compareDucks(votePrompt, voterNames);
57
68
 
58
69
  // Parse votes
59
70
  const votes: VoteResult[] = responses.map(response => {
@@ -76,12 +87,36 @@ export async function duckVoteTool(
76
87
  `winner: ${aggregatedResult.winner || 'none'}`
77
88
  );
78
89
 
90
+ // Build structured data for UI consumption
91
+ const structuredData = {
92
+ question: aggregatedResult.question,
93
+ options: aggregatedResult.options,
94
+ winner: aggregatedResult.winner,
95
+ isTie: aggregatedResult.isTie,
96
+ tally: aggregatedResult.tally,
97
+ confidenceByOption: aggregatedResult.confidenceByOption,
98
+ votes: aggregatedResult.votes.map(v => ({
99
+ voter: v.voter,
100
+ nickname: v.nickname,
101
+ choice: v.choice,
102
+ confidence: v.confidence,
103
+ reasoning: v.reasoning,
104
+ })),
105
+ totalVoters: aggregatedResult.totalVoters,
106
+ validVotes: aggregatedResult.validVotes,
107
+ consensusLevel: aggregatedResult.consensusLevel,
108
+ };
109
+
79
110
  return {
80
111
  content: [
81
112
  {
82
113
  type: 'text',
83
114
  text: formattedOutput,
84
115
  },
116
+ {
117
+ type: 'text',
118
+ text: JSON.stringify(structuredData),
119
+ },
85
120
  ],
86
121
  };
87
122
  }
@@ -75,12 +75,26 @@ export function getUsageStatsTool(
75
75
 
76
76
  logger.info(`Retrieved usage stats for period: ${period}`);
77
77
 
78
+ // Build structured data for UI consumption
79
+ const structuredData = {
80
+ period: stats.period,
81
+ startDate: stats.startDate,
82
+ endDate: stats.endDate,
83
+ totals: stats.totals,
84
+ usage: stats.usage,
85
+ costByProvider: stats.costByProvider,
86
+ };
87
+
78
88
  return {
79
89
  content: [
80
90
  {
81
91
  type: 'text',
82
92
  text: output,
83
93
  },
94
+ {
95
+ type: 'text',
96
+ text: JSON.stringify(structuredData),
97
+ },
84
98
  ],
85
99
  };
86
100
  }
@@ -0,0 +1,88 @@
1
+ import { App } from '@modelcontextprotocol/ext-apps';
2
+
3
+ interface CompareResponse {
4
+ provider: string;
5
+ nickname: string;
6
+ model: string;
7
+ content: string;
8
+ latency: number;
9
+ tokens: { prompt: number; completion: number; total: number } | null;
10
+ cached: boolean;
11
+ error?: string;
12
+ }
13
+
14
+ const app = new App({ name: 'CompareDucks', version: '1.0.0' }, {});
15
+
16
+ app.ontoolresult = (params) => {
17
+ const container = document.getElementById('app')!;
18
+ if (params.isError) {
19
+ container.innerHTML = `<div class="error-banner">Tool execution failed</div>`;
20
+ return;
21
+ }
22
+
23
+ // Parse JSON from second content item
24
+ const content = params.content;
25
+ if (!content || !Array.isArray(content) || content.length < 2) {
26
+ container.innerHTML = `<div class="error-banner">No structured data received</div>`;
27
+ return;
28
+ }
29
+
30
+ try {
31
+ const data: CompareResponse[] = JSON.parse(
32
+ (content[1] as { type: string; text: string }).text
33
+ );
34
+ render(data);
35
+ } catch {
36
+ container.innerHTML = `<div class="error-banner">Failed to parse response data</div>`;
37
+ }
38
+ };
39
+
40
+ function render(responses: CompareResponse[]) {
41
+ const container = document.getElementById('app')!;
42
+ const successCount = responses.filter((r) => !r.error).length;
43
+
44
+ let html = `<div class="summary-bar">${successCount}/${responses.length} ducks responded successfully</div>`;
45
+ html += `<div class="grid">`;
46
+
47
+ for (const r of responses) {
48
+ const isError = !!r.error;
49
+ const latencyClass =
50
+ r.latency < 2000 ? 'fast' : r.latency < 5000 ? 'medium' : 'slow';
51
+
52
+ html += `<div class="card${isError ? ' card-error' : ''}">`;
53
+ html += `<div class="card-header">`;
54
+ html += `<span class="nickname">${esc(r.nickname)}</span>`;
55
+ html += `<span class="provider">${esc(r.provider)}</span>`;
56
+ html += `</div>`;
57
+
58
+ if (!isError) {
59
+ html += `<div class="badges">`;
60
+ html += `<span class="badge model">${esc(r.model)}</span>`;
61
+ if (r.tokens) {
62
+ html += `<span class="badge tokens">${r.tokens.total} tokens</span>`;
63
+ }
64
+ if (r.cached) {
65
+ html += `<span class="badge cached">Cached</span>`;
66
+ }
67
+ html += `</div>`;
68
+ html += `<div class="latency-bar ${latencyClass}" style="width:${Math.min(100, (r.latency / 10000) * 100)}%"></div>`;
69
+ html += `<div class="latency-label">${r.latency}ms</div>`;
70
+ html += `<div class="content"><pre>${esc(r.content)}</pre></div>`;
71
+ } else {
72
+ html += `<div class="content error-text"><pre>${esc(r.content)}</pre></div>`;
73
+ }
74
+
75
+ html += `</div>`;
76
+ }
77
+
78
+ html += `</div>`;
79
+ container.innerHTML = html;
80
+ }
81
+
82
+ function esc(s: string): string {
83
+ const d = document.createElement('div');
84
+ d.textContent = s;
85
+ return d.innerHTML;
86
+ }
87
+
88
+ app.connect().catch(console.error);