@qwickapps/server 1.5.1 → 1.6.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/CHANGELOG.md +43 -0
- package/dist/core/control-panel.d.ts.map +1 -1
- package/dist/core/control-panel.js +41 -0
- package/dist/core/control-panel.js.map +1 -1
- package/dist/core/guards.d.ts.map +1 -1
- package/dist/core/guards.js +77 -0
- package/dist/core/guards.js.map +1 -1
- package/dist/core/health-manager.d.ts +4 -0
- package/dist/core/health-manager.d.ts.map +1 -1
- package/dist/core/health-manager.js +6 -1
- package/dist/core/health-manager.js.map +1 -1
- package/dist/core/plugin-registry.d.ts +55 -5
- package/dist/core/plugin-registry.d.ts.map +1 -1
- package/dist/core/plugin-registry.js +57 -19
- package/dist/core/plugin-registry.js.map +1 -1
- package/dist/core/types.d.ts +2 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/plugins/api-keys/api-keys-plugin.d.ts +46 -0
- package/dist/plugins/api-keys/api-keys-plugin.d.ts.map +1 -0
- package/dist/plugins/api-keys/api-keys-plugin.js +329 -0
- package/dist/plugins/api-keys/api-keys-plugin.js.map +1 -0
- package/dist/plugins/api-keys/index.d.ts +14 -0
- package/dist/plugins/api-keys/index.d.ts.map +1 -0
- package/dist/plugins/api-keys/index.js +17 -0
- package/dist/plugins/api-keys/index.js.map +1 -0
- package/dist/plugins/api-keys/middleware/bearer-token-auth.d.ts +74 -0
- package/dist/plugins/api-keys/middleware/bearer-token-auth.d.ts.map +1 -0
- package/dist/plugins/api-keys/middleware/bearer-token-auth.js +201 -0
- package/dist/plugins/api-keys/middleware/bearer-token-auth.js.map +1 -0
- package/dist/plugins/api-keys/middleware/index.d.ts +7 -0
- package/dist/plugins/api-keys/middleware/index.d.ts.map +1 -0
- package/dist/plugins/api-keys/middleware/index.js +7 -0
- package/dist/plugins/api-keys/middleware/index.js.map +1 -0
- package/dist/plugins/api-keys/stores/index.d.ts +7 -0
- package/dist/plugins/api-keys/stores/index.d.ts.map +1 -0
- package/dist/plugins/api-keys/stores/index.js +7 -0
- package/dist/plugins/api-keys/stores/index.js.map +1 -0
- package/dist/plugins/api-keys/stores/postgres-store.d.ts +34 -0
- package/dist/plugins/api-keys/stores/postgres-store.d.ts.map +1 -0
- package/dist/plugins/api-keys/stores/postgres-store.js +360 -0
- package/dist/plugins/api-keys/stores/postgres-store.js.map +1 -0
- package/dist/plugins/api-keys/types.d.ts +268 -0
- package/dist/plugins/api-keys/types.d.ts.map +1 -0
- package/dist/plugins/api-keys/types.js +56 -0
- package/dist/plugins/api-keys/types.js.map +1 -0
- package/dist/plugins/auth/auth-plugin.d.ts.map +1 -1
- package/dist/plugins/auth/auth-plugin.js +17 -1
- package/dist/plugins/auth/auth-plugin.js.map +1 -1
- package/dist/plugins/auth/auth-plugin.test.js +133 -0
- package/dist/plugins/auth/auth-plugin.test.js.map +1 -1
- package/dist/plugins/auth/env-config.d.ts.map +1 -1
- package/dist/plugins/auth/env-config.js +6 -2
- package/dist/plugins/auth/env-config.js.map +1 -1
- package/dist/plugins/auth/types.d.ts +10 -0
- package/dist/plugins/auth/types.d.ts.map +1 -1
- package/dist/plugins/auth/types.js.map +1 -1
- package/dist/plugins/devices/__tests__/token-utils.test.js +4 -2
- package/dist/plugins/devices/__tests__/token-utils.test.js.map +1 -1
- package/dist/plugins/frontend-app-plugin.d.ts.map +1 -1
- package/dist/plugins/frontend-app-plugin.js +21 -4
- package/dist/plugins/frontend-app-plugin.js.map +1 -1
- package/dist/plugins/index.d.ts +2 -0
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +2 -0
- package/dist/plugins/index.js.map +1 -1
- package/dist/plugins/qwickbrain/index.d.ts +25 -0
- package/dist/plugins/qwickbrain/index.d.ts.map +1 -0
- package/dist/plugins/qwickbrain/index.js +24 -0
- package/dist/plugins/qwickbrain/index.js.map +1 -0
- package/dist/plugins/qwickbrain/qwickbrain-plugin.d.ts +23 -0
- package/dist/plugins/qwickbrain/qwickbrain-plugin.d.ts.map +1 -0
- package/dist/plugins/qwickbrain/qwickbrain-plugin.js +528 -0
- package/dist/plugins/qwickbrain/qwickbrain-plugin.js.map +1 -0
- package/dist/plugins/qwickbrain/types.d.ts +131 -0
- package/dist/plugins/qwickbrain/types.d.ts.map +1 -0
- package/dist/plugins/qwickbrain/types.js +9 -0
- package/dist/plugins/qwickbrain/types.js.map +1 -0
- package/dist/plugins/users/__tests__/postgres-store.test.js +1 -0
- package/dist/plugins/users/__tests__/postgres-store.test.js.map +1 -1
- package/dist/plugins/users/__tests__/users-plugin.test.js +3 -0
- package/dist/plugins/users/__tests__/users-plugin.test.js.map +1 -1
- package/dist/plugins/users/stores/postgres-store.d.ts.map +1 -1
- package/dist/plugins/users/stores/postgres-store.js +59 -1
- package/dist/plugins/users/stores/postgres-store.js.map +1 -1
- package/dist/plugins/users/types.d.ts +22 -0
- package/dist/plugins/users/types.d.ts.map +1 -1
- package/dist-ui/assets/index-5nX8fM1a.js +469 -0
- package/dist-ui/assets/index-5nX8fM1a.js.map +1 -0
- package/dist-ui/index.html +1 -1
- package/dist-ui-lib/api/controlPanelApi.d.ts +68 -0
- package/dist-ui-lib/components/index.d.ts +2 -1
- package/dist-ui-lib/index.js +2642 -2281
- package/dist-ui-lib/index.js.map +1 -1
- package/dist-ui-lib/pages/APIKeysPage.d.ts +13 -0
- package/dist-ui-lib/pages/AcceptInvitationPage.d.ts +28 -0
- package/package.json +3 -2
- package/src/core/control-panel.ts +47 -0
- package/src/core/guards.ts +89 -0
- package/src/core/health-manager.ts +6 -1
- package/src/core/plugin-registry.ts +123 -25
- package/src/core/types.ts +2 -0
- package/src/index.ts +11 -0
- package/src/plugins/api-keys/api-keys-plugin.ts +397 -0
- package/src/plugins/api-keys/index.ts +49 -0
- package/src/plugins/api-keys/middleware/bearer-token-auth.ts +250 -0
- package/src/plugins/api-keys/middleware/index.ts +12 -0
- package/src/plugins/api-keys/stores/index.ts +7 -0
- package/src/plugins/api-keys/stores/postgres-store.ts +487 -0
- package/src/plugins/api-keys/types.ts +243 -0
- package/src/plugins/auth/auth-plugin.test.ts +167 -0
- package/src/plugins/auth/auth-plugin.ts +17 -1
- package/src/plugins/auth/env-config.ts +6 -2
- package/src/plugins/auth/types.ts +10 -0
- package/src/plugins/devices/__tests__/token-utils.test.ts +4 -2
- package/src/plugins/frontend-app-plugin.ts +24 -4
- package/src/plugins/index.ts +15 -0
- package/src/plugins/qwickbrain/index.ts +33 -0
- package/src/plugins/qwickbrain/qwickbrain-plugin.ts +642 -0
- package/src/plugins/qwickbrain/types.ts +146 -0
- package/src/plugins/users/__tests__/postgres-store.test.ts +1 -0
- package/src/plugins/users/__tests__/users-plugin.test.ts +3 -0
- package/src/plugins/users/stores/postgres-store.ts +69 -0
- package/src/plugins/users/types.ts +25 -0
- package/ui/src/App.tsx +6 -1
- package/ui/src/api/controlPanelApi.ts +206 -37
- package/ui/src/components/index.ts +6 -0
- package/ui/src/pages/APIKeysPage.tsx +661 -0
- package/ui/src/pages/AcceptInvitationPage.tsx +169 -0
- package/ui/src/pages/UsersPage.tsx +225 -2
- package/dist-ui/assets/index-CynOqPkb.js +0 -469
- package/dist-ui/assets/index-CynOqPkb.js.map +0 -1
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QwickBrain Plugin
|
|
3
|
+
*
|
|
4
|
+
* MCP proxy plugin for @qwickapps/server that exposes QwickBrain tools
|
|
5
|
+
* to external AI clients via authenticated API endpoints.
|
|
6
|
+
*
|
|
7
|
+
* Copyright (c) 2025 QwickApps.com. All rights reserved.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Request, Response as ExpressResponse } from 'express';
|
|
11
|
+
import type { Plugin, PluginConfig, PluginRegistry } from '../../core/plugin-registry.js';
|
|
12
|
+
import type {
|
|
13
|
+
QwickBrainPluginConfig,
|
|
14
|
+
MCPToolDefinition,
|
|
15
|
+
MCPToolCallRequest,
|
|
16
|
+
MCPToolCallResponse,
|
|
17
|
+
QwickBrainConnectionStatus,
|
|
18
|
+
} from './types.js';
|
|
19
|
+
import { isAuthenticated, getAuthenticatedUser } from '../auth/auth-plugin.js';
|
|
20
|
+
import type { AuthenticatedUser } from '../auth/types.js';
|
|
21
|
+
|
|
22
|
+
// Connection status tracking
|
|
23
|
+
let connectionStatus: QwickBrainConnectionStatus = {
|
|
24
|
+
connected: false,
|
|
25
|
+
lastCheck: new Date(),
|
|
26
|
+
tailscaleStatus: 'unknown',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Health check interval
|
|
30
|
+
let healthCheckInterval: NodeJS.Timeout | null = null;
|
|
31
|
+
|
|
32
|
+
// In-memory rate limit tracking
|
|
33
|
+
interface RateLimitEntry {
|
|
34
|
+
count: number;
|
|
35
|
+
windowStart: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const rateLimitStore = new Map<string, RateLimitEntry>();
|
|
39
|
+
|
|
40
|
+
// Cleanup interval for rate limit entries
|
|
41
|
+
let rateLimitCleanupInterval: NodeJS.Timeout | null = null;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Response from proxy request
|
|
45
|
+
*/
|
|
46
|
+
interface ProxyResponse {
|
|
47
|
+
ok: boolean;
|
|
48
|
+
status: number;
|
|
49
|
+
json: () => Promise<unknown>;
|
|
50
|
+
text: () => Promise<string>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Proxy a request to the QwickBrain instance
|
|
55
|
+
*/
|
|
56
|
+
async function proxyToQwickBrain(
|
|
57
|
+
baseUrl: string,
|
|
58
|
+
path: string,
|
|
59
|
+
options: {
|
|
60
|
+
method?: string;
|
|
61
|
+
body?: unknown;
|
|
62
|
+
timeout?: number;
|
|
63
|
+
} = {}
|
|
64
|
+
): Promise<ProxyResponse> {
|
|
65
|
+
const { method = 'GET', body, timeout = 30000 } = options;
|
|
66
|
+
const url = `${baseUrl}${path}`;
|
|
67
|
+
|
|
68
|
+
const controller = new AbortController();
|
|
69
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const fetchOptions: RequestInit = {
|
|
73
|
+
method,
|
|
74
|
+
signal: controller.signal,
|
|
75
|
+
headers: {
|
|
76
|
+
'Content-Type': 'application/json',
|
|
77
|
+
'Accept': 'application/json',
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
if (body && method !== 'GET') {
|
|
82
|
+
fetchOptions.body = JSON.stringify(body);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const response = await fetch(url, fetchOptions);
|
|
86
|
+
return {
|
|
87
|
+
ok: response.ok,
|
|
88
|
+
status: response.status,
|
|
89
|
+
json: () => response.json(),
|
|
90
|
+
text: () => response.text(),
|
|
91
|
+
};
|
|
92
|
+
} finally {
|
|
93
|
+
clearTimeout(timeoutId);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Check Tailscale connectivity status
|
|
99
|
+
*/
|
|
100
|
+
async function checkTailscaleStatus(): Promise<'connected' | 'disconnected' | 'unknown'> {
|
|
101
|
+
try {
|
|
102
|
+
const { exec } = await import('child_process');
|
|
103
|
+
const { promisify } = await import('util');
|
|
104
|
+
const execAsync = promisify(exec);
|
|
105
|
+
|
|
106
|
+
const { stdout } = await execAsync('tailscale status --json', { timeout: 5000 });
|
|
107
|
+
const status = JSON.parse(stdout);
|
|
108
|
+
|
|
109
|
+
return status.BackendState === 'Running' ? 'connected' : 'disconnected';
|
|
110
|
+
} catch {
|
|
111
|
+
return 'unknown';
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Check QwickBrain connectivity
|
|
117
|
+
*/
|
|
118
|
+
async function checkQwickBrainHealth(
|
|
119
|
+
baseUrl: string,
|
|
120
|
+
timeout: number
|
|
121
|
+
): Promise<{ connected: boolean; latencyMs?: number; error?: string }> {
|
|
122
|
+
const startTime = Date.now();
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const response = await proxyToQwickBrain(baseUrl, '/health', { timeout });
|
|
126
|
+
const latencyMs = Date.now() - startTime;
|
|
127
|
+
|
|
128
|
+
if (response.ok) {
|
|
129
|
+
return { connected: true, latencyMs };
|
|
130
|
+
} else {
|
|
131
|
+
return { connected: false, error: `HTTP ${response.status}` };
|
|
132
|
+
}
|
|
133
|
+
} catch (error) {
|
|
134
|
+
return {
|
|
135
|
+
connected: false,
|
|
136
|
+
error: error instanceof Error ? error.message : 'Connection failed',
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Create the QwickBrain plugin
|
|
143
|
+
*/
|
|
144
|
+
export function createQwickBrainPlugin(config: QwickBrainPluginConfig): Plugin {
|
|
145
|
+
const debug = config.debug || false;
|
|
146
|
+
// Note: For regular plugins with slug-based routing, routes are auto-prefixed with slug
|
|
147
|
+
// So we use empty prefix here. The framework will add /mcp (or configured slug) automatically
|
|
148
|
+
const apiPrefix = '';
|
|
149
|
+
const apiEnabled = config.api?.enabled !== false;
|
|
150
|
+
const timeout = config.timeout || 30000;
|
|
151
|
+
const exposedTools = config.exposedTools || '*';
|
|
152
|
+
const authRequired = config.auth?.required !== false; // Default true
|
|
153
|
+
const allowedRoles = config.auth?.allowedRoles;
|
|
154
|
+
const rateLimitEnabled = config.rateLimit?.enabled !== false; // Default true
|
|
155
|
+
const perClientPerMinute = config.rateLimit?.perClientPerMinute || 60;
|
|
156
|
+
const globalPerMinute = config.rateLimit?.globalPerMinute || 1000;
|
|
157
|
+
const windowMs = 60000; // 1 minute window
|
|
158
|
+
|
|
159
|
+
function log(message: string, data?: Record<string, unknown>) {
|
|
160
|
+
if (debug) {
|
|
161
|
+
console.log(`[QwickBrainPlugin] ${message}`, data || '');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Check rate limit for a key
|
|
167
|
+
* Returns remaining requests or -1 if limited
|
|
168
|
+
*/
|
|
169
|
+
function checkRateLimit(key: string, maxRequests: number): { limited: boolean; remaining: number; resetAt: number } {
|
|
170
|
+
const now = Date.now();
|
|
171
|
+
const entry = rateLimitStore.get(key);
|
|
172
|
+
|
|
173
|
+
if (!entry || now - entry.windowStart >= windowMs) {
|
|
174
|
+
// New window
|
|
175
|
+
rateLimitStore.set(key, { count: 1, windowStart: now });
|
|
176
|
+
return { limited: false, remaining: maxRequests - 1, resetAt: now + windowMs };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Within window
|
|
180
|
+
if (entry.count >= maxRequests) {
|
|
181
|
+
return { limited: true, remaining: 0, resetAt: entry.windowStart + windowMs };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
entry.count++;
|
|
185
|
+
return { limited: false, remaining: maxRequests - entry.count, resetAt: entry.windowStart + windowMs };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Check rate limits for a request (per-client and global)
|
|
190
|
+
* Returns error response if limited, null otherwise
|
|
191
|
+
*/
|
|
192
|
+
function checkRateLimits(userId: string | undefined): { status: number; body: Record<string, unknown>; headers: Record<string, string> } | null {
|
|
193
|
+
if (!rateLimitEnabled) return null;
|
|
194
|
+
|
|
195
|
+
// Check global rate limit
|
|
196
|
+
const globalKey = 'global:mcp';
|
|
197
|
+
const globalResult = checkRateLimit(globalKey, globalPerMinute);
|
|
198
|
+
if (globalResult.limited) {
|
|
199
|
+
log('Global rate limit exceeded');
|
|
200
|
+
return {
|
|
201
|
+
status: 429,
|
|
202
|
+
body: {
|
|
203
|
+
error: 'Too Many Requests',
|
|
204
|
+
message: 'Global rate limit exceeded. Please try again later.',
|
|
205
|
+
retryAfter: Math.ceil((globalResult.resetAt - Date.now()) / 1000),
|
|
206
|
+
},
|
|
207
|
+
headers: {
|
|
208
|
+
'Retry-After': String(Math.ceil((globalResult.resetAt - Date.now()) / 1000)),
|
|
209
|
+
'X-RateLimit-Limit': String(globalPerMinute),
|
|
210
|
+
'X-RateLimit-Remaining': '0',
|
|
211
|
+
'X-RateLimit-Reset': String(Math.ceil(globalResult.resetAt / 1000)),
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Check per-client rate limit (by user ID or IP)
|
|
217
|
+
const clientKey = `client:${userId || 'anonymous'}`;
|
|
218
|
+
const clientResult = checkRateLimit(clientKey, perClientPerMinute);
|
|
219
|
+
if (clientResult.limited) {
|
|
220
|
+
log('Client rate limit exceeded', { userId });
|
|
221
|
+
return {
|
|
222
|
+
status: 429,
|
|
223
|
+
body: {
|
|
224
|
+
error: 'Too Many Requests',
|
|
225
|
+
message: 'Rate limit exceeded. Please try again later.',
|
|
226
|
+
retryAfter: Math.ceil((clientResult.resetAt - Date.now()) / 1000),
|
|
227
|
+
},
|
|
228
|
+
headers: {
|
|
229
|
+
'Retry-After': String(Math.ceil((clientResult.resetAt - Date.now()) / 1000)),
|
|
230
|
+
'X-RateLimit-Limit': String(perClientPerMinute),
|
|
231
|
+
'X-RateLimit-Remaining': '0',
|
|
232
|
+
'X-RateLimit-Reset': String(Math.ceil(clientResult.resetAt / 1000)),
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Check if a tool should be exposed
|
|
242
|
+
*/
|
|
243
|
+
function isToolExposed(toolName: string): boolean {
|
|
244
|
+
if (exposedTools === '*') return true;
|
|
245
|
+
return exposedTools.includes(toolName);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Check if user has required role
|
|
250
|
+
*/
|
|
251
|
+
function hasAllowedRole(user: AuthenticatedUser | null): boolean {
|
|
252
|
+
if (!allowedRoles || allowedRoles.length === 0) return true;
|
|
253
|
+
if (!user || !user.roles) return false;
|
|
254
|
+
return allowedRoles.some(role => user.roles?.includes(role));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Check authentication and authorization for protected routes
|
|
259
|
+
* Returns error response object if not authorized, null if authorized
|
|
260
|
+
*/
|
|
261
|
+
function checkAuth(req: Request): { status: number; body: Record<string, unknown> } | null {
|
|
262
|
+
if (!authRequired) return null;
|
|
263
|
+
|
|
264
|
+
if (!isAuthenticated(req)) {
|
|
265
|
+
log('Unauthorized access attempt', { path: req.path });
|
|
266
|
+
return {
|
|
267
|
+
status: 401,
|
|
268
|
+
body: {
|
|
269
|
+
error: 'Unauthorized',
|
|
270
|
+
message: 'Authentication required to access MCP tools',
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const user = getAuthenticatedUser(req);
|
|
276
|
+
if (!hasAllowedRole(user)) {
|
|
277
|
+
log('Forbidden access attempt', { path: req.path, userId: user?.id });
|
|
278
|
+
return {
|
|
279
|
+
status: 403,
|
|
280
|
+
body: {
|
|
281
|
+
error: 'Forbidden',
|
|
282
|
+
message: 'Insufficient permissions to access MCP tools',
|
|
283
|
+
requiredRoles: allowedRoles,
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
id: 'qwickbrain',
|
|
293
|
+
name: 'QwickBrain MCP',
|
|
294
|
+
version: '1.0.0',
|
|
295
|
+
type: 'regular' as const,
|
|
296
|
+
slug: 'mcp',
|
|
297
|
+
configurable: {
|
|
298
|
+
slug: true, // Allow users to customize slug via UI
|
|
299
|
+
},
|
|
300
|
+
|
|
301
|
+
async onStart(_pluginConfig: PluginConfig, registry: PluginRegistry): Promise<void> {
|
|
302
|
+
log('Starting QwickBrain plugin');
|
|
303
|
+
log('Configuration', {
|
|
304
|
+
qwickbrainUrl: config.qwickbrainUrl,
|
|
305
|
+
timeout,
|
|
306
|
+
exposedTools: exposedTools === '*' ? 'all' : exposedTools,
|
|
307
|
+
authRequired,
|
|
308
|
+
allowedRoles: allowedRoles || 'any',
|
|
309
|
+
rateLimitEnabled,
|
|
310
|
+
perClientPerMinute,
|
|
311
|
+
globalPerMinute,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// Set up rate limit cleanup (every 5 minutes)
|
|
315
|
+
rateLimitCleanupInterval = setInterval(() => {
|
|
316
|
+
const now = Date.now();
|
|
317
|
+
let cleaned = 0;
|
|
318
|
+
for (const [key, entry] of rateLimitStore.entries()) {
|
|
319
|
+
if (now - entry.windowStart >= windowMs * 2) {
|
|
320
|
+
rateLimitStore.delete(key);
|
|
321
|
+
cleaned++;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (cleaned > 0) {
|
|
325
|
+
log('Rate limit cleanup', { cleaned, remaining: rateLimitStore.size });
|
|
326
|
+
}
|
|
327
|
+
}, 300000); // 5 minutes
|
|
328
|
+
|
|
329
|
+
// Initial health check
|
|
330
|
+
const tailscaleStatus = await checkTailscaleStatus();
|
|
331
|
+
const healthResult = await checkQwickBrainHealth(config.qwickbrainUrl, timeout);
|
|
332
|
+
|
|
333
|
+
connectionStatus = {
|
|
334
|
+
connected: healthResult.connected,
|
|
335
|
+
lastCheck: new Date(),
|
|
336
|
+
latencyMs: healthResult.latencyMs,
|
|
337
|
+
error: healthResult.error,
|
|
338
|
+
tailscaleStatus,
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
log('Initial connection status', connectionStatus as unknown as Record<string, unknown>);
|
|
342
|
+
|
|
343
|
+
// Set up periodic health check
|
|
344
|
+
healthCheckInterval = setInterval(async () => {
|
|
345
|
+
const tsStatus = await checkTailscaleStatus();
|
|
346
|
+
const health = await checkQwickBrainHealth(config.qwickbrainUrl, timeout);
|
|
347
|
+
|
|
348
|
+
connectionStatus = {
|
|
349
|
+
connected: health.connected,
|
|
350
|
+
lastCheck: new Date(),
|
|
351
|
+
latencyMs: health.latencyMs,
|
|
352
|
+
error: health.error,
|
|
353
|
+
tailscaleStatus: tsStatus,
|
|
354
|
+
};
|
|
355
|
+
}, 30000); // Check every 30 seconds
|
|
356
|
+
|
|
357
|
+
// Register health checks
|
|
358
|
+
registry.registerHealthCheck({
|
|
359
|
+
name: 'qwickbrain-connection',
|
|
360
|
+
type: 'custom',
|
|
361
|
+
check: async () => {
|
|
362
|
+
const health = await checkQwickBrainHealth(config.qwickbrainUrl, timeout);
|
|
363
|
+
return {
|
|
364
|
+
healthy: health.connected,
|
|
365
|
+
latencyMs: health.latencyMs,
|
|
366
|
+
error: health.error,
|
|
367
|
+
};
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
registry.registerHealthCheck({
|
|
372
|
+
name: 'qwickbrain-tailscale',
|
|
373
|
+
type: 'custom',
|
|
374
|
+
check: async () => {
|
|
375
|
+
const status = await checkTailscaleStatus();
|
|
376
|
+
return {
|
|
377
|
+
healthy: status === 'connected',
|
|
378
|
+
status,
|
|
379
|
+
};
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// Add API routes if enabled
|
|
384
|
+
if (apiEnabled) {
|
|
385
|
+
// GET /mcp/status - Connection status (no auth required)
|
|
386
|
+
registry.addRoute({
|
|
387
|
+
method: 'get',
|
|
388
|
+
path: `${apiPrefix}/status`,
|
|
389
|
+
pluginId: 'qwickbrain',
|
|
390
|
+
handler: async (_req: Request, res: ExpressResponse) => {
|
|
391
|
+
res.json({
|
|
392
|
+
connected: connectionStatus.connected,
|
|
393
|
+
lastCheck: connectionStatus.lastCheck.toISOString(),
|
|
394
|
+
latencyMs: connectionStatus.latencyMs,
|
|
395
|
+
tailscaleStatus: connectionStatus.tailscaleStatus,
|
|
396
|
+
error: connectionStatus.error,
|
|
397
|
+
});
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// GET /mcp/tools - List available MCP tools (auth required)
|
|
402
|
+
registry.addRoute({
|
|
403
|
+
method: 'get',
|
|
404
|
+
path: `${apiPrefix}/tools`,
|
|
405
|
+
pluginId: 'qwickbrain',
|
|
406
|
+
handler: async (req: Request, res: ExpressResponse) => {
|
|
407
|
+
// Check authentication
|
|
408
|
+
const authError = checkAuth(req);
|
|
409
|
+
if (authError) {
|
|
410
|
+
res.status(authError.status).json(authError.body);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const user = getAuthenticatedUser(req);
|
|
415
|
+
|
|
416
|
+
// Check rate limits
|
|
417
|
+
const rateLimitError = checkRateLimits(user?.id);
|
|
418
|
+
if (rateLimitError) {
|
|
419
|
+
Object.entries(rateLimitError.headers).forEach(([key, value]) => {
|
|
420
|
+
res.setHeader(key, value);
|
|
421
|
+
});
|
|
422
|
+
res.status(rateLimitError.status).json(rateLimitError.body);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
if (!connectionStatus.connected) {
|
|
428
|
+
res.status(503).json({
|
|
429
|
+
error: 'QwickBrain not connected',
|
|
430
|
+
details: connectionStatus.error,
|
|
431
|
+
});
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Fetch tools from QwickBrain
|
|
436
|
+
const response = await proxyToQwickBrain(
|
|
437
|
+
config.qwickbrainUrl,
|
|
438
|
+
'/tools',
|
|
439
|
+
{ timeout }
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
if (!response.ok) {
|
|
443
|
+
res.status(response.status).json({
|
|
444
|
+
error: 'Failed to fetch tools from QwickBrain',
|
|
445
|
+
});
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const data = await response.json() as { tools?: MCPToolDefinition[] };
|
|
450
|
+
const tools: MCPToolDefinition[] = data.tools || [];
|
|
451
|
+
|
|
452
|
+
// Filter to exposed tools only
|
|
453
|
+
const filteredTools = tools.filter(tool => isToolExposed(tool.name));
|
|
454
|
+
|
|
455
|
+
res.json({
|
|
456
|
+
tools: filteredTools,
|
|
457
|
+
count: filteredTools.length,
|
|
458
|
+
});
|
|
459
|
+
} catch (error) {
|
|
460
|
+
log('Error fetching tools', { error: String(error) });
|
|
461
|
+
res.status(500).json({
|
|
462
|
+
error: 'Failed to fetch tools',
|
|
463
|
+
details: error instanceof Error ? error.message : 'Unknown error',
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
// POST /mcp/tools/:name - Execute an MCP tool (auth required)
|
|
470
|
+
registry.addRoute({
|
|
471
|
+
method: 'post',
|
|
472
|
+
path: `${apiPrefix}/tools/:name`,
|
|
473
|
+
pluginId: 'qwickbrain',
|
|
474
|
+
handler: async (req: Request, res: ExpressResponse) => {
|
|
475
|
+
// Check authentication
|
|
476
|
+
const authError = checkAuth(req);
|
|
477
|
+
if (authError) {
|
|
478
|
+
res.status(authError.status).json(authError.body);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const toolName = req.params.name;
|
|
483
|
+
const user = getAuthenticatedUser(req);
|
|
484
|
+
|
|
485
|
+
// Check rate limits
|
|
486
|
+
const rateLimitError = checkRateLimits(user?.id);
|
|
487
|
+
if (rateLimitError) {
|
|
488
|
+
Object.entries(rateLimitError.headers).forEach(([key, value]) => {
|
|
489
|
+
res.setHeader(key, value);
|
|
490
|
+
});
|
|
491
|
+
res.status(rateLimitError.status).json(rateLimitError.body);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
try {
|
|
496
|
+
// Check if tool is exposed
|
|
497
|
+
if (!isToolExposed(toolName)) {
|
|
498
|
+
res.status(403).json({
|
|
499
|
+
error: 'Tool not available',
|
|
500
|
+
tool: toolName,
|
|
501
|
+
});
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (!connectionStatus.connected) {
|
|
506
|
+
res.status(503).json({
|
|
507
|
+
error: 'QwickBrain not connected',
|
|
508
|
+
details: connectionStatus.error,
|
|
509
|
+
});
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const toolRequest: MCPToolCallRequest = {
|
|
514
|
+
name: toolName,
|
|
515
|
+
arguments: req.body || {},
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
log('Executing tool', { tool: toolName, userId: user?.id, arguments: req.body });
|
|
519
|
+
|
|
520
|
+
// Proxy the tool call to QwickBrain
|
|
521
|
+
const response = await proxyToQwickBrain(
|
|
522
|
+
config.qwickbrainUrl,
|
|
523
|
+
`/tools/${toolName}`,
|
|
524
|
+
{
|
|
525
|
+
method: 'POST',
|
|
526
|
+
body: toolRequest.arguments,
|
|
527
|
+
timeout,
|
|
528
|
+
}
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
if (!response.ok) {
|
|
532
|
+
const errorText = await response.text();
|
|
533
|
+
res.status(response.status).json({
|
|
534
|
+
error: 'Tool execution failed',
|
|
535
|
+
details: errorText,
|
|
536
|
+
});
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const result = await response.json() as MCPToolCallResponse;
|
|
541
|
+
|
|
542
|
+
log('Tool executed successfully', { tool: toolName });
|
|
543
|
+
|
|
544
|
+
res.json(result);
|
|
545
|
+
} catch (error) {
|
|
546
|
+
log('Error executing tool', { tool: toolName, error: String(error) });
|
|
547
|
+
res.status(500).json({
|
|
548
|
+
error: 'Tool execution failed',
|
|
549
|
+
tool: toolName,
|
|
550
|
+
details: error instanceof Error ? error.message : 'Unknown error',
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
},
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
// GET /mcp/sse - Server-Sent Events endpoint for streaming (auth required)
|
|
557
|
+
registry.addRoute({
|
|
558
|
+
method: 'get',
|
|
559
|
+
path: `${apiPrefix}/sse`,
|
|
560
|
+
pluginId: 'qwickbrain',
|
|
561
|
+
handler: async (req: Request, res: ExpressResponse) => {
|
|
562
|
+
// Check authentication
|
|
563
|
+
const authError = checkAuth(req);
|
|
564
|
+
if (authError) {
|
|
565
|
+
res.status(authError.status).json(authError.body);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const user = getAuthenticatedUser(req);
|
|
570
|
+
|
|
571
|
+
// Set SSE headers
|
|
572
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
573
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
574
|
+
res.setHeader('Connection', 'keep-alive');
|
|
575
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
576
|
+
|
|
577
|
+
// Send initial connection event
|
|
578
|
+
res.write(`event: connected\ndata: ${JSON.stringify({ status: 'connected', userId: user?.id })}\n\n`);
|
|
579
|
+
|
|
580
|
+
// Keep connection alive with periodic pings
|
|
581
|
+
const pingInterval = setInterval(() => {
|
|
582
|
+
res.write(`event: ping\ndata: ${JSON.stringify({ time: new Date().toISOString() })}\n\n`);
|
|
583
|
+
}, 30000);
|
|
584
|
+
|
|
585
|
+
// Handle client disconnect
|
|
586
|
+
req.on('close', () => {
|
|
587
|
+
clearInterval(pingInterval);
|
|
588
|
+
log('SSE client disconnected', { userId: user?.id });
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
log('SSE client connected', { userId: user?.id });
|
|
592
|
+
},
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
log('QwickBrain plugin started');
|
|
597
|
+
},
|
|
598
|
+
|
|
599
|
+
async onStop(): Promise<void> {
|
|
600
|
+
log('Stopping QwickBrain plugin');
|
|
601
|
+
|
|
602
|
+
if (healthCheckInterval) {
|
|
603
|
+
clearInterval(healthCheckInterval);
|
|
604
|
+
healthCheckInterval = null;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (rateLimitCleanupInterval) {
|
|
608
|
+
clearInterval(rateLimitCleanupInterval);
|
|
609
|
+
rateLimitCleanupInterval = null;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Clear rate limit store
|
|
613
|
+
rateLimitStore.clear();
|
|
614
|
+
|
|
615
|
+
connectionStatus = {
|
|
616
|
+
connected: false,
|
|
617
|
+
lastCheck: new Date(),
|
|
618
|
+
tailscaleStatus: 'unknown',
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
log('QwickBrain plugin stopped');
|
|
622
|
+
},
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// ========================================
|
|
627
|
+
// Helper Functions
|
|
628
|
+
// ========================================
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Get the current connection status
|
|
632
|
+
*/
|
|
633
|
+
export function getConnectionStatus(): QwickBrainConnectionStatus {
|
|
634
|
+
return { ...connectionStatus };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Check if QwickBrain is connected
|
|
639
|
+
*/
|
|
640
|
+
export function isConnected(): boolean {
|
|
641
|
+
return connectionStatus.connected;
|
|
642
|
+
}
|