@assistkick/create 1.19.0 → 1.21.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 +89 -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 +134 -0
- package/templates/assistkick-product-system/packages/frontend/src/api/client.ts +34 -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 +208 -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/meta/0001_snapshot.json +1019 -23
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/_journal.json +7 -0
- package/templates/assistkick-product-system/packages/shared/db/schema.ts +10 -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,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP config routes — per-user MCP server configuration management.
|
|
3
|
+
* GET /api/mcp-config — get all configs for the authenticated user
|
|
4
|
+
* PUT /api/mcp-config/:type — upsert a config (type = localhost | remote)
|
|
5
|
+
* DELETE /api/mcp-config/:type — delete a config
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Router } from 'express';
|
|
9
|
+
import type { McpConfigService } from '../services/mcp_config_service.js';
|
|
10
|
+
|
|
11
|
+
interface McpConfigRoutesDeps {
|
|
12
|
+
mcpConfigService: McpConfigService;
|
|
13
|
+
log: (tag: string, ...args: any[]) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const createMcpConfigRoutes = ({ mcpConfigService, log }: McpConfigRoutesDeps): Router => {
|
|
17
|
+
const router: Router = Router();
|
|
18
|
+
|
|
19
|
+
// GET /api/mcp-config — list all configs for the authenticated user
|
|
20
|
+
router.get('/', async (req, res) => {
|
|
21
|
+
const userId = (req as any).user?.id;
|
|
22
|
+
if (!userId) {
|
|
23
|
+
res.status(401).json({ error: 'Unauthorized' });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const configs = await mcpConfigService.getAllConfigs(userId);
|
|
29
|
+
res.json({ configs });
|
|
30
|
+
} catch (err: any) {
|
|
31
|
+
log('MCP_CONFIG', `List configs failed: ${err.message}`);
|
|
32
|
+
res.status(500).json({ error: 'Failed to list MCP configs' });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// PUT /api/mcp-config/:type — upsert a config
|
|
37
|
+
router.put('/:type', async (req, res) => {
|
|
38
|
+
const userId = (req as any).user?.id;
|
|
39
|
+
if (!userId) {
|
|
40
|
+
res.status(401).json({ error: 'Unauthorized' });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const configType = req.params.type;
|
|
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);
|
|
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 — delete a config
|
|
66
|
+
router.delete('/:type', async (req, res) => {
|
|
67
|
+
const userId = (req as any).user?.id;
|
|
68
|
+
if (!userId) {
|
|
69
|
+
res.status(401).json({ error: 'Unauthorized' });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const configType = req.params.type;
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const deleted = await mcpConfigService.deleteConfig(userId, configType);
|
|
77
|
+
if (!deleted) {
|
|
78
|
+
res.status(404).json({ error: 'Config not found' });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
res.json({ success: true });
|
|
82
|
+
} catch (err: any) {
|
|
83
|
+
log('MCP_CONFIG', `Delete config failed: ${err.message}`);
|
|
84
|
+
res.status(500).json({ error: 'Failed to delete MCP config' });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return router;
|
|
89
|
+
};
|
|
@@ -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);
|
|
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,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* McpConfigService — CRUD operations for per-user MCP server configurations.
|
|
3
|
+
* Each user has two config slots: 'localhost' (for local dev) and 'remote' (for Docker/cloud).
|
|
4
|
+
* Uses deterministic IDs to enforce uniqueness at the PK level.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {eq} from 'drizzle-orm';
|
|
8
|
+
import {userMcpConfigs} from '@assistkick/shared/db/schema.js';
|
|
9
|
+
|
|
10
|
+
interface McpConfigServiceDeps {
|
|
11
|
+
getDb: () => any;
|
|
12
|
+
log: (tag: string, ...args: unknown[]) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface McpConfigRecord {
|
|
16
|
+
id: string;
|
|
17
|
+
userId: string;
|
|
18
|
+
configType: string;
|
|
19
|
+
mcpServersJson: string;
|
|
20
|
+
createdAt: string;
|
|
21
|
+
updatedAt: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class McpConfigService {
|
|
25
|
+
private readonly getDb: () => any;
|
|
26
|
+
private readonly log: McpConfigServiceDeps['log'];
|
|
27
|
+
|
|
28
|
+
constructor({ getDb, log }: McpConfigServiceDeps) {
|
|
29
|
+
this.getDb = getDb;
|
|
30
|
+
this.log = log;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Generate a deterministic ID to enforce (userId, configType) uniqueness. */
|
|
34
|
+
private makeId = (userId: string, configType: string): string => {
|
|
35
|
+
return `mcpcfg_${userId}_${configType}`;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Get all MCP configs for a user (localhost + remote). */
|
|
39
|
+
getAllConfigs = async (userId: string): Promise<McpConfigRecord[]> => {
|
|
40
|
+
const db = this.getDb();
|
|
41
|
+
return db.select().from(userMcpConfigs).where(eq(userMcpConfigs.userId, userId));
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Get a single config by user and type. */
|
|
45
|
+
getConfig = async (userId: string, configType: string): Promise<McpConfigRecord | null> => {
|
|
46
|
+
const db = this.getDb();
|
|
47
|
+
const id = this.makeId(userId, configType);
|
|
48
|
+
const [row] = await db.select().from(userMcpConfigs).where(eq(userMcpConfigs.id, id));
|
|
49
|
+
return row || null;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Create or update a config. Validates JSON before storing. */
|
|
53
|
+
upsertConfig = async (userId: string, configType: string, mcpServersJson: string): Promise<McpConfigRecord> => {
|
|
54
|
+
// Validate JSON
|
|
55
|
+
try {
|
|
56
|
+
JSON.parse(mcpServersJson);
|
|
57
|
+
} catch {
|
|
58
|
+
throw new Error('Invalid JSON in mcpServersJson');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (configType !== 'localhost' && configType !== 'remote') {
|
|
62
|
+
throw new Error('configType must be "localhost" or "remote"');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const db = this.getDb();
|
|
66
|
+
const id = this.makeId(userId, configType);
|
|
67
|
+
const now = new Date().toISOString();
|
|
68
|
+
|
|
69
|
+
const existing = await this.getConfig(userId, configType);
|
|
70
|
+
|
|
71
|
+
if (existing) {
|
|
72
|
+
await db.update(userMcpConfigs)
|
|
73
|
+
.set({ mcpServersJson, updatedAt: now })
|
|
74
|
+
.where(eq(userMcpConfigs.id, id));
|
|
75
|
+
this.log('MCP_CONFIG', `Updated ${configType} config for user ${userId}`);
|
|
76
|
+
return { ...existing, mcpServersJson, updatedAt: now };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const record: McpConfigRecord = {
|
|
80
|
+
id,
|
|
81
|
+
userId,
|
|
82
|
+
configType,
|
|
83
|
+
mcpServersJson,
|
|
84
|
+
createdAt: now,
|
|
85
|
+
updatedAt: now,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
await db.insert(userMcpConfigs).values(record);
|
|
89
|
+
this.log('MCP_CONFIG', `Created ${configType} config for user ${userId}`);
|
|
90
|
+
return record;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** Delete a config. */
|
|
94
|
+
deleteConfig = async (userId: string, configType: string): Promise<boolean> => {
|
|
95
|
+
const db = this.getDb();
|
|
96
|
+
const id = this.makeId(userId, configType);
|
|
97
|
+
const existing = await this.getConfig(userId, configType);
|
|
98
|
+
if (!existing) return false;
|
|
99
|
+
|
|
100
|
+
await db.delete(userMcpConfigs).where(eq(userMcpConfigs.id, id));
|
|
101
|
+
this.log('MCP_CONFIG', `Deleted ${configType} config for user ${userId}`);
|
|
102
|
+
return true;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Build a merged mcpServers object for a user based on the current environment.
|
|
107
|
+
* In Docker: only remote configs are included.
|
|
108
|
+
* Locally: both localhost and remote configs are merged.
|
|
109
|
+
*/
|
|
110
|
+
buildMergedConfig = async (userId: string): Promise<Record<string, unknown> | null> => {
|
|
111
|
+
const configs = await this.getAllConfigs(userId);
|
|
112
|
+
if (configs.length === 0) return null;
|
|
113
|
+
|
|
114
|
+
const isDocker = process.env.DOCKER === '1'
|
|
115
|
+
|| (await import('node:fs')).existsSync('/.dockerenv');
|
|
116
|
+
|
|
117
|
+
const merged: Record<string, unknown> = {};
|
|
118
|
+
|
|
119
|
+
for (const cfg of configs) {
|
|
120
|
+
if (isDocker && cfg.configType === 'localhost') continue;
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const servers = JSON.parse(cfg.mcpServersJson);
|
|
124
|
+
if (servers && typeof servers === 'object') {
|
|
125
|
+
Object.assign(merged, servers);
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
// Skip invalid JSON entries
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return Object.keys(merged).length > 0 ? merged : null;
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -941,6 +941,40 @@ export class ApiClient {
|
|
|
941
941
|
return resp.json();
|
|
942
942
|
};
|
|
943
943
|
|
|
944
|
+
// --- MCP Config ---
|
|
945
|
+
|
|
946
|
+
/** Fetch all MCP configs for the authenticated user. */
|
|
947
|
+
getMcpConfigs = async (): Promise<{ configs: Array<{ id: string; userId: string; configType: string; mcpServersJson: string; createdAt: string; updatedAt: string }> }> => {
|
|
948
|
+
const resp = await this.fetchWithRefresh(`${this.baseUrl}/api/mcp-config`);
|
|
949
|
+
if (!resp.ok) throw new Error(`Failed to fetch MCP configs: ${resp.status}`);
|
|
950
|
+
return resp.json();
|
|
951
|
+
};
|
|
952
|
+
|
|
953
|
+
/** Save (create or update) an MCP config for the authenticated user. */
|
|
954
|
+
saveMcpConfig = async (configType: 'localhost' | 'remote', mcpServersJson: string): Promise<{ config: any }> => {
|
|
955
|
+
const resp = await this.fetchWithRefresh(`${this.baseUrl}/api/mcp-config/${configType}`, {
|
|
956
|
+
method: 'PUT',
|
|
957
|
+
headers: { 'Content-Type': 'application/json' },
|
|
958
|
+
body: JSON.stringify({ mcpServersJson }),
|
|
959
|
+
});
|
|
960
|
+
if (!resp.ok) {
|
|
961
|
+
const err = await resp.json().catch(() => ({}));
|
|
962
|
+
throw new Error(err.error || `Failed to save MCP config: ${resp.status}`);
|
|
963
|
+
}
|
|
964
|
+
return resp.json();
|
|
965
|
+
};
|
|
966
|
+
|
|
967
|
+
/** Delete an MCP config for the authenticated user. */
|
|
968
|
+
deleteMcpConfig = async (configType: 'localhost' | 'remote'): Promise<void> => {
|
|
969
|
+
const resp = await this.fetchWithRefresh(`${this.baseUrl}/api/mcp-config/${configType}`, {
|
|
970
|
+
method: 'DELETE',
|
|
971
|
+
});
|
|
972
|
+
if (!resp.ok) {
|
|
973
|
+
const err = await resp.json().catch(() => ({}));
|
|
974
|
+
throw new Error(err.error || `Failed to delete MCP config: ${resp.status}`);
|
|
975
|
+
}
|
|
976
|
+
};
|
|
977
|
+
|
|
944
978
|
// --- File system ---
|
|
945
979
|
|
|
946
980
|
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
|
}
|
package/templates/assistkick-product-system/packages/frontend/src/components/McpConfigModal.tsx
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* McpConfigModal — Modal dialog for editing per-user MCP server configurations.
|
|
3
|
+
* Two tabs: Localhost (for local dev) and Remote (for Docker/cloud deployments).
|
|
4
|
+
* Each tab has a JSON textarea editor with validation and save functionality.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
8
|
+
import { X } from 'lucide-react';
|
|
9
|
+
import { apiClient } from '../api/client';
|
|
10
|
+
|
|
11
|
+
interface McpConfigModalProps {
|
|
12
|
+
onClose: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type ConfigTab = 'localhost' | 'remote';
|
|
16
|
+
|
|
17
|
+
const PLACEHOLDER_CONFIG = JSON.stringify(
|
|
18
|
+
{
|
|
19
|
+
'example-server': {
|
|
20
|
+
url: 'http://localhost:8080/sse',
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
null,
|
|
24
|
+
2,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
export function McpConfigModal({ onClose }: McpConfigModalProps) {
|
|
28
|
+
const [activeTab, setActiveTab] = useState<ConfigTab>('localhost');
|
|
29
|
+
const [localhostJson, setLocalhostJson] = useState('{}');
|
|
30
|
+
const [remoteJson, setRemoteJson] = useState('{}');
|
|
31
|
+
const [saving, setSaving] = useState(false);
|
|
32
|
+
const [error, setError] = useState<string | null>(null);
|
|
33
|
+
const [success, setSuccess] = useState<string | null>(null);
|
|
34
|
+
const [loading, setLoading] = useState(true);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const load = async () => {
|
|
38
|
+
try {
|
|
39
|
+
const { configs } = await apiClient.getMcpConfigs();
|
|
40
|
+
for (const cfg of configs) {
|
|
41
|
+
const formatted = JSON.stringify(JSON.parse(cfg.mcpServersJson), null, 2);
|
|
42
|
+
if (cfg.configType === 'localhost') setLocalhostJson(formatted);
|
|
43
|
+
if (cfg.configType === 'remote') setRemoteJson(formatted);
|
|
44
|
+
}
|
|
45
|
+
} catch (err: any) {
|
|
46
|
+
setError(`Failed to load configs: ${err.message}`);
|
|
47
|
+
} finally {
|
|
48
|
+
setLoading(false);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
load();
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
const currentJson = activeTab === 'localhost' ? localhostJson : remoteJson;
|
|
55
|
+
const setCurrentJson = activeTab === 'localhost' ? setLocalhostJson : setRemoteJson;
|
|
56
|
+
|
|
57
|
+
const validateJson = useCallback((json: string): string | null => {
|
|
58
|
+
try {
|
|
59
|
+
const parsed = JSON.parse(json);
|
|
60
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
61
|
+
return 'Must be a JSON object (e.g. { "server-name": { ... } })';
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
} catch {
|
|
65
|
+
return 'Invalid JSON syntax';
|
|
66
|
+
}
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
const handleSave = useCallback(async () => {
|
|
70
|
+
const jsonToSave = activeTab === 'localhost' ? localhostJson : remoteJson;
|
|
71
|
+
const validationError = validateJson(jsonToSave);
|
|
72
|
+
if (validationError) {
|
|
73
|
+
setError(validationError);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
setSaving(true);
|
|
78
|
+
setError(null);
|
|
79
|
+
setSuccess(null);
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
await apiClient.saveMcpConfig(activeTab, jsonToSave);
|
|
83
|
+
setSuccess(`${activeTab === 'localhost' ? 'Localhost' : 'Remote'} config saved`);
|
|
84
|
+
setTimeout(() => setSuccess(null), 3000);
|
|
85
|
+
} catch (err: any) {
|
|
86
|
+
setError(err.message);
|
|
87
|
+
} finally {
|
|
88
|
+
setSaving(false);
|
|
89
|
+
}
|
|
90
|
+
}, [activeTab, localhostJson, remoteJson, validateJson]);
|
|
91
|
+
|
|
92
|
+
const handleKeyDown = useCallback(
|
|
93
|
+
(e: React.KeyboardEvent) => {
|
|
94
|
+
if (e.key === 'Escape') onClose();
|
|
95
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
|
96
|
+
e.preventDefault();
|
|
97
|
+
handleSave();
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
[onClose, handleSave],
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const jsonError = validateJson(currentJson);
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div
|
|
107
|
+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
|
|
108
|
+
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
|
109
|
+
onKeyDown={handleKeyDown}
|
|
110
|
+
>
|
|
111
|
+
<div className="bg-surface border border-edge rounded-lg shadow-xl w-[640px] max-h-[80vh] flex flex-col">
|
|
112
|
+
{/* Header */}
|
|
113
|
+
<div className="flex items-center justify-between px-4 py-3 border-b border-edge">
|
|
114
|
+
<h2 className="text-sm font-semibold text-content">MCP Server Configuration</h2>
|
|
115
|
+
<button
|
|
116
|
+
type="button"
|
|
117
|
+
onClick={onClose}
|
|
118
|
+
className="text-content-muted hover:text-content transition-colors"
|
|
119
|
+
>
|
|
120
|
+
<X size={16} />
|
|
121
|
+
</button>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
{/* Description */}
|
|
125
|
+
<div className="px-4 py-2 text-[11px] text-content-muted border-b border-edge">
|
|
126
|
+
Configure MCP servers for your chat sessions. <strong>Localhost</strong> servers are only
|
|
127
|
+
used in local development. <strong>Remote</strong> servers are used in all environments
|
|
128
|
+
(local + Docker).
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
{/* Tabs */}
|
|
132
|
+
<div className="flex border-b border-edge">
|
|
133
|
+
{(['localhost', 'remote'] as ConfigTab[]).map((tab) => (
|
|
134
|
+
<button
|
|
135
|
+
key={tab}
|
|
136
|
+
type="button"
|
|
137
|
+
className={`flex-1 px-4 py-2 text-xs font-mono transition-colors ${
|
|
138
|
+
activeTab === tab
|
|
139
|
+
? 'text-accent border-b-2 border-accent bg-surface-alt'
|
|
140
|
+
: 'text-content-muted hover:text-content hover:bg-surface-raised'
|
|
141
|
+
}`}
|
|
142
|
+
onClick={() => { setActiveTab(tab); setError(null); setSuccess(null); }}
|
|
143
|
+
>
|
|
144
|
+
{tab === 'localhost' ? 'Localhost' : 'Remote'}
|
|
145
|
+
</button>
|
|
146
|
+
))}
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
{/* Editor */}
|
|
150
|
+
<div className="flex-1 overflow-auto p-4">
|
|
151
|
+
{loading ? (
|
|
152
|
+
<div className="text-content-muted text-xs text-center py-8">Loading...</div>
|
|
153
|
+
) : (
|
|
154
|
+
<>
|
|
155
|
+
<label className="block text-[11px] text-content-muted mb-1.5">
|
|
156
|
+
mcpServers JSON ({activeTab === 'localhost' ? 'local dev only' : 'all environments'})
|
|
157
|
+
</label>
|
|
158
|
+
<textarea
|
|
159
|
+
className={`w-full h-64 bg-surface-alt border rounded font-mono text-xs text-content p-3 resize-y focus:outline-none focus:ring-1 ${
|
|
160
|
+
jsonError ? 'border-error focus:ring-error' : 'border-edge focus:ring-accent'
|
|
161
|
+
}`}
|
|
162
|
+
value={currentJson}
|
|
163
|
+
onChange={(e) => {
|
|
164
|
+
setCurrentJson(e.target.value);
|
|
165
|
+
setError(null);
|
|
166
|
+
}}
|
|
167
|
+
placeholder={PLACEHOLDER_CONFIG}
|
|
168
|
+
spellCheck={false}
|
|
169
|
+
/>
|
|
170
|
+
{jsonError && (
|
|
171
|
+
<p className="text-[11px] text-error mt-1">{jsonError}</p>
|
|
172
|
+
)}
|
|
173
|
+
<p className="text-[10px] text-content-muted mt-1.5">
|
|
174
|
+
Enter the content of the <code className="bg-surface-raised px-1 rounded">mcpServers</code> object.
|
|
175
|
+
Example: <code className="bg-surface-raised px-1 rounded">{'{ "jetbrains": { "url": "http://localhost:6637/sse" } }'}</code>
|
|
176
|
+
</p>
|
|
177
|
+
</>
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
{/* Footer */}
|
|
182
|
+
<div className="flex items-center justify-between px-4 py-3 border-t border-edge">
|
|
183
|
+
<div className="text-[11px]">
|
|
184
|
+
{error && <span className="text-error">{error}</span>}
|
|
185
|
+
{success && <span className="text-accent">{success}</span>}
|
|
186
|
+
</div>
|
|
187
|
+
<div className="flex gap-2">
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
onClick={onClose}
|
|
191
|
+
className="h-7 px-3 rounded text-[11px] font-mono text-content-secondary bg-transparent border border-edge hover:bg-surface-raised transition-colors"
|
|
192
|
+
>
|
|
193
|
+
Close
|
|
194
|
+
</button>
|
|
195
|
+
<button
|
|
196
|
+
type="button"
|
|
197
|
+
onClick={handleSave}
|
|
198
|
+
disabled={saving || !!jsonError || loading}
|
|
199
|
+
className="h-7 px-3 rounded text-[11px] font-mono text-white bg-accent hover:bg-accent/90 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
|
200
|
+
>
|
|
201
|
+
{saving ? 'Saving...' : 'Save'}
|
|
202
|
+
</button>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
);
|
|
208
|
+
}
|