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,362 @@
|
|
|
1
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
2
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
3
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
4
|
+
import { logger } from '../utils/logger.js';
|
|
5
|
+
import { SafeLogger } from '../utils/safe-logger.js';
|
|
6
|
+
|
|
7
|
+
export interface MCPServerConfig {
|
|
8
|
+
name: string;
|
|
9
|
+
type: 'stdio' | 'http';
|
|
10
|
+
command?: string; // for stdio
|
|
11
|
+
args?: string[]; // for stdio
|
|
12
|
+
url?: string; // for http
|
|
13
|
+
apiKey?: string; // for http auth
|
|
14
|
+
enabled?: boolean;
|
|
15
|
+
retryAttempts?: number; // Number of retry attempts (default: 3)
|
|
16
|
+
retryDelay?: number; // Initial retry delay in ms (default: 1000)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface MCPTool {
|
|
20
|
+
serverName: string;
|
|
21
|
+
name: string;
|
|
22
|
+
description: string;
|
|
23
|
+
inputSchema: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class MCPClientManager {
|
|
27
|
+
private clients: Map<string, Client> = new Map();
|
|
28
|
+
private configs: MCPServerConfig[] = [];
|
|
29
|
+
private connectionStatus: Map<string, 'connected' | 'connecting' | 'disconnected' | 'error'> = new Map();
|
|
30
|
+
private retryInfo: Map<string, { attempts: number; lastAttempt: number }> = new Map();
|
|
31
|
+
|
|
32
|
+
constructor(configs: MCPServerConfig[] = []) {
|
|
33
|
+
this.configs = configs.filter(config => config.enabled !== false);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async initialize(): Promise<void> {
|
|
37
|
+
logger.info(`Initializing MCP Client Manager with ${this.configs.length} servers`);
|
|
38
|
+
|
|
39
|
+
const connectionPromises = this.configs.map(config => this.connectToServer(config));
|
|
40
|
+
|
|
41
|
+
// Connect to all servers in parallel, but don't fail if some fail
|
|
42
|
+
const results = await Promise.allSettled(connectionPromises);
|
|
43
|
+
|
|
44
|
+
let successCount = 0;
|
|
45
|
+
results.forEach((result, index) => {
|
|
46
|
+
const serverName = this.configs[index].name;
|
|
47
|
+
if (result.status === 'fulfilled') {
|
|
48
|
+
successCount++;
|
|
49
|
+
logger.info(`Successfully connected to MCP server: ${serverName}`);
|
|
50
|
+
} else {
|
|
51
|
+
logger.error(`Failed to connect to MCP server ${serverName}:`, result.reason);
|
|
52
|
+
this.connectionStatus.set(serverName, 'error');
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
logger.info(`MCP Client Manager initialized: ${successCount}/${this.configs.length} servers connected`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private async connectToServer(config: MCPServerConfig): Promise<Client> {
|
|
60
|
+
const { name, type } = config;
|
|
61
|
+
const maxRetries = config.retryAttempts ?? 3;
|
|
62
|
+
const initialDelay = config.retryDelay ?? 1000;
|
|
63
|
+
|
|
64
|
+
if (this.clients.has(name)) {
|
|
65
|
+
logger.warn(`MCP server ${name} already connected`);
|
|
66
|
+
return this.clients.get(name)!;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
70
|
+
try {
|
|
71
|
+
this.connectionStatus.set(name, 'connecting');
|
|
72
|
+
this.retryInfo.set(name, { attempts: attempt, lastAttempt: Date.now() });
|
|
73
|
+
|
|
74
|
+
if (attempt > 0) {
|
|
75
|
+
const delay = initialDelay * Math.pow(2, attempt - 1); // Exponential backoff
|
|
76
|
+
logger.info(`Retrying connection to MCP server ${name} (attempt ${attempt}/${maxRetries}) after ${delay}ms`);
|
|
77
|
+
await this.sleep(delay);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Create client
|
|
81
|
+
const client = new Client({
|
|
82
|
+
name: 'mcp-rubber-duck',
|
|
83
|
+
version: '1.0.0',
|
|
84
|
+
}, {
|
|
85
|
+
capabilities: {
|
|
86
|
+
tools: {},
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Create transport based on type
|
|
91
|
+
let transport;
|
|
92
|
+
if (type === 'stdio') {
|
|
93
|
+
if (!config.command) {
|
|
94
|
+
throw new Error(`stdio server ${name} requires command`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
transport = new StdioClientTransport({
|
|
98
|
+
command: config.command,
|
|
99
|
+
args: config.args || [],
|
|
100
|
+
});
|
|
101
|
+
} else if (type === 'http') {
|
|
102
|
+
if (!config.url) {
|
|
103
|
+
throw new Error(`http server ${name} requires url`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// HTTP transport using StreamableHTTPClientTransport
|
|
107
|
+
const url = new URL(config.url);
|
|
108
|
+
const transportOptions: { requestInit?: { headers?: Record<string, string> } } = {};
|
|
109
|
+
|
|
110
|
+
if (config.apiKey) {
|
|
111
|
+
transportOptions.requestInit = {
|
|
112
|
+
headers: {
|
|
113
|
+
'Authorization': `Bearer ${config.apiKey}`,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
transport = new StreamableHTTPClientTransport(url, transportOptions);
|
|
119
|
+
} else {
|
|
120
|
+
throw new Error(`Unsupported transport type: ${String(type)}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Connect to server with timeout
|
|
124
|
+
const connectTimeout = new Promise((_, reject) =>
|
|
125
|
+
setTimeout(() => reject(new Error('Connection timeout')), 30000)
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
await Promise.race([
|
|
129
|
+
client.connect(transport),
|
|
130
|
+
connectTimeout
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
this.clients.set(name, client);
|
|
134
|
+
this.connectionStatus.set(name, 'connected');
|
|
135
|
+
this.retryInfo.delete(name); // Clear retry info on success
|
|
136
|
+
|
|
137
|
+
logger.info(`Connected to MCP server: ${name} (${type}) after ${attempt} retries`);
|
|
138
|
+
return client;
|
|
139
|
+
|
|
140
|
+
} catch (error: unknown) {
|
|
141
|
+
logger.warn(`Failed to connect to MCP server ${name} (attempt ${attempt + 1}/${maxRetries + 1}):`, error instanceof Error ? error.message : String(error));
|
|
142
|
+
|
|
143
|
+
if (attempt === maxRetries) {
|
|
144
|
+
this.connectionStatus.set(name, 'error');
|
|
145
|
+
this.retryInfo.set(name, { attempts: attempt + 1, lastAttempt: Date.now() });
|
|
146
|
+
logger.error(`All retry attempts exhausted for MCP server ${name}`);
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
throw new Error(`Failed to connect to MCP server ${name} after ${maxRetries + 1} attempts`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private sleep(ms: number): Promise<void> {
|
|
156
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async disconnectAll(): Promise<void> {
|
|
160
|
+
logger.info('Disconnecting all MCP clients');
|
|
161
|
+
|
|
162
|
+
const disconnectPromises = Array.from(this.clients.entries()).map(async ([name, client]) => {
|
|
163
|
+
try {
|
|
164
|
+
await client.close();
|
|
165
|
+
this.connectionStatus.set(name, 'disconnected');
|
|
166
|
+
logger.info(`Disconnected from MCP server: ${name}`);
|
|
167
|
+
} catch (error: unknown) {
|
|
168
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
169
|
+
logger.error(`Error disconnecting from MCP server ${name}:`, errorMessage);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await Promise.allSettled(disconnectPromises);
|
|
174
|
+
|
|
175
|
+
this.clients.clear();
|
|
176
|
+
logger.info('All MCP clients disconnected');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
getClient(serverName: string): Client | undefined {
|
|
180
|
+
const client = this.clients.get(serverName);
|
|
181
|
+
const status = this.connectionStatus.get(serverName);
|
|
182
|
+
|
|
183
|
+
if (!client || status !== 'connected') {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return client;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
getConnectionStatus(serverName: string): 'connected' | 'connecting' | 'disconnected' | 'error' | 'unknown' {
|
|
191
|
+
return this.connectionStatus.get(serverName) || 'unknown';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
getConnectedServers(): string[] {
|
|
195
|
+
return Array.from(this.clients.keys()).filter(name =>
|
|
196
|
+
this.connectionStatus.get(name) === 'connected'
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async listAllTools(): Promise<MCPTool[]> {
|
|
201
|
+
const allTools: MCPTool[] = [];
|
|
202
|
+
|
|
203
|
+
for (const [serverName, client] of this.clients.entries()) {
|
|
204
|
+
if (this.connectionStatus.get(serverName) !== 'connected') {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const toolsResult = await client.listTools();
|
|
210
|
+
|
|
211
|
+
if (toolsResult.tools) {
|
|
212
|
+
const serverTools = toolsResult.tools.map(tool => ({
|
|
213
|
+
serverName,
|
|
214
|
+
name: tool.name,
|
|
215
|
+
description: tool.description || '',
|
|
216
|
+
inputSchema: tool.inputSchema,
|
|
217
|
+
}));
|
|
218
|
+
|
|
219
|
+
allTools.push(...serverTools);
|
|
220
|
+
logger.debug(`Listed ${serverTools.length} tools from ${serverName}`);
|
|
221
|
+
}
|
|
222
|
+
} catch (error: unknown) {
|
|
223
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
224
|
+
logger.error(`Failed to list tools from ${serverName}:`, errorMessage);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
logger.debug(`Total MCP tools available: ${allTools.length}`);
|
|
229
|
+
return allTools;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async listServerTools(serverName: string): Promise<MCPTool[]> {
|
|
233
|
+
const client = this.getClient(serverName);
|
|
234
|
+
if (!client) {
|
|
235
|
+
throw new Error(`MCP server ${serverName} not connected`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
const toolsResult = await client.listTools();
|
|
240
|
+
|
|
241
|
+
if (!toolsResult.tools) {
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return toolsResult.tools.map(tool => ({
|
|
246
|
+
serverName,
|
|
247
|
+
name: tool.name,
|
|
248
|
+
description: tool.description || '',
|
|
249
|
+
inputSchema: tool.inputSchema,
|
|
250
|
+
}));
|
|
251
|
+
} catch (error: unknown) {
|
|
252
|
+
logger.error(`Failed to list tools from ${serverName}:`, error instanceof Error ? error.message : String(error));
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async callTool(serverName: string, toolName: string, args: Record<string, unknown>): Promise<unknown> {
|
|
258
|
+
const client = this.getClient(serverName);
|
|
259
|
+
if (!client) {
|
|
260
|
+
throw new Error(`MCP server ${serverName} not connected`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
SafeLogger.debug(`Calling MCP tool ${serverName}:${toolName} with args:`, args);
|
|
265
|
+
|
|
266
|
+
const result = await client.callTool({
|
|
267
|
+
name: toolName,
|
|
268
|
+
arguments: args,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
SafeLogger.debug(`MCP tool ${serverName}:${toolName} returned:`, result);
|
|
272
|
+
return result;
|
|
273
|
+
|
|
274
|
+
} catch (error: unknown) {
|
|
275
|
+
logger.error(`Failed to call MCP tool ${serverName}:${toolName}:`, error instanceof Error ? error.message : String(error));
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Health check for all connected servers
|
|
281
|
+
async healthCheck(): Promise<Record<string, boolean>> {
|
|
282
|
+
const healthStatus: Record<string, boolean> = {};
|
|
283
|
+
|
|
284
|
+
for (const [serverName, client] of this.clients.entries()) {
|
|
285
|
+
try {
|
|
286
|
+
// Try to list tools as a health check
|
|
287
|
+
await client.listTools();
|
|
288
|
+
healthStatus[serverName] = true;
|
|
289
|
+
this.connectionStatus.set(serverName, 'connected');
|
|
290
|
+
} catch (error: unknown) {
|
|
291
|
+
healthStatus[serverName] = false;
|
|
292
|
+
this.connectionStatus.set(serverName, 'error');
|
|
293
|
+
logger.warn(`Health check failed for MCP server ${serverName}:`, error instanceof Error ? error.message : String(error));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return healthStatus;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Get status of all servers
|
|
301
|
+
getStatus(): Record<string, {
|
|
302
|
+
status: string;
|
|
303
|
+
type: string;
|
|
304
|
+
toolCount?: number;
|
|
305
|
+
retryAttempts?: number;
|
|
306
|
+
lastAttempt?: number;
|
|
307
|
+
}> {
|
|
308
|
+
const status: Record<string, {
|
|
309
|
+
status: string;
|
|
310
|
+
type: string;
|
|
311
|
+
retryAttempts?: number;
|
|
312
|
+
lastAttempt?: number;
|
|
313
|
+
}> = {};
|
|
314
|
+
|
|
315
|
+
this.configs.forEach(config => {
|
|
316
|
+
const retryInfo = this.retryInfo.get(config.name);
|
|
317
|
+
status[config.name] = {
|
|
318
|
+
status: this.connectionStatus.get(config.name) || 'unknown',
|
|
319
|
+
type: config.type,
|
|
320
|
+
...(retryInfo && {
|
|
321
|
+
retryAttempts: retryInfo.attempts,
|
|
322
|
+
lastAttempt: retryInfo.lastAttempt,
|
|
323
|
+
}),
|
|
324
|
+
};
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
return status;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Get retry information for a specific server
|
|
331
|
+
getRetryInfo(serverName: string): { attempts: number; lastAttempt: number } | undefined {
|
|
332
|
+
return this.retryInfo.get(serverName);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Retry connection for a specific server
|
|
336
|
+
async retryConnection(serverName: string): Promise<boolean> {
|
|
337
|
+
const config = this.configs.find(c => c.name === serverName);
|
|
338
|
+
if (!config) {
|
|
339
|
+
logger.error(`Server config not found for ${serverName}`);
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
// Disconnect first if connected
|
|
345
|
+
const existingClient = this.clients.get(serverName);
|
|
346
|
+
if (existingClient) {
|
|
347
|
+
await existingClient.close();
|
|
348
|
+
this.clients.delete(serverName);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Reset retry info
|
|
352
|
+
this.retryInfo.delete(serverName);
|
|
353
|
+
|
|
354
|
+
// Attempt to connect
|
|
355
|
+
await this.connectToServer(config);
|
|
356
|
+
return true;
|
|
357
|
+
} catch (error: unknown) {
|
|
358
|
+
logger.error(`Manual retry failed for ${serverName}:`, error instanceof Error ? error.message : String(error));
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { ApprovalService } from '../services/approval.js';
|
|
2
|
+
|
|
3
|
+
export function approveMCPRequestTool(
|
|
4
|
+
approvalService: ApprovalService,
|
|
5
|
+
args: Record<string, unknown>
|
|
6
|
+
) {
|
|
7
|
+
const { approval_id, decision, reason } = args as {
|
|
8
|
+
approval_id?: string;
|
|
9
|
+
decision?: string;
|
|
10
|
+
reason?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
if (!approval_id || !decision) {
|
|
14
|
+
return {
|
|
15
|
+
content: [
|
|
16
|
+
{
|
|
17
|
+
type: 'text',
|
|
18
|
+
text: '❌ Missing required parameters: approval_id and decision are required',
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
isError: true,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!['approve', 'deny'].includes(decision)) {
|
|
26
|
+
return {
|
|
27
|
+
content: [
|
|
28
|
+
{
|
|
29
|
+
type: 'text',
|
|
30
|
+
text: '❌ Decision must be either "approve" or "deny"',
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
isError: true,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
// Get the request details before processing
|
|
39
|
+
const request = approvalService.getApprovalRequest(approval_id);
|
|
40
|
+
|
|
41
|
+
if (!request) {
|
|
42
|
+
return {
|
|
43
|
+
content: [
|
|
44
|
+
{
|
|
45
|
+
type: 'text',
|
|
46
|
+
text: `❌ Approval request ${approval_id} not found`,
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
isError: true,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (request.status !== 'pending') {
|
|
54
|
+
return {
|
|
55
|
+
content: [
|
|
56
|
+
{
|
|
57
|
+
type: 'text',
|
|
58
|
+
text: `❌ Request ${approval_id} is not pending (status: ${request.status})`,
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
isError: true,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let success = false;
|
|
66
|
+
let message = '';
|
|
67
|
+
|
|
68
|
+
if (decision === 'approve') {
|
|
69
|
+
success = approvalService.approveRequest(approval_id);
|
|
70
|
+
if (success) {
|
|
71
|
+
message = `✅ Approved: ${request.duckName} can now call ${request.mcpServer}:${request.toolName}`;
|
|
72
|
+
} else {
|
|
73
|
+
message = `❌ Failed to approve request ${approval_id}`;
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
success = approvalService.denyRequest(approval_id, reason);
|
|
77
|
+
if (success) {
|
|
78
|
+
message = `❌ Denied: ${request.duckName} cannot call ${request.mcpServer}:${request.toolName}`;
|
|
79
|
+
if (reason) {
|
|
80
|
+
message += `\nReason: ${reason}`;
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
message = `❌ Failed to deny request ${approval_id}`;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Add request details for context
|
|
88
|
+
const detailsLines = [
|
|
89
|
+
message,
|
|
90
|
+
'',
|
|
91
|
+
'📋 **Request Details:**',
|
|
92
|
+
`- Duck: ${request.duckName}`,
|
|
93
|
+
`- MCP Server: ${request.mcpServer}`,
|
|
94
|
+
`- Tool: ${request.toolName}`,
|
|
95
|
+
`- Arguments: \`${JSON.stringify(request.arguments)}\``,
|
|
96
|
+
`- Request ID: \`${request.id}\``,
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
// Add next steps
|
|
100
|
+
if (decision === 'approve') {
|
|
101
|
+
detailsLines.push('');
|
|
102
|
+
detailsLines.push('💡 The duck can now retry their request with the approval.');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
content: [
|
|
107
|
+
{
|
|
108
|
+
type: 'text',
|
|
109
|
+
text: detailsLines.join('\n'),
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
isError: !success,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
} catch (error: unknown) {
|
|
116
|
+
return {
|
|
117
|
+
content: [
|
|
118
|
+
{
|
|
119
|
+
type: 'text',
|
|
120
|
+
text: `❌ Error processing approval: ${error instanceof Error ? error.message : String(error)}`,
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
isError: true,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { ProviderManager } from '../providers/manager.js';
|
|
2
|
+
import { ResponseCache } from '../services/cache.js';
|
|
3
|
+
import { formatDuckResponse } from '../utils/ascii-art.js';
|
|
4
|
+
import { logger } from '../utils/logger.js';
|
|
5
|
+
|
|
6
|
+
export async function askDuckTool(
|
|
7
|
+
providerManager: ProviderManager,
|
|
8
|
+
cache: ResponseCache,
|
|
9
|
+
args: Record<string, unknown>
|
|
10
|
+
) {
|
|
11
|
+
const { prompt, provider, model, temperature } = args as {
|
|
12
|
+
prompt?: string;
|
|
13
|
+
provider?: string;
|
|
14
|
+
model?: string;
|
|
15
|
+
temperature?: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
if (!prompt) {
|
|
19
|
+
throw new Error('Prompt is required');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Validate model if provided
|
|
23
|
+
if (model && provider) {
|
|
24
|
+
const isValid = providerManager.validateModel(provider, model);
|
|
25
|
+
if (!isValid) {
|
|
26
|
+
logger.warn(`Model ${model} may not be valid for provider ${provider}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Generate cache key
|
|
31
|
+
const cacheKey = cache.generateKey(
|
|
32
|
+
provider || 'default',
|
|
33
|
+
prompt,
|
|
34
|
+
{ model, temperature }
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
// Try to get cached response
|
|
38
|
+
const { value: response, cached } = await cache.getOrSet(
|
|
39
|
+
cacheKey,
|
|
40
|
+
async () => {
|
|
41
|
+
return await providerManager.askDuck(provider, prompt, {
|
|
42
|
+
model,
|
|
43
|
+
temperature,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// Format the response
|
|
49
|
+
const formattedResponse = formatDuckResponse(
|
|
50
|
+
response.nickname,
|
|
51
|
+
response.content,
|
|
52
|
+
response.model
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
// Add usage info if available
|
|
56
|
+
let usageInfo = '';
|
|
57
|
+
if (response.usage) {
|
|
58
|
+
usageInfo = `\n\n📊 Tokens used: ${response.usage.total_tokens} (${response.usage.prompt_tokens} prompt, ${response.usage.completion_tokens} completion)`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Add cache and latency info
|
|
62
|
+
const metaInfo = `\n⏱️ Latency: ${response.latency}ms | ${cached ? '💾 Cached' : '🔄 Fresh'}`;
|
|
63
|
+
|
|
64
|
+
logger.info(`Duck ${response.nickname} responded to query ${cached ? '(cached)' : ''}`);
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
content: [
|
|
68
|
+
{
|
|
69
|
+
type: 'text',
|
|
70
|
+
text: formattedResponse + usageInfo + metaInfo,
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { ProviderManager } from '../providers/manager.js';
|
|
2
|
+
import { ConversationManager } from '../services/conversation.js';
|
|
3
|
+
import { formatDuckResponse } from '../utils/ascii-art.js';
|
|
4
|
+
import { logger } from '../utils/logger.js';
|
|
5
|
+
|
|
6
|
+
export async function chatDuckTool(
|
|
7
|
+
providerManager: ProviderManager,
|
|
8
|
+
conversationManager: ConversationManager,
|
|
9
|
+
args: Record<string, unknown>
|
|
10
|
+
) {
|
|
11
|
+
const { conversation_id, message, provider, model } = args as {
|
|
12
|
+
conversation_id?: string;
|
|
13
|
+
message?: string;
|
|
14
|
+
provider?: string;
|
|
15
|
+
model?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
if (!conversation_id || !message) {
|
|
19
|
+
throw new Error('conversation_id and message are required');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Get or create conversation
|
|
23
|
+
let conversation = conversationManager.getConversation(conversation_id);
|
|
24
|
+
|
|
25
|
+
if (!conversation) {
|
|
26
|
+
// Create new conversation with specified or default provider
|
|
27
|
+
const providerName = provider || providerManager.getProviderNames()[0];
|
|
28
|
+
conversation = conversationManager.createConversation(conversation_id, providerName);
|
|
29
|
+
logger.info(`Created new conversation: ${conversation_id} with ${providerName}`);
|
|
30
|
+
} else if (provider && provider !== conversation.provider) {
|
|
31
|
+
// Switch provider if requested
|
|
32
|
+
conversation = conversationManager.switchProvider(conversation_id, provider);
|
|
33
|
+
logger.info(`Switched conversation ${conversation_id} to ${provider}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Add user message to conversation
|
|
37
|
+
conversationManager.addMessage(conversation_id, {
|
|
38
|
+
role: 'user',
|
|
39
|
+
content: message,
|
|
40
|
+
timestamp: new Date(),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Get conversation context
|
|
44
|
+
const messages = conversationManager.getConversationContext(conversation_id);
|
|
45
|
+
|
|
46
|
+
// Get response from provider
|
|
47
|
+
const providerToUse = provider || conversation.provider;
|
|
48
|
+
const response = await providerManager.askDuck(providerToUse, '', {
|
|
49
|
+
messages,
|
|
50
|
+
model,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Add assistant response to conversation
|
|
54
|
+
conversationManager.addMessage(conversation_id, {
|
|
55
|
+
role: 'assistant',
|
|
56
|
+
content: response.content,
|
|
57
|
+
timestamp: new Date(),
|
|
58
|
+
provider: providerToUse,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Format response
|
|
62
|
+
const formattedResponse = formatDuckResponse(
|
|
63
|
+
response.nickname,
|
|
64
|
+
response.content,
|
|
65
|
+
response.model
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// Add conversation info
|
|
69
|
+
const conversationInfo = `\n\n💬 Conversation: ${conversation_id} | Messages: ${messages.length + 1}`;
|
|
70
|
+
const latencyInfo = `\n⏱️ Latency: ${response.latency}ms`;
|
|
71
|
+
|
|
72
|
+
logger.info(`Duck ${response.nickname} responded in conversation ${conversation_id}`);
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
content: [
|
|
76
|
+
{
|
|
77
|
+
type: 'text',
|
|
78
|
+
text: formattedResponse + conversationInfo + latencyInfo,
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ConversationManager } from '../services/conversation.js';
|
|
2
|
+
import { logger } from '../utils/logger.js';
|
|
3
|
+
|
|
4
|
+
export function clearConversationsTool(
|
|
5
|
+
conversationManager: ConversationManager,
|
|
6
|
+
_args: Record<string, unknown>
|
|
7
|
+
) {
|
|
8
|
+
const result = conversationManager.clearAll();
|
|
9
|
+
|
|
10
|
+
logger.info(`User cleared ${result.conversationsCleared} conversations`);
|
|
11
|
+
|
|
12
|
+
const message = result.conversationsCleared === 0
|
|
13
|
+
? '🧹 No conversations to clear - memory is already empty!'
|
|
14
|
+
: `🧹 Cleared ${result.conversationsCleared} conversation${result.conversationsCleared === 1 ? '' : 's'} (${result.messagesCleared} message${result.messagesCleared === 1 ? '' : 's'})`;
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
content: [
|
|
18
|
+
{
|
|
19
|
+
type: 'text',
|
|
20
|
+
text: `${message}\n\n🦆 All ducks now have a fresh start! Previous conversation context has been removed.`,
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
};
|
|
24
|
+
}
|