mcp-rubber-duck 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +19 -0
- package/.env.desktop.example +145 -0
- package/.env.example +45 -0
- package/.env.pi.example +106 -0
- package/.env.template +165 -0
- package/.eslintrc.json +40 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +65 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +58 -0
- package/.github/ISSUE_TEMPLATE/question.md +67 -0
- package/.github/pull_request_template.md +111 -0
- package/.github/workflows/docker-build.yml +138 -0
- package/.github/workflows/release.yml +182 -0
- package/.github/workflows/security.yml +141 -0
- package/.github/workflows/semantic-release.yml +89 -0
- package/.prettierrc +10 -0
- package/.releaserc.json +66 -0
- package/CHANGELOG.md +95 -0
- package/CONTRIBUTING.md +242 -0
- package/Dockerfile +62 -0
- package/LICENSE +21 -0
- package/README.md +803 -0
- package/audit-ci.json +8 -0
- package/config/claude_desktop.json +14 -0
- package/config/config.example.json +91 -0
- package/dist/config/config.d.ts +51 -0
- package/dist/config/config.d.ts.map +1 -0
- package/dist/config/config.js +301 -0
- package/dist/config/config.js.map +1 -0
- package/dist/config/types.d.ts +356 -0
- package/dist/config/types.d.ts.map +1 -0
- package/dist/config/types.js +41 -0
- package/dist/config/types.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +109 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/duck-provider-enhanced.d.ts +29 -0
- package/dist/providers/duck-provider-enhanced.d.ts.map +1 -0
- package/dist/providers/duck-provider-enhanced.js +230 -0
- package/dist/providers/duck-provider-enhanced.js.map +1 -0
- package/dist/providers/enhanced-manager.d.ts +54 -0
- package/dist/providers/enhanced-manager.d.ts.map +1 -0
- package/dist/providers/enhanced-manager.js +217 -0
- package/dist/providers/enhanced-manager.js.map +1 -0
- package/dist/providers/manager.d.ts +28 -0
- package/dist/providers/manager.d.ts.map +1 -0
- package/dist/providers/manager.js +204 -0
- package/dist/providers/manager.js.map +1 -0
- package/dist/providers/provider.d.ts +29 -0
- package/dist/providers/provider.d.ts.map +1 -0
- package/dist/providers/provider.js +179 -0
- package/dist/providers/provider.js.map +1 -0
- package/dist/providers/types.d.ts +69 -0
- package/dist/providers/types.d.ts.map +1 -0
- package/dist/providers/types.js +2 -0
- package/dist/providers/types.js.map +1 -0
- package/dist/server.d.ts +24 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +501 -0
- package/dist/server.js.map +1 -0
- package/dist/services/approval.d.ts +44 -0
- package/dist/services/approval.d.ts.map +1 -0
- package/dist/services/approval.js +159 -0
- package/dist/services/approval.js.map +1 -0
- package/dist/services/cache.d.ts +21 -0
- package/dist/services/cache.d.ts.map +1 -0
- package/dist/services/cache.js +63 -0
- package/dist/services/cache.js.map +1 -0
- package/dist/services/conversation.d.ts +24 -0
- package/dist/services/conversation.d.ts.map +1 -0
- package/dist/services/conversation.js +108 -0
- package/dist/services/conversation.js.map +1 -0
- package/dist/services/function-bridge.d.ts +41 -0
- package/dist/services/function-bridge.d.ts.map +1 -0
- package/dist/services/function-bridge.js +259 -0
- package/dist/services/function-bridge.js.map +1 -0
- package/dist/services/health.d.ts +17 -0
- package/dist/services/health.d.ts.map +1 -0
- package/dist/services/health.js +77 -0
- package/dist/services/health.js.map +1 -0
- package/dist/services/mcp-client-manager.d.ts +49 -0
- package/dist/services/mcp-client-manager.d.ts.map +1 -0
- package/dist/services/mcp-client-manager.js +279 -0
- package/dist/services/mcp-client-manager.js.map +1 -0
- package/dist/tools/approve-mcp-request.d.ts +9 -0
- package/dist/tools/approve-mcp-request.d.ts.map +1 -0
- package/dist/tools/approve-mcp-request.js +111 -0
- package/dist/tools/approve-mcp-request.js.map +1 -0
- package/dist/tools/ask-duck.d.ts +9 -0
- package/dist/tools/ask-duck.d.ts.map +1 -0
- package/dist/tools/ask-duck.js +43 -0
- package/dist/tools/ask-duck.js.map +1 -0
- package/dist/tools/chat-duck.d.ts +9 -0
- package/dist/tools/chat-duck.d.ts.map +1 -0
- package/dist/tools/chat-duck.js +57 -0
- package/dist/tools/chat-duck.js.map +1 -0
- package/dist/tools/clear-conversations.d.ts +8 -0
- package/dist/tools/clear-conversations.d.ts.map +1 -0
- package/dist/tools/clear-conversations.js +17 -0
- package/dist/tools/clear-conversations.js.map +1 -0
- package/dist/tools/compare-ducks.d.ts +8 -0
- package/dist/tools/compare-ducks.d.ts.map +1 -0
- package/dist/tools/compare-ducks.js +49 -0
- package/dist/tools/compare-ducks.js.map +1 -0
- package/dist/tools/duck-council.d.ts +8 -0
- package/dist/tools/duck-council.d.ts.map +1 -0
- package/dist/tools/duck-council.js +69 -0
- package/dist/tools/duck-council.js.map +1 -0
- package/dist/tools/get-pending-approvals.d.ts +15 -0
- package/dist/tools/get-pending-approvals.d.ts.map +1 -0
- package/dist/tools/get-pending-approvals.js +74 -0
- package/dist/tools/get-pending-approvals.js.map +1 -0
- package/dist/tools/list-ducks.d.ts +9 -0
- package/dist/tools/list-ducks.d.ts.map +1 -0
- package/dist/tools/list-ducks.js +47 -0
- package/dist/tools/list-ducks.js.map +1 -0
- package/dist/tools/list-models.d.ts +8 -0
- package/dist/tools/list-models.d.ts.map +1 -0
- package/dist/tools/list-models.js +72 -0
- package/dist/tools/list-models.js.map +1 -0
- package/dist/tools/mcp-status.d.ts +17 -0
- package/dist/tools/mcp-status.d.ts.map +1 -0
- package/dist/tools/mcp-status.js +100 -0
- package/dist/tools/mcp-status.js.map +1 -0
- package/dist/utils/ascii-art.d.ts +19 -0
- package/dist/utils/ascii-art.d.ts.map +1 -0
- package/dist/utils/ascii-art.js +73 -0
- package/dist/utils/ascii-art.js.map +1 -0
- package/dist/utils/logger.d.ts +3 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +86 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/safe-logger.d.ts +23 -0
- package/dist/utils/safe-logger.d.ts.map +1 -0
- package/dist/utils/safe-logger.js +145 -0
- package/dist/utils/safe-logger.js.map +1 -0
- package/docker-compose.yml +161 -0
- package/jest.config.js +26 -0
- package/package.json +65 -0
- package/scripts/build-multiarch.sh +290 -0
- package/scripts/deploy-raspbian.sh +410 -0
- package/scripts/deploy.sh +322 -0
- package/scripts/gh-deploy.sh +343 -0
- package/scripts/setup-docker-raspbian.sh +530 -0
- package/server.json +8 -0
- package/src/config/config.ts +357 -0
- package/src/config/types.ts +89 -0
- package/src/index.ts +114 -0
- package/src/providers/duck-provider-enhanced.ts +294 -0
- package/src/providers/enhanced-manager.ts +290 -0
- package/src/providers/manager.ts +257 -0
- package/src/providers/provider.ts +207 -0
- package/src/providers/types.ts +78 -0
- package/src/server.ts +603 -0
- package/src/services/approval.ts +225 -0
- package/src/services/cache.ts +79 -0
- package/src/services/conversation.ts +146 -0
- package/src/services/function-bridge.ts +329 -0
- package/src/services/health.ts +107 -0
- package/src/services/mcp-client-manager.ts +362 -0
- package/src/tools/approve-mcp-request.ts +126 -0
- package/src/tools/ask-duck.ts +74 -0
- package/src/tools/chat-duck.ts +82 -0
- package/src/tools/clear-conversations.ts +24 -0
- package/src/tools/compare-ducks.ts +67 -0
- package/src/tools/duck-council.ts +88 -0
- package/src/tools/get-pending-approvals.ts +90 -0
- package/src/tools/list-ducks.ts +65 -0
- package/src/tools/list-models.ts +101 -0
- package/src/tools/mcp-status.ts +117 -0
- package/src/utils/ascii-art.ts +85 -0
- package/src/utils/logger.ts +116 -0
- package/src/utils/safe-logger.ts +165 -0
- package/systemd/mcp-rubber-duck-with-ollama.service +55 -0
- package/systemd/mcp-rubber-duck.service +58 -0
- package/test-functionality.js +147 -0
- package/test-mcp-interface.js +221 -0
- package/tests/ascii-art.test.ts +36 -0
- package/tests/config.test.ts +239 -0
- package/tests/conversation.test.ts +308 -0
- package/tests/mcp-bridge.test.ts +291 -0
- package/tests/providers.test.ts +269 -0
- package/tests/tools/clear-conversations.test.ts +163 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
|
2
|
+
import { ConversationManager } from '../src/services/conversation.js';
|
|
3
|
+
|
|
4
|
+
// Mock logger to avoid console noise during tests
|
|
5
|
+
jest.mock('../src/utils/logger');
|
|
6
|
+
|
|
7
|
+
describe('ConversationManager', () => {
|
|
8
|
+
let conversationManager: ConversationManager;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
conversationManager = new ConversationManager();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('conversation creation and retrieval', () => {
|
|
15
|
+
it('should create a new conversation', () => {
|
|
16
|
+
const conversation = conversationManager.createConversation('test-1', 'openai');
|
|
17
|
+
|
|
18
|
+
expect(conversation.id).toBe('test-1');
|
|
19
|
+
expect(conversation.provider).toBe('openai');
|
|
20
|
+
expect(conversation.messages).toHaveLength(0);
|
|
21
|
+
expect(conversation.createdAt).toBeInstanceOf(Date);
|
|
22
|
+
expect(conversation.updatedAt).toBeInstanceOf(Date);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should retrieve an existing conversation', () => {
|
|
26
|
+
conversationManager.createConversation('test-1', 'openai');
|
|
27
|
+
const retrieved = conversationManager.getConversation('test-1');
|
|
28
|
+
|
|
29
|
+
expect(retrieved).toBeDefined();
|
|
30
|
+
expect(retrieved!.id).toBe('test-1');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should return undefined for non-existent conversation', () => {
|
|
34
|
+
const retrieved = conversationManager.getConversation('nonexistent');
|
|
35
|
+
expect(retrieved).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('message handling', () => {
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
conversationManager.createConversation('test-1', 'openai');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should add messages to conversation', () => {
|
|
45
|
+
const message = {
|
|
46
|
+
role: 'user' as const,
|
|
47
|
+
content: 'Hello duck',
|
|
48
|
+
timestamp: new Date(),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const conversation = conversationManager.addMessage('test-1', message);
|
|
52
|
+
|
|
53
|
+
expect(conversation.messages).toHaveLength(1);
|
|
54
|
+
expect(conversation.messages[0]).toEqual(message);
|
|
55
|
+
expect(conversation.updatedAt).toBeInstanceOf(Date);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should throw error for non-existent conversation when adding message', () => {
|
|
59
|
+
const message = {
|
|
60
|
+
role: 'user' as const,
|
|
61
|
+
content: 'Hello duck',
|
|
62
|
+
timestamp: new Date(),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
expect(() => {
|
|
66
|
+
conversationManager.addMessage('nonexistent', message);
|
|
67
|
+
}).toThrow('Conversation nonexistent not found');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should trim conversations that exceed max size', () => {
|
|
71
|
+
// Add 55 messages (exceeds max of 50)
|
|
72
|
+
for (let i = 0; i < 55; i++) {
|
|
73
|
+
conversationManager.addMessage('test-1', {
|
|
74
|
+
role: 'user' as const,
|
|
75
|
+
content: `Message ${i}`,
|
|
76
|
+
timestamp: new Date(),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const conversation = conversationManager.getConversation('test-1');
|
|
81
|
+
|
|
82
|
+
// Should be trimmed to 50 messages
|
|
83
|
+
expect(conversation!.messages).toHaveLength(50);
|
|
84
|
+
// Should keep the latest messages
|
|
85
|
+
expect(conversation!.messages[0].content).toBe('Message 5');
|
|
86
|
+
expect(conversation!.messages[49].content).toBe('Message 54');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('provider switching', () => {
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
conversationManager.createConversation('test-1', 'openai');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should switch provider and add system message', () => {
|
|
96
|
+
const conversation = conversationManager.switchProvider('test-1', 'groq');
|
|
97
|
+
|
|
98
|
+
expect(conversation.provider).toBe('groq');
|
|
99
|
+
expect(conversation.messages).toHaveLength(1);
|
|
100
|
+
expect(conversation.messages[0].role).toBe('system');
|
|
101
|
+
expect(conversation.messages[0].content).toBe('Switched to groq duck');
|
|
102
|
+
expect(conversation.messages[0].provider).toBe('groq');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should throw error for non-existent conversation when switching provider', () => {
|
|
106
|
+
expect(() => {
|
|
107
|
+
conversationManager.switchProvider('nonexistent', 'groq');
|
|
108
|
+
}).toThrow('Conversation nonexistent not found');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('conversation context', () => {
|
|
113
|
+
beforeEach(() => {
|
|
114
|
+
conversationManager.createConversation('test-1', 'openai');
|
|
115
|
+
|
|
116
|
+
// Add some messages
|
|
117
|
+
for (let i = 0; i < 5; i++) {
|
|
118
|
+
conversationManager.addMessage('test-1', {
|
|
119
|
+
role: 'user' as const,
|
|
120
|
+
content: `Message ${i}`,
|
|
121
|
+
timestamp: new Date(),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should return all messages when no limit specified', () => {
|
|
127
|
+
const context = conversationManager.getConversationContext('test-1');
|
|
128
|
+
expect(context).toHaveLength(5);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should return limited messages when maxMessages specified', () => {
|
|
132
|
+
const context = conversationManager.getConversationContext('test-1', 3);
|
|
133
|
+
expect(context).toHaveLength(3);
|
|
134
|
+
// Should return the last 3 messages
|
|
135
|
+
expect(context[0].content).toBe('Message 2');
|
|
136
|
+
expect(context[2].content).toBe('Message 4');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should return empty array for non-existent conversation', () => {
|
|
140
|
+
const context = conversationManager.getConversationContext('nonexistent');
|
|
141
|
+
expect(context).toEqual([]);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('conversation management', () => {
|
|
146
|
+
it('should list all conversations', () => {
|
|
147
|
+
conversationManager.createConversation('test-1', 'openai');
|
|
148
|
+
conversationManager.createConversation('test-2', 'groq');
|
|
149
|
+
|
|
150
|
+
const list = conversationManager.listConversations();
|
|
151
|
+
expect(list).toHaveLength(2);
|
|
152
|
+
expect(list[0].id).toBe('test-1');
|
|
153
|
+
expect(list[1].id).toBe('test-2');
|
|
154
|
+
expect(list[0].messageCount).toBe(0);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should delete conversation', () => {
|
|
158
|
+
conversationManager.createConversation('test-1', 'openai');
|
|
159
|
+
|
|
160
|
+
const deleted = conversationManager.deleteConversation('test-1');
|
|
161
|
+
expect(deleted).toBe(true);
|
|
162
|
+
|
|
163
|
+
const retrieved = conversationManager.getConversation('test-1');
|
|
164
|
+
expect(retrieved).toBeUndefined();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should return false when deleting non-existent conversation', () => {
|
|
168
|
+
const deleted = conversationManager.deleteConversation('nonexistent');
|
|
169
|
+
expect(deleted).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('clearAll functionality', () => {
|
|
174
|
+
it('should clear empty conversation list', () => {
|
|
175
|
+
const result = conversationManager.clearAll();
|
|
176
|
+
|
|
177
|
+
expect(result.conversationsCleared).toBe(0);
|
|
178
|
+
expect(result.messagesCleared).toBe(0);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should clear single conversation', () => {
|
|
182
|
+
conversationManager.createConversation('test-1', 'openai');
|
|
183
|
+
conversationManager.addMessage('test-1', {
|
|
184
|
+
role: 'user' as const,
|
|
185
|
+
content: 'Hello',
|
|
186
|
+
timestamp: new Date(),
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const result = conversationManager.clearAll();
|
|
190
|
+
|
|
191
|
+
expect(result.conversationsCleared).toBe(1);
|
|
192
|
+
expect(result.messagesCleared).toBe(1);
|
|
193
|
+
|
|
194
|
+
// Verify conversations are actually cleared
|
|
195
|
+
const retrieved = conversationManager.getConversation('test-1');
|
|
196
|
+
expect(retrieved).toBeUndefined();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should clear multiple conversations with correct counts', () => {
|
|
200
|
+
// Create first conversation with 2 messages
|
|
201
|
+
conversationManager.createConversation('test-1', 'openai');
|
|
202
|
+
conversationManager.addMessage('test-1', {
|
|
203
|
+
role: 'user' as const,
|
|
204
|
+
content: 'Hello 1',
|
|
205
|
+
timestamp: new Date(),
|
|
206
|
+
});
|
|
207
|
+
conversationManager.addMessage('test-1', {
|
|
208
|
+
role: 'assistant' as const,
|
|
209
|
+
content: 'Hi 1',
|
|
210
|
+
timestamp: new Date(),
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Create second conversation with 3 messages
|
|
214
|
+
conversationManager.createConversation('test-2', 'groq');
|
|
215
|
+
conversationManager.addMessage('test-2', {
|
|
216
|
+
role: 'user' as const,
|
|
217
|
+
content: 'Hello 2',
|
|
218
|
+
timestamp: new Date(),
|
|
219
|
+
});
|
|
220
|
+
conversationManager.addMessage('test-2', {
|
|
221
|
+
role: 'assistant' as const,
|
|
222
|
+
content: 'Hi 2',
|
|
223
|
+
timestamp: new Date(),
|
|
224
|
+
});
|
|
225
|
+
conversationManager.addMessage('test-2', {
|
|
226
|
+
role: 'user' as const,
|
|
227
|
+
content: 'Follow up',
|
|
228
|
+
timestamp: new Date(),
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const result = conversationManager.clearAll();
|
|
232
|
+
|
|
233
|
+
expect(result.conversationsCleared).toBe(2);
|
|
234
|
+
expect(result.messagesCleared).toBe(5);
|
|
235
|
+
|
|
236
|
+
// Verify all conversations are cleared
|
|
237
|
+
expect(conversationManager.getConversation('test-1')).toBeUndefined();
|
|
238
|
+
expect(conversationManager.getConversation('test-2')).toBeUndefined();
|
|
239
|
+
expect(conversationManager.listConversations()).toHaveLength(0);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('should allow new conversations after clear', () => {
|
|
243
|
+
// Create and clear
|
|
244
|
+
conversationManager.createConversation('test-1', 'openai');
|
|
245
|
+
conversationManager.clearAll();
|
|
246
|
+
|
|
247
|
+
// Create new conversation after clear
|
|
248
|
+
const newConversation = conversationManager.createConversation('test-2', 'groq');
|
|
249
|
+
|
|
250
|
+
expect(newConversation.id).toBe('test-2');
|
|
251
|
+
expect(newConversation.provider).toBe('groq');
|
|
252
|
+
expect(conversationManager.listConversations()).toHaveLength(1);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe('conversation persistence', () => {
|
|
257
|
+
it('should maintain conversation between message additions', () => {
|
|
258
|
+
conversationManager.createConversation('test-1', 'openai');
|
|
259
|
+
|
|
260
|
+
// Add first message
|
|
261
|
+
conversationManager.addMessage('test-1', {
|
|
262
|
+
role: 'user' as const,
|
|
263
|
+
content: 'First message',
|
|
264
|
+
timestamp: new Date(),
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// Add second message
|
|
268
|
+
conversationManager.addMessage('test-1', {
|
|
269
|
+
role: 'assistant' as const,
|
|
270
|
+
content: 'First response',
|
|
271
|
+
timestamp: new Date(),
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const conversation = conversationManager.getConversation('test-1');
|
|
275
|
+
expect(conversation!.messages).toHaveLength(2);
|
|
276
|
+
expect(conversation!.messages[0].content).toBe('First message');
|
|
277
|
+
expect(conversation!.messages[1].content).toBe('First response');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('should handle provider switching without losing messages', () => {
|
|
281
|
+
conversationManager.createConversation('test-1', 'openai');
|
|
282
|
+
|
|
283
|
+
// Add initial message
|
|
284
|
+
conversationManager.addMessage('test-1', {
|
|
285
|
+
role: 'user' as const,
|
|
286
|
+
content: 'Before switch',
|
|
287
|
+
timestamp: new Date(),
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// Switch provider
|
|
291
|
+
conversationManager.switchProvider('test-1', 'groq');
|
|
292
|
+
|
|
293
|
+
// Add message after switch
|
|
294
|
+
conversationManager.addMessage('test-1', {
|
|
295
|
+
role: 'user' as const,
|
|
296
|
+
content: 'After switch',
|
|
297
|
+
timestamp: new Date(),
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
const conversation = conversationManager.getConversation('test-1');
|
|
301
|
+
expect(conversation!.messages).toHaveLength(3);
|
|
302
|
+
expect(conversation!.messages[0].content).toBe('Before switch');
|
|
303
|
+
expect(conversation!.messages[1].content).toBe('Switched to groq duck');
|
|
304
|
+
expect(conversation!.messages[2].content).toBe('After switch');
|
|
305
|
+
expect(conversation!.provider).toBe('groq');
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
});
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { jest } from '@jest/globals';
|
|
2
|
+
import { ApprovalService } from '../src/services/approval';
|
|
3
|
+
import { FunctionBridge } from '../src/services/function-bridge';
|
|
4
|
+
import { MCPClientManager } from '../src/services/mcp-client-manager';
|
|
5
|
+
|
|
6
|
+
describe('MCP Bridge', () => {
|
|
7
|
+
let approvalService: ApprovalService;
|
|
8
|
+
let mcpManager: MCPClientManager;
|
|
9
|
+
let functionBridge: FunctionBridge;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
approvalService = new ApprovalService(300); // 5 minutes
|
|
13
|
+
mcpManager = new MCPClientManager([]); // Empty config for testing
|
|
14
|
+
functionBridge = new FunctionBridge(mcpManager, approvalService, []);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
approvalService.shutdown();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('ApprovalService', () => {
|
|
22
|
+
it('should create approval requests', () => {
|
|
23
|
+
const request = approvalService.createApprovalRequest(
|
|
24
|
+
'TestDuck',
|
|
25
|
+
'filesystem',
|
|
26
|
+
'read_file',
|
|
27
|
+
{ path: '/test.txt' }
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
expect(request).toBeDefined();
|
|
31
|
+
expect(request.duckName).toBe('TestDuck');
|
|
32
|
+
expect(request.mcpServer).toBe('filesystem');
|
|
33
|
+
expect(request.toolName).toBe('read_file');
|
|
34
|
+
expect(request.status).toBe('pending');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should approve pending requests', () => {
|
|
38
|
+
const request = approvalService.createApprovalRequest(
|
|
39
|
+
'TestDuck',
|
|
40
|
+
'filesystem',
|
|
41
|
+
'read_file',
|
|
42
|
+
{ path: '/test.txt' }
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const approved = approvalService.approveRequest(request.id);
|
|
46
|
+
expect(approved).toBe(true);
|
|
47
|
+
|
|
48
|
+
const status = approvalService.getApprovalStatus(request.id);
|
|
49
|
+
expect(status).toBe('approved');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should deny requests', () => {
|
|
53
|
+
const request = approvalService.createApprovalRequest(
|
|
54
|
+
'TestDuck',
|
|
55
|
+
'filesystem',
|
|
56
|
+
'read_file',
|
|
57
|
+
{ path: '/test.txt' }
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const denied = approvalService.denyRequest(request.id, 'Security concern');
|
|
61
|
+
expect(denied).toBe(true);
|
|
62
|
+
|
|
63
|
+
const status = approvalService.getApprovalStatus(request.id);
|
|
64
|
+
expect(status).toBe('denied');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should handle non-existent requests', () => {
|
|
68
|
+
const status = approvalService.getApprovalStatus('non-existent');
|
|
69
|
+
expect(status).toBeUndefined();
|
|
70
|
+
|
|
71
|
+
const approved = approvalService.approveRequest('non-existent');
|
|
72
|
+
expect(approved).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should get pending approvals', () => {
|
|
76
|
+
approvalService.createApprovalRequest('Duck1', 'server1', 'tool1', {});
|
|
77
|
+
approvalService.createApprovalRequest('Duck2', 'server2', 'tool2', {});
|
|
78
|
+
|
|
79
|
+
const pending = approvalService.getPendingApprovals();
|
|
80
|
+
expect(pending).toHaveLength(2);
|
|
81
|
+
expect(pending.every(req => req.status === 'pending')).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('FunctionBridge', () => {
|
|
86
|
+
it('should generate function definitions for empty MCP tools', async () => {
|
|
87
|
+
const functions = await functionBridge.getFunctionDefinitions();
|
|
88
|
+
expect(Array.isArray(functions)).toBe(true);
|
|
89
|
+
// Should be empty since we have no MCP servers configured
|
|
90
|
+
expect(functions).toHaveLength(0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should handle function calls requiring approval', async () => {
|
|
94
|
+
const result = await functionBridge.handleFunctionCall(
|
|
95
|
+
'TestDuck',
|
|
96
|
+
'mcp__filesystem__read_file',
|
|
97
|
+
{ path: '/test.txt', _mcp_server: 'filesystem', _mcp_tool: 'read_file' }
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(result.success).toBe(false);
|
|
101
|
+
expect(result.needsApproval).toBe(true);
|
|
102
|
+
expect(result.approvalId).toBeDefined();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should handle invalid function names', async () => {
|
|
106
|
+
const result = await functionBridge.handleFunctionCall(
|
|
107
|
+
'TestDuck',
|
|
108
|
+
'invalid_function',
|
|
109
|
+
{}
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
expect(result.success).toBe(false);
|
|
113
|
+
expect(result.error).toContain('Invalid function name');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should validate tool arguments', async () => {
|
|
117
|
+
// Mock a tool with schema in the functionBridge
|
|
118
|
+
const mockTool = {
|
|
119
|
+
serverName: 'test_server',
|
|
120
|
+
name: 'test_tool',
|
|
121
|
+
description: 'Test tool',
|
|
122
|
+
inputSchema: {
|
|
123
|
+
type: 'object',
|
|
124
|
+
properties: {
|
|
125
|
+
required_param: { type: 'string' }
|
|
126
|
+
},
|
|
127
|
+
required: ['required_param']
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// Add the tool schema manually for testing
|
|
132
|
+
(functionBridge as any).toolSchemas.set('test_server:test_tool', mockTool.inputSchema);
|
|
133
|
+
|
|
134
|
+
const result = await functionBridge.handleFunctionCall(
|
|
135
|
+
'TestDuck',
|
|
136
|
+
'mcp__test_server__test_tool',
|
|
137
|
+
{
|
|
138
|
+
_mcp_server: 'test_server',
|
|
139
|
+
_mcp_tool: 'test_tool',
|
|
140
|
+
// Missing required_param
|
|
141
|
+
}
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
expect(result.success).toBe(false);
|
|
145
|
+
expect(result.error).toContain('Invalid arguments');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should handle underscored tool names correctly', async () => {
|
|
149
|
+
// Test that tool names with underscores are extracted correctly
|
|
150
|
+
const serverName = (functionBridge as any).extractServerFromFunctionName('mcp__file_system__read_file');
|
|
151
|
+
const toolName = (functionBridge as any).extractToolFromFunctionName('mcp__file_system__read_file');
|
|
152
|
+
|
|
153
|
+
expect(serverName).toBe('file_system');
|
|
154
|
+
expect(toolName).toBe('read_file');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should handle complex server and tool names', async () => {
|
|
158
|
+
// Test more complex names
|
|
159
|
+
const serverName = (functionBridge as any).extractServerFromFunctionName('mcp__complex_server_name__complex_tool_name');
|
|
160
|
+
const toolName = (functionBridge as any).extractToolFromFunctionName('mcp__complex_server_name__complex_tool_name');
|
|
161
|
+
|
|
162
|
+
expect(serverName).toBe('complex_server_name');
|
|
163
|
+
expect(toolName).toBe('complex_tool_name');
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('MCPClientManager', () => {
|
|
168
|
+
it('should initialize with empty config', async () => {
|
|
169
|
+
expect(mcpManager.getConnectedServers()).toEqual([]);
|
|
170
|
+
expect(mcpManager.getConnectionStatus('nonexistent')).toBe('unknown');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should handle health check with no servers', async () => {
|
|
174
|
+
const health = await mcpManager.healthCheck();
|
|
175
|
+
expect(health).toEqual({});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should get status of all servers', () => {
|
|
179
|
+
const status = mcpManager.getStatus();
|
|
180
|
+
expect(typeof status).toBe('object');
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('Integration Tests', () => {
|
|
185
|
+
it('should handle complete approval workflow', async () => {
|
|
186
|
+
// Create approval request
|
|
187
|
+
const request = approvalService.createApprovalRequest(
|
|
188
|
+
'TestDuck',
|
|
189
|
+
'filesystem',
|
|
190
|
+
'read_file',
|
|
191
|
+
{ path: '/test.txt' }
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
expect(request.status).toBe('pending');
|
|
195
|
+
|
|
196
|
+
// Try function call without approval
|
|
197
|
+
const result1 = await functionBridge.handleFunctionCall(
|
|
198
|
+
'TestDuck',
|
|
199
|
+
'mcp__filesystem__read_file',
|
|
200
|
+
{
|
|
201
|
+
path: '/test.txt',
|
|
202
|
+
_mcp_server: 'filesystem',
|
|
203
|
+
_mcp_tool: 'read_file'
|
|
204
|
+
}
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
expect(result1.success).toBe(false);
|
|
208
|
+
expect(result1.needsApproval).toBe(true);
|
|
209
|
+
|
|
210
|
+
// Approve the request
|
|
211
|
+
const approved = approvalService.approveRequest(request.id);
|
|
212
|
+
expect(approved).toBe(true);
|
|
213
|
+
|
|
214
|
+
// Try function call with approval (would still fail due to no MCP server, but approval logic works)
|
|
215
|
+
const result2 = await functionBridge.handleFunctionCall(
|
|
216
|
+
'TestDuck',
|
|
217
|
+
'mcp__filesystem__read_file',
|
|
218
|
+
{
|
|
219
|
+
path: '/test.txt',
|
|
220
|
+
_mcp_server: 'filesystem',
|
|
221
|
+
_mcp_tool: 'read_file',
|
|
222
|
+
_approval_id: request.id
|
|
223
|
+
}
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
// Should pass approval but fail on MCP execution
|
|
227
|
+
expect(result2.error).toContain('MCP server filesystem not connected');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('should handle expired approvals', (done) => {
|
|
231
|
+
// Create approval service with very short timeout
|
|
232
|
+
const shortApprovalService = new ApprovalService(1); // 1 second
|
|
233
|
+
|
|
234
|
+
const request = shortApprovalService.createApprovalRequest(
|
|
235
|
+
'TestDuck',
|
|
236
|
+
'filesystem',
|
|
237
|
+
'read_file',
|
|
238
|
+
{ path: '/test.txt' }
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
// Wait for expiration
|
|
242
|
+
setTimeout(() => {
|
|
243
|
+
const status = shortApprovalService.getApprovalStatus(request.id);
|
|
244
|
+
expect(status).toBe('expired');
|
|
245
|
+
|
|
246
|
+
// Try to approve expired request
|
|
247
|
+
const approved = shortApprovalService.approveRequest(request.id);
|
|
248
|
+
expect(approved).toBe(false);
|
|
249
|
+
|
|
250
|
+
shortApprovalService.shutdown();
|
|
251
|
+
done();
|
|
252
|
+
}, 1100);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('should handle function definition generation', async () => {
|
|
256
|
+
// Mock MCP tools
|
|
257
|
+
const mockTools = [
|
|
258
|
+
{
|
|
259
|
+
serverName: 'filesystem',
|
|
260
|
+
name: 'read_file',
|
|
261
|
+
description: 'Read a file',
|
|
262
|
+
inputSchema: {
|
|
263
|
+
type: 'object',
|
|
264
|
+
properties: { path: { type: 'string' } },
|
|
265
|
+
required: ['path']
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
serverName: 'web',
|
|
270
|
+
name: 'fetch_url',
|
|
271
|
+
description: 'Fetch URL content',
|
|
272
|
+
inputSchema: {
|
|
273
|
+
type: 'object',
|
|
274
|
+
properties: { url: { type: 'string' } },
|
|
275
|
+
required: ['url']
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
];
|
|
279
|
+
|
|
280
|
+
// Mock the listAllTools method
|
|
281
|
+
jest.spyOn(mcpManager, 'listAllTools').mockResolvedValue(mockTools);
|
|
282
|
+
|
|
283
|
+
const functions = await functionBridge.getFunctionDefinitions();
|
|
284
|
+
|
|
285
|
+
expect(functions).toHaveLength(2);
|
|
286
|
+
expect(functions[0].name).toBe('mcp__filesystem__read_file');
|
|
287
|
+
expect(functions[1].name).toBe('mcp__web__fetch_url');
|
|
288
|
+
expect(functions[0].description).toBe('[filesystem] Read a file');
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
});
|