@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.
Files changed (135) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/core/control-panel.d.ts.map +1 -1
  3. package/dist/core/control-panel.js +41 -0
  4. package/dist/core/control-panel.js.map +1 -1
  5. package/dist/core/guards.d.ts.map +1 -1
  6. package/dist/core/guards.js +77 -0
  7. package/dist/core/guards.js.map +1 -1
  8. package/dist/core/health-manager.d.ts +4 -0
  9. package/dist/core/health-manager.d.ts.map +1 -1
  10. package/dist/core/health-manager.js +6 -1
  11. package/dist/core/health-manager.js.map +1 -1
  12. package/dist/core/plugin-registry.d.ts +55 -5
  13. package/dist/core/plugin-registry.d.ts.map +1 -1
  14. package/dist/core/plugin-registry.js +57 -19
  15. package/dist/core/plugin-registry.js.map +1 -1
  16. package/dist/core/types.d.ts +2 -0
  17. package/dist/core/types.d.ts.map +1 -1
  18. package/dist/index.d.ts +2 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +3 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/plugins/api-keys/api-keys-plugin.d.ts +46 -0
  23. package/dist/plugins/api-keys/api-keys-plugin.d.ts.map +1 -0
  24. package/dist/plugins/api-keys/api-keys-plugin.js +329 -0
  25. package/dist/plugins/api-keys/api-keys-plugin.js.map +1 -0
  26. package/dist/plugins/api-keys/index.d.ts +14 -0
  27. package/dist/plugins/api-keys/index.d.ts.map +1 -0
  28. package/dist/plugins/api-keys/index.js +17 -0
  29. package/dist/plugins/api-keys/index.js.map +1 -0
  30. package/dist/plugins/api-keys/middleware/bearer-token-auth.d.ts +74 -0
  31. package/dist/plugins/api-keys/middleware/bearer-token-auth.d.ts.map +1 -0
  32. package/dist/plugins/api-keys/middleware/bearer-token-auth.js +201 -0
  33. package/dist/plugins/api-keys/middleware/bearer-token-auth.js.map +1 -0
  34. package/dist/plugins/api-keys/middleware/index.d.ts +7 -0
  35. package/dist/plugins/api-keys/middleware/index.d.ts.map +1 -0
  36. package/dist/plugins/api-keys/middleware/index.js +7 -0
  37. package/dist/plugins/api-keys/middleware/index.js.map +1 -0
  38. package/dist/plugins/api-keys/stores/index.d.ts +7 -0
  39. package/dist/plugins/api-keys/stores/index.d.ts.map +1 -0
  40. package/dist/plugins/api-keys/stores/index.js +7 -0
  41. package/dist/plugins/api-keys/stores/index.js.map +1 -0
  42. package/dist/plugins/api-keys/stores/postgres-store.d.ts +34 -0
  43. package/dist/plugins/api-keys/stores/postgres-store.d.ts.map +1 -0
  44. package/dist/plugins/api-keys/stores/postgres-store.js +360 -0
  45. package/dist/plugins/api-keys/stores/postgres-store.js.map +1 -0
  46. package/dist/plugins/api-keys/types.d.ts +268 -0
  47. package/dist/plugins/api-keys/types.d.ts.map +1 -0
  48. package/dist/plugins/api-keys/types.js +56 -0
  49. package/dist/plugins/api-keys/types.js.map +1 -0
  50. package/dist/plugins/auth/auth-plugin.d.ts.map +1 -1
  51. package/dist/plugins/auth/auth-plugin.js +17 -1
  52. package/dist/plugins/auth/auth-plugin.js.map +1 -1
  53. package/dist/plugins/auth/auth-plugin.test.js +133 -0
  54. package/dist/plugins/auth/auth-plugin.test.js.map +1 -1
  55. package/dist/plugins/auth/env-config.d.ts.map +1 -1
  56. package/dist/plugins/auth/env-config.js +6 -2
  57. package/dist/plugins/auth/env-config.js.map +1 -1
  58. package/dist/plugins/auth/types.d.ts +10 -0
  59. package/dist/plugins/auth/types.d.ts.map +1 -1
  60. package/dist/plugins/auth/types.js.map +1 -1
  61. package/dist/plugins/devices/__tests__/token-utils.test.js +4 -2
  62. package/dist/plugins/devices/__tests__/token-utils.test.js.map +1 -1
  63. package/dist/plugins/frontend-app-plugin.d.ts.map +1 -1
  64. package/dist/plugins/frontend-app-plugin.js +21 -4
  65. package/dist/plugins/frontend-app-plugin.js.map +1 -1
  66. package/dist/plugins/index.d.ts +2 -0
  67. package/dist/plugins/index.d.ts.map +1 -1
  68. package/dist/plugins/index.js +2 -0
  69. package/dist/plugins/index.js.map +1 -1
  70. package/dist/plugins/qwickbrain/index.d.ts +25 -0
  71. package/dist/plugins/qwickbrain/index.d.ts.map +1 -0
  72. package/dist/plugins/qwickbrain/index.js +24 -0
  73. package/dist/plugins/qwickbrain/index.js.map +1 -0
  74. package/dist/plugins/qwickbrain/qwickbrain-plugin.d.ts +23 -0
  75. package/dist/plugins/qwickbrain/qwickbrain-plugin.d.ts.map +1 -0
  76. package/dist/plugins/qwickbrain/qwickbrain-plugin.js +528 -0
  77. package/dist/plugins/qwickbrain/qwickbrain-plugin.js.map +1 -0
  78. package/dist/plugins/qwickbrain/types.d.ts +131 -0
  79. package/dist/plugins/qwickbrain/types.d.ts.map +1 -0
  80. package/dist/plugins/qwickbrain/types.js +9 -0
  81. package/dist/plugins/qwickbrain/types.js.map +1 -0
  82. package/dist/plugins/users/__tests__/postgres-store.test.js +1 -0
  83. package/dist/plugins/users/__tests__/postgres-store.test.js.map +1 -1
  84. package/dist/plugins/users/__tests__/users-plugin.test.js +3 -0
  85. package/dist/plugins/users/__tests__/users-plugin.test.js.map +1 -1
  86. package/dist/plugins/users/stores/postgres-store.d.ts.map +1 -1
  87. package/dist/plugins/users/stores/postgres-store.js +59 -1
  88. package/dist/plugins/users/stores/postgres-store.js.map +1 -1
  89. package/dist/plugins/users/types.d.ts +22 -0
  90. package/dist/plugins/users/types.d.ts.map +1 -1
  91. package/dist-ui/assets/index-5nX8fM1a.js +469 -0
  92. package/dist-ui/assets/index-5nX8fM1a.js.map +1 -0
  93. package/dist-ui/index.html +1 -1
  94. package/dist-ui-lib/api/controlPanelApi.d.ts +68 -0
  95. package/dist-ui-lib/components/index.d.ts +2 -1
  96. package/dist-ui-lib/index.js +2642 -2281
  97. package/dist-ui-lib/index.js.map +1 -1
  98. package/dist-ui-lib/pages/APIKeysPage.d.ts +13 -0
  99. package/dist-ui-lib/pages/AcceptInvitationPage.d.ts +28 -0
  100. package/package.json +3 -2
  101. package/src/core/control-panel.ts +47 -0
  102. package/src/core/guards.ts +89 -0
  103. package/src/core/health-manager.ts +6 -1
  104. package/src/core/plugin-registry.ts +123 -25
  105. package/src/core/types.ts +2 -0
  106. package/src/index.ts +11 -0
  107. package/src/plugins/api-keys/api-keys-plugin.ts +397 -0
  108. package/src/plugins/api-keys/index.ts +49 -0
  109. package/src/plugins/api-keys/middleware/bearer-token-auth.ts +250 -0
  110. package/src/plugins/api-keys/middleware/index.ts +12 -0
  111. package/src/plugins/api-keys/stores/index.ts +7 -0
  112. package/src/plugins/api-keys/stores/postgres-store.ts +487 -0
  113. package/src/plugins/api-keys/types.ts +243 -0
  114. package/src/plugins/auth/auth-plugin.test.ts +167 -0
  115. package/src/plugins/auth/auth-plugin.ts +17 -1
  116. package/src/plugins/auth/env-config.ts +6 -2
  117. package/src/plugins/auth/types.ts +10 -0
  118. package/src/plugins/devices/__tests__/token-utils.test.ts +4 -2
  119. package/src/plugins/frontend-app-plugin.ts +24 -4
  120. package/src/plugins/index.ts +15 -0
  121. package/src/plugins/qwickbrain/index.ts +33 -0
  122. package/src/plugins/qwickbrain/qwickbrain-plugin.ts +642 -0
  123. package/src/plugins/qwickbrain/types.ts +146 -0
  124. package/src/plugins/users/__tests__/postgres-store.test.ts +1 -0
  125. package/src/plugins/users/__tests__/users-plugin.test.ts +3 -0
  126. package/src/plugins/users/stores/postgres-store.ts +69 -0
  127. package/src/plugins/users/types.ts +25 -0
  128. package/ui/src/App.tsx +6 -1
  129. package/ui/src/api/controlPanelApi.ts +206 -37
  130. package/ui/src/components/index.ts +6 -0
  131. package/ui/src/pages/APIKeysPage.tsx +661 -0
  132. package/ui/src/pages/AcceptInvitationPage.tsx +169 -0
  133. package/ui/src/pages/UsersPage.tsx +225 -2
  134. package/dist-ui/assets/index-CynOqPkb.js +0 -469
  135. 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
+ }