@assistkick/create 1.19.0 → 1.22.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/package.json +1 -1
- package/templates/assistkick-product-system/packages/backend/src/routes/mcp_config.ts +87 -0
- package/templates/assistkick-product-system/packages/backend/src/server.ts +8 -1
- package/templates/assistkick-product-system/packages/backend/src/services/chat_cli_bridge.ts +7 -0
- package/templates/assistkick-product-system/packages/backend/src/services/chat_ws_handler.ts +26 -1
- package/templates/assistkick-product-system/packages/backend/src/services/mcp_config_service.ts +157 -0
- package/templates/assistkick-product-system/packages/frontend/src/api/client.ts +37 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatView.tsx +14 -1
- package/templates/assistkick-product-system/packages/frontend/src/components/McpConfigModal.tsx +307 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0001_superb_roxanne_simpson.sql +8 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0002_noisy_maelstrom.sql +1 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0001_snapshot.json +1019 -23
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0002_snapshot.json +997 -22
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/_journal.json +14 -0
- package/templates/assistkick-product-system/packages/shared/db/schema.ts +11 -0
- package/templates/assistkick-product-system/packages/shared/lib/app_use_flow.ts +484 -0
- package/templates/assistkick-product-system/packages/shared/package.json +2 -1
- package/templates/assistkick-product-system/packages/shared/tools/agent_builder.ts +341 -0
- package/templates/assistkick-product-system/packages/shared/tools/app_use_record.ts +268 -0
- package/templates/assistkick-product-system/packages/shared/tools/app_use_run.ts +348 -0
- package/templates/assistkick-product-system/packages/shared/tools/app_use_validate.ts +67 -0
- package/templates/assistkick-product-system/packages/shared/tools/workflow_builder.ts +754 -0
- package/templates/skills/assistkick-agent-builder/SKILL.md +168 -0
- package/templates/skills/assistkick-app-use/SKILL.md +296 -0
- package/templates/skills/assistkick-app-use/references/agent-browser.md +1156 -0
- package/templates/skills/assistkick-workflow-builder/SKILL.md +234 -0
package/package.json
CHANGED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP config routes — per-user MCP server configuration management.
|
|
3
|
+
* All endpoints accept an optional ?projectId query param to scope configs to a project.
|
|
4
|
+
* Without projectId, configs are global (user-level).
|
|
5
|
+
*
|
|
6
|
+
* GET /api/mcp-config?projectId=... — get configs for the authenticated user
|
|
7
|
+
* PUT /api/mcp-config/:type?projectId=... — upsert a config (type = localhost | remote)
|
|
8
|
+
* DELETE /api/mcp-config/:type?projectId=... — delete a config
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Router } from 'express';
|
|
12
|
+
import type { McpConfigService } from '../services/mcp_config_service.js';
|
|
13
|
+
|
|
14
|
+
interface McpConfigRoutesDeps {
|
|
15
|
+
mcpConfigService: McpConfigService;
|
|
16
|
+
log: (tag: string, ...args: any[]) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const createMcpConfigRoutes = ({ mcpConfigService, log }: McpConfigRoutesDeps): Router => {
|
|
20
|
+
const router: Router = Router();
|
|
21
|
+
|
|
22
|
+
// GET /api/mcp-config?projectId=...
|
|
23
|
+
router.get('/', async (req, res) => {
|
|
24
|
+
const userId = (req as any).user?.id;
|
|
25
|
+
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
|
|
26
|
+
|
|
27
|
+
const projectId = (req.query.projectId as string) || null;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const configs = await mcpConfigService.getAllConfigs(userId, projectId);
|
|
31
|
+
res.json({ configs });
|
|
32
|
+
} catch (err: any) {
|
|
33
|
+
log('MCP_CONFIG', `List configs failed: ${err.message}`);
|
|
34
|
+
res.status(500).json({ error: 'Failed to list MCP configs' });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// PUT /api/mcp-config/:type?projectId=...
|
|
39
|
+
router.put('/:type', async (req, res) => {
|
|
40
|
+
const userId = (req as any).user?.id;
|
|
41
|
+
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
|
|
42
|
+
|
|
43
|
+
const configType = req.params.type;
|
|
44
|
+
const projectId = (req.query.projectId as string) || (req.body.projectId as string) || null;
|
|
45
|
+
const { mcpServersJson } = req.body;
|
|
46
|
+
|
|
47
|
+
if (!mcpServersJson || typeof mcpServersJson !== 'string') {
|
|
48
|
+
res.status(400).json({ error: 'mcpServersJson is required and must be a string' });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const config = await mcpConfigService.upsertConfig(userId, configType, mcpServersJson, projectId);
|
|
54
|
+
res.json({ config });
|
|
55
|
+
} catch (err: any) {
|
|
56
|
+
log('MCP_CONFIG', `Upsert config failed: ${err.message}`);
|
|
57
|
+
if (err.message.includes('Invalid JSON') || err.message.includes('configType must be')) {
|
|
58
|
+
res.status(400).json({ error: err.message });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
res.status(500).json({ error: 'Failed to save MCP config' });
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// DELETE /api/mcp-config/:type?projectId=...
|
|
66
|
+
router.delete('/:type', async (req, res) => {
|
|
67
|
+
const userId = (req as any).user?.id;
|
|
68
|
+
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
|
|
69
|
+
|
|
70
|
+
const configType = req.params.type;
|
|
71
|
+
const projectId = (req.query.projectId as string) || null;
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const deleted = await mcpConfigService.deleteConfig(userId, configType, projectId);
|
|
75
|
+
if (!deleted) {
|
|
76
|
+
res.status(404).json({ error: 'Config not found' });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
res.json({ success: true });
|
|
80
|
+
} catch (err: any) {
|
|
81
|
+
log('MCP_CONFIG', `Delete config failed: ${err.message}`);
|
|
82
|
+
res.status(500).json({ error: 'Failed to delete MCP config' });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return router;
|
|
87
|
+
};
|
|
@@ -54,6 +54,8 @@ import { createChatSessionRoutes } from './routes/chat_sessions.js';
|
|
|
54
54
|
import { createChatUploadRoutes } from './routes/chat_upload.js';
|
|
55
55
|
import { createChatFilesRoutes } from './routes/chat_files.js';
|
|
56
56
|
import { createSkillRoutes } from './routes/skills.js';
|
|
57
|
+
import { McpConfigService } from './services/mcp_config_service.js';
|
|
58
|
+
import { createMcpConfigRoutes } from './routes/mcp_config.js';
|
|
57
59
|
import { DevCommandDetector } from './services/dev_command_detector.js';
|
|
58
60
|
import { PreviewServerManager } from './services/preview_server_manager.js';
|
|
59
61
|
import { createPreviewApiRoutes, createPreviewProxyMiddleware } from './routes/preview.js';
|
|
@@ -186,6 +188,11 @@ waitForDb().then(async () => {
|
|
|
186
188
|
logError('STARTUP', 'Startup tasks failed', err);
|
|
187
189
|
});
|
|
188
190
|
|
|
191
|
+
// MCP config routes (per-user MCP server configuration)
|
|
192
|
+
const mcpConfigService = new McpConfigService({ getDb, log });
|
|
193
|
+
const mcpConfigRoutes = createMcpConfigRoutes({ mcpConfigService, log });
|
|
194
|
+
app.use('/api/mcp-config', authMiddleware.requireAuth, mcpConfigRoutes);
|
|
195
|
+
|
|
189
196
|
// Chat v2 session management routes (authenticated)
|
|
190
197
|
const chatSessionService = new ChatSessionService({ getDb, log });
|
|
191
198
|
const chatMessageRepository = new ChatMessageRepository({ getDb, log });
|
|
@@ -262,7 +269,7 @@ const chatWss = new WebSocketServer({ noServer: true });
|
|
|
262
269
|
const chatCliBridge = new ChatCliBridge({ projectRoot: paths.projectRoot, workspacesDir: paths.workspacesDir, log });
|
|
263
270
|
const permissionService = new PermissionService({ getDb, log });
|
|
264
271
|
const titleGeneratorService = new TitleGeneratorService({ log });
|
|
265
|
-
const chatHandler = new ChatWsHandler({ wss: chatWss, authService, chatCliBridge, permissionService, chatMessageRepository, chatSessionService, titleGeneratorService, log });
|
|
272
|
+
const chatHandler = new ChatWsHandler({ wss: chatWss, authService, chatCliBridge, permissionService, chatMessageRepository, chatSessionService, titleGeneratorService, mcpConfigService, log });
|
|
266
273
|
|
|
267
274
|
// Chat permission routes — used by MCP permission server (no auth — internal only)
|
|
268
275
|
const chatPermissionRoutes = createChatPermissionRoutes({ permissionService, log });
|
package/templates/assistkick-product-system/packages/backend/src/services/chat_cli_bridge.ts
CHANGED
|
@@ -33,6 +33,8 @@ export interface SpawnOptions {
|
|
|
33
33
|
allowedTools?: string[];
|
|
34
34
|
/** Compacted conversation context to prepend to the system prompt */
|
|
35
35
|
continuationContext?: string;
|
|
36
|
+
/** Inline MCP config JSON string to pass via --mcp-config */
|
|
37
|
+
mcpConfigJson?: string;
|
|
36
38
|
onEvent: (event: Record<string, unknown>) => void;
|
|
37
39
|
}
|
|
38
40
|
|
|
@@ -90,6 +92,11 @@ export class ChatCliBridge {
|
|
|
90
92
|
}
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
// MCP server configuration (inline JSON string)
|
|
96
|
+
if (options.mcpConfigJson) {
|
|
97
|
+
args.push('--mcp-config', options.mcpConfigJson);
|
|
98
|
+
}
|
|
99
|
+
|
|
93
100
|
// Prompt via stdin
|
|
94
101
|
args.push('-p', '-');
|
|
95
102
|
|
package/templates/assistkick-product-system/packages/backend/src/services/chat_ws_handler.ts
CHANGED
|
@@ -29,6 +29,7 @@ import type { PermissionService, PermissionDecision, PermissionRequest } from '.
|
|
|
29
29
|
import type { ChatMessageRepository } from './chat_message_repository.js';
|
|
30
30
|
import type { ChatSessionService } from './chat_session_service.js';
|
|
31
31
|
import type { TitleGeneratorService } from './title_generator_service.js';
|
|
32
|
+
import type { McpConfigService } from './mcp_config_service.js';
|
|
32
33
|
|
|
33
34
|
export interface ChatWsHandlerDeps {
|
|
34
35
|
wss: WebSocketServer;
|
|
@@ -38,6 +39,7 @@ export interface ChatWsHandlerDeps {
|
|
|
38
39
|
chatMessageRepository?: ChatMessageRepository;
|
|
39
40
|
chatSessionService?: ChatSessionService;
|
|
40
41
|
titleGeneratorService?: TitleGeneratorService;
|
|
42
|
+
mcpConfigService?: McpConfigService;
|
|
41
43
|
log: (tag: string, ...args: unknown[]) => void;
|
|
42
44
|
}
|
|
43
45
|
|
|
@@ -237,6 +239,7 @@ export class ChatWsHandler {
|
|
|
237
239
|
private readonly chatMessageRepository: ChatMessageRepository | null;
|
|
238
240
|
private readonly chatSessionService: ChatSessionService | null;
|
|
239
241
|
private readonly titleGeneratorService: TitleGeneratorService | null;
|
|
242
|
+
private readonly mcpConfigService: McpConfigService | null;
|
|
240
243
|
private readonly log: ChatWsHandlerDeps['log'];
|
|
241
244
|
private readonly activeStreams = new Map<WebSocket, Set<string>>();
|
|
242
245
|
|
|
@@ -249,7 +252,10 @@ export class ChatWsHandler {
|
|
|
249
252
|
/** Track stream context per Claude session ID for message persistence. */
|
|
250
253
|
private readonly streamContexts = new Map<string, StreamContext>();
|
|
251
254
|
|
|
252
|
-
|
|
255
|
+
/** Map WebSocket connections to their authenticated user ID. */
|
|
256
|
+
private readonly wsUserIds = new Map<WebSocket, string>();
|
|
257
|
+
|
|
258
|
+
constructor({ wss, authService, chatCliBridge, permissionService, chatMessageRepository, chatSessionService, titleGeneratorService, mcpConfigService, log }: ChatWsHandlerDeps) {
|
|
253
259
|
this.wss = wss;
|
|
254
260
|
this.authService = authService;
|
|
255
261
|
this.chatCliBridge = chatCliBridge;
|
|
@@ -257,6 +263,7 @@ export class ChatWsHandler {
|
|
|
257
263
|
this.chatMessageRepository = chatMessageRepository || null;
|
|
258
264
|
this.chatSessionService = chatSessionService || null;
|
|
259
265
|
this.titleGeneratorService = titleGeneratorService || null;
|
|
266
|
+
this.mcpConfigService = mcpConfigService || null;
|
|
260
267
|
this.log = log;
|
|
261
268
|
|
|
262
269
|
// Register the permission request handler to route requests to the right WebSocket
|
|
@@ -317,6 +324,7 @@ export class ChatWsHandler {
|
|
|
317
324
|
}
|
|
318
325
|
|
|
319
326
|
this.log('CHAT_WS', `Admin ${payload.email} connected`);
|
|
327
|
+
this.wsUserIds.set(ws, payload.sub);
|
|
320
328
|
|
|
321
329
|
ws.on('message', (raw: Buffer | string) => {
|
|
322
330
|
let msg: ChatClientMessage;
|
|
@@ -340,6 +348,7 @@ export class ChatWsHandler {
|
|
|
340
348
|
|
|
341
349
|
ws.on('close', () => {
|
|
342
350
|
this.log('CHAT_WS', `Admin ${payload.email} disconnected`);
|
|
351
|
+
this.wsUserIds.delete(ws);
|
|
343
352
|
this.cancelActiveStream(ws);
|
|
344
353
|
});
|
|
345
354
|
|
|
@@ -416,6 +425,21 @@ export class ChatWsHandler {
|
|
|
416
425
|
}
|
|
417
426
|
}
|
|
418
427
|
|
|
428
|
+
// Build inline MCP config JSON for this spawn (if user has MCP servers configured)
|
|
429
|
+
let mcpConfigJson: string | undefined;
|
|
430
|
+
const userId = this.wsUserIds.get(ws);
|
|
431
|
+
if (userId && this.mcpConfigService) {
|
|
432
|
+
try {
|
|
433
|
+
const mergedServers = await this.mcpConfigService.buildMergedConfig(userId, projectId);
|
|
434
|
+
if (mergedServers) {
|
|
435
|
+
mcpConfigJson = JSON.stringify({ mcpServers: mergedServers });
|
|
436
|
+
this.log('CHAT_WS', `MCP config: ${Object.keys(mergedServers).length} server(s) for user ${userId}`);
|
|
437
|
+
}
|
|
438
|
+
} catch (err: any) {
|
|
439
|
+
this.log('CHAT_WS', `Failed to build MCP config: ${err.message}`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
419
443
|
try {
|
|
420
444
|
const { completion, systemPrompt } = this.chatCliBridge.spawn({
|
|
421
445
|
projectId,
|
|
@@ -425,6 +449,7 @@ export class ChatWsHandler {
|
|
|
425
449
|
permissionMode,
|
|
426
450
|
allowedTools: msg.allowedTools,
|
|
427
451
|
continuationContext,
|
|
452
|
+
mcpConfigJson,
|
|
428
453
|
onEvent: (event) => {
|
|
429
454
|
// Forward to current WS (may have been re-attached after disconnect)
|
|
430
455
|
const currentWs = this.sessionToWs.get(claudeSessionId);
|
package/templates/assistkick-product-system/packages/backend/src/services/mcp_config_service.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* McpConfigService — CRUD operations for per-user MCP server configurations.
|
|
3
|
+
* Each user has two config slots per scope: 'localhost' (local dev) and 'remote' (Docker/cloud).
|
|
4
|
+
* Configs can be global (projectId=null) or project-scoped (projectId set).
|
|
5
|
+
* Uses deterministic IDs to enforce uniqueness at the PK level.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {eq, and, isNull, or} from 'drizzle-orm';
|
|
9
|
+
import {userMcpConfigs} from '@assistkick/shared/db/schema.js';
|
|
10
|
+
import {existsSync} from 'node:fs';
|
|
11
|
+
|
|
12
|
+
interface McpConfigServiceDeps {
|
|
13
|
+
getDb: () => any;
|
|
14
|
+
log: (tag: string, ...args: unknown[]) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface McpConfigRecord {
|
|
18
|
+
id: string;
|
|
19
|
+
userId: string;
|
|
20
|
+
projectId: string | null;
|
|
21
|
+
configType: string;
|
|
22
|
+
mcpServersJson: string;
|
|
23
|
+
createdAt: string;
|
|
24
|
+
updatedAt: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class McpConfigService {
|
|
28
|
+
private readonly getDb: () => any;
|
|
29
|
+
private readonly log: McpConfigServiceDeps['log'];
|
|
30
|
+
|
|
31
|
+
constructor({getDb, log}: McpConfigServiceDeps) {
|
|
32
|
+
this.getDb = getDb;
|
|
33
|
+
this.log = log;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Generate a deterministic ID to enforce (userId, projectId, configType) uniqueness. */
|
|
37
|
+
private makeId = (userId: string, configType: string, projectId?: string | null): string => {
|
|
38
|
+
const scope = projectId || 'global';
|
|
39
|
+
return `mcpcfg_${userId}_${scope}_${configType}`;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Get all MCP configs for a user at a given scope. */
|
|
43
|
+
getAllConfigs = async (userId: string, projectId?: string | null): Promise<McpConfigRecord[]> => {
|
|
44
|
+
const db = this.getDb();
|
|
45
|
+
const scopeCondition = projectId
|
|
46
|
+
? eq(userMcpConfigs.projectId, projectId)
|
|
47
|
+
: isNull(userMcpConfigs.projectId);
|
|
48
|
+
return db.select().from(userMcpConfigs).where(
|
|
49
|
+
and(eq(userMcpConfigs.userId, userId), scopeCondition),
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Get a single config by user, scope, and type. */
|
|
54
|
+
getConfig = async (userId: string, configType: string, projectId?: string | null): Promise<McpConfigRecord | null> => {
|
|
55
|
+
const db = this.getDb();
|
|
56
|
+
const id = this.makeId(userId, configType, projectId);
|
|
57
|
+
const [row] = await db.select().from(userMcpConfigs).where(eq(userMcpConfigs.id, id));
|
|
58
|
+
return row || null;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Create or update a config. Validates JSON before storing. */
|
|
62
|
+
upsertConfig = async (userId: string, configType: string, mcpServersJson: string, projectId?: string | null): Promise<McpConfigRecord> => {
|
|
63
|
+
try {
|
|
64
|
+
JSON.parse(mcpServersJson);
|
|
65
|
+
} catch {
|
|
66
|
+
throw new Error('Invalid JSON in mcpServersJson');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (configType !== 'localhost' && configType !== 'remote') {
|
|
70
|
+
throw new Error('configType must be "localhost" or "remote"');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const db = this.getDb();
|
|
74
|
+
const id = this.makeId(userId, configType, projectId);
|
|
75
|
+
const now = new Date().toISOString();
|
|
76
|
+
|
|
77
|
+
const existing = await this.getConfig(userId, configType, projectId);
|
|
78
|
+
|
|
79
|
+
if (existing) {
|
|
80
|
+
await db.update(userMcpConfigs)
|
|
81
|
+
.set({mcpServersJson, updatedAt: now})
|
|
82
|
+
.where(eq(userMcpConfigs.id, id));
|
|
83
|
+
this.log('MCP_CONFIG', `Updated ${configType} config for user ${userId} (project: ${projectId || 'global'})`);
|
|
84
|
+
return {...existing, mcpServersJson, updatedAt: now};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const record: McpConfigRecord = {
|
|
88
|
+
id,
|
|
89
|
+
userId,
|
|
90
|
+
projectId: projectId || null,
|
|
91
|
+
configType,
|
|
92
|
+
mcpServersJson,
|
|
93
|
+
createdAt: now,
|
|
94
|
+
updatedAt: now,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
await db.insert(userMcpConfigs).values(record);
|
|
98
|
+
this.log('MCP_CONFIG', `Created ${configType} config for user ${userId} (project: ${projectId || 'global'})`);
|
|
99
|
+
return record;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** Delete a config. */
|
|
103
|
+
deleteConfig = async (userId: string, configType: string, projectId?: string | null): Promise<boolean> => {
|
|
104
|
+
const db = this.getDb();
|
|
105
|
+
const id = this.makeId(userId, configType, projectId);
|
|
106
|
+
const existing = await this.getConfig(userId, configType, projectId);
|
|
107
|
+
if (!existing) return false;
|
|
108
|
+
|
|
109
|
+
await db.delete(userMcpConfigs).where(eq(userMcpConfigs.id, id));
|
|
110
|
+
this.log('MCP_CONFIG', `Deleted ${configType} config for user ${userId} (project: ${projectId || 'global'})`);
|
|
111
|
+
return true;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Build a merged mcpServers object for a user + project based on the current environment.
|
|
116
|
+
* Merges global configs first, then project-specific configs on top (project overrides global).
|
|
117
|
+
* In Docker: only remote configs are included.
|
|
118
|
+
* Locally: both localhost and remote configs are merged.
|
|
119
|
+
*/
|
|
120
|
+
buildMergedConfig = async (userId: string, projectId?: string | null): Promise<Record<string, unknown> | null> => {
|
|
121
|
+
const db = this.getDb();
|
|
122
|
+
|
|
123
|
+
// Load global + project-scoped configs in one query
|
|
124
|
+
const condition = projectId
|
|
125
|
+
? and(
|
|
126
|
+
eq(userMcpConfigs.userId, userId),
|
|
127
|
+
or(isNull(userMcpConfigs.projectId), eq(userMcpConfigs.projectId, projectId)),
|
|
128
|
+
)
|
|
129
|
+
: and(eq(userMcpConfigs.userId, userId), isNull(userMcpConfigs.projectId));
|
|
130
|
+
|
|
131
|
+
const configs: McpConfigRecord[] = await db.select().from(userMcpConfigs).where(condition);
|
|
132
|
+
if (configs.length === 0) return null;
|
|
133
|
+
|
|
134
|
+
const isDocker = process.env.DOCKER === '1' || existsSync('/.dockerenv');
|
|
135
|
+
|
|
136
|
+
const merged: Record<string, unknown> = {};
|
|
137
|
+
|
|
138
|
+
// Apply global configs first, then project-specific (so project overrides global)
|
|
139
|
+
const globalConfigs = configs.filter(c => !c.projectId);
|
|
140
|
+
const projectConfigs = configs.filter(c => !!c.projectId);
|
|
141
|
+
|
|
142
|
+
for (const cfg of [...globalConfigs, ...projectConfigs]) {
|
|
143
|
+
if (isDocker && cfg.configType === 'localhost') continue;
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const servers = JSON.parse(cfg.mcpServersJson);
|
|
147
|
+
if (servers && typeof servers === 'object') {
|
|
148
|
+
Object.assign(merged, servers);
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
// Skip invalid JSON entries
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return Object.keys(merged).length > 0 ? merged : null;
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -941,6 +941,43 @@ export class ApiClient {
|
|
|
941
941
|
return resp.json();
|
|
942
942
|
};
|
|
943
943
|
|
|
944
|
+
// --- MCP Config ---
|
|
945
|
+
|
|
946
|
+
/** Fetch MCP configs for the authenticated user. Pass projectId for project-scoped, omit for global. */
|
|
947
|
+
getMcpConfigs = async (projectId?: string | null): Promise<{ configs: Array<{ id: string; userId: string; projectId: string | null; configType: string; mcpServersJson: string; createdAt: string; updatedAt: string }> }> => {
|
|
948
|
+
const qs = projectId ? `?projectId=${encodeURIComponent(projectId)}` : '';
|
|
949
|
+
const resp = await this.fetchWithRefresh(`${this.baseUrl}/api/mcp-config${qs}`);
|
|
950
|
+
if (!resp.ok) throw new Error(`Failed to fetch MCP configs: ${resp.status}`);
|
|
951
|
+
return resp.json();
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
/** Save (create or update) an MCP config. Pass projectId for project-scoped, omit for global. */
|
|
955
|
+
saveMcpConfig = async (configType: 'localhost' | 'remote', mcpServersJson: string, projectId?: string | null): Promise<{ config: any }> => {
|
|
956
|
+
const qs = projectId ? `?projectId=${encodeURIComponent(projectId)}` : '';
|
|
957
|
+
const resp = await this.fetchWithRefresh(`${this.baseUrl}/api/mcp-config/${configType}${qs}`, {
|
|
958
|
+
method: 'PUT',
|
|
959
|
+
headers: { 'Content-Type': 'application/json' },
|
|
960
|
+
body: JSON.stringify({ mcpServersJson }),
|
|
961
|
+
});
|
|
962
|
+
if (!resp.ok) {
|
|
963
|
+
const err = await resp.json().catch(() => ({}));
|
|
964
|
+
throw new Error(err.error || `Failed to save MCP config: ${resp.status}`);
|
|
965
|
+
}
|
|
966
|
+
return resp.json();
|
|
967
|
+
};
|
|
968
|
+
|
|
969
|
+
/** Delete an MCP config. Pass projectId for project-scoped, omit for global. */
|
|
970
|
+
deleteMcpConfig = async (configType: 'localhost' | 'remote', projectId?: string | null): Promise<void> => {
|
|
971
|
+
const qs = projectId ? `?projectId=${encodeURIComponent(projectId)}` : '';
|
|
972
|
+
const resp = await this.fetchWithRefresh(`${this.baseUrl}/api/mcp-config/${configType}${qs}`, {
|
|
973
|
+
method: 'DELETE',
|
|
974
|
+
});
|
|
975
|
+
if (!resp.ok) {
|
|
976
|
+
const err = await resp.json().catch(() => ({}));
|
|
977
|
+
throw new Error(err.error || `Failed to delete MCP config: ${resp.status}`);
|
|
978
|
+
}
|
|
979
|
+
};
|
|
980
|
+
|
|
944
981
|
// --- File system ---
|
|
945
982
|
|
|
946
983
|
fetchFileTree = async (projectId: string) => {
|
|
@@ -30,7 +30,8 @@ import {PermissionModeSelector} from './PermissionModeSelector';
|
|
|
30
30
|
import {AttachmentManager} from '../lib/attachment_manager';
|
|
31
31
|
import type {SkillInfo} from '../api/client';
|
|
32
32
|
import {apiClient} from '../api/client';
|
|
33
|
-
import {Check, ChevronDown, ChevronUp, Copy, Search, Settings2, X, Zap} from 'lucide-react';
|
|
33
|
+
import {Check, ChevronDown, ChevronUp, Copy, Search, Server, Settings2, X, Zap} from 'lucide-react';
|
|
34
|
+
import {McpConfigModal} from './McpConfigModal';
|
|
34
35
|
import {IconButton} from './ds/IconButton';
|
|
35
36
|
import {formatTokenCount} from '../lib/context_usage_helpers';
|
|
36
37
|
import {useFileTreeCache} from '../hooks/use_file_tree_cache';
|
|
@@ -123,6 +124,7 @@ export function ChatView({ visible }: ChatViewProps) {
|
|
|
123
124
|
useEffect(() => { localStorage.setItem('chat_allowed_tools', JSON.stringify(allowedTools)); }, [allowedTools]);
|
|
124
125
|
|
|
125
126
|
const [showSettings, setShowSettings] = useState(false);
|
|
127
|
+
const [showMcpConfig, setShowMcpConfig] = useState(false);
|
|
126
128
|
const [isNewSession, setIsNewSession] = useState(true);
|
|
127
129
|
const [compacting, setCompacting] = useState(false);
|
|
128
130
|
const [copiedSessionId, setCopiedSessionId] = useState(false);
|
|
@@ -757,6 +759,14 @@ export function ChatView({ visible }: ChatViewProps) {
|
|
|
757
759
|
Compact
|
|
758
760
|
</button>
|
|
759
761
|
)}
|
|
762
|
+
<IconButton
|
|
763
|
+
label="MCP server config"
|
|
764
|
+
variant="ghost"
|
|
765
|
+
size="sm"
|
|
766
|
+
onClick={() => setShowMcpConfig(true)}
|
|
767
|
+
>
|
|
768
|
+
<Server size={14} strokeWidth={2} />
|
|
769
|
+
</IconButton>
|
|
760
770
|
<IconButton
|
|
761
771
|
label="Chat settings"
|
|
762
772
|
variant={showSettings ? 'accent' : 'ghost'}
|
|
@@ -826,6 +836,9 @@ export function ChatView({ visible }: ChatViewProps) {
|
|
|
826
836
|
}
|
|
827
837
|
onRespond={handlePermissionResponse}
|
|
828
838
|
/>
|
|
839
|
+
|
|
840
|
+
{/* MCP Config modal */}
|
|
841
|
+
{showMcpConfig && <McpConfigModal onClose={() => setShowMcpConfig(false)} />}
|
|
829
842
|
</div>
|
|
830
843
|
);
|
|
831
844
|
}
|