@assistkick/create 1.21.0 → 1.23.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 +19 -21
- package/templates/assistkick-product-system/packages/backend/src/services/chat_cli_bridge.ts +5 -5
- package/templates/assistkick-product-system/packages/backend/src/services/chat_ws_handler.ts +18 -6
- package/templates/assistkick-product-system/packages/backend/src/services/mcp_config_service.ts +53 -30
- package/templates/assistkick-product-system/packages/frontend/src/api/client.ts +12 -9
- package/templates/assistkick-product-system/packages/frontend/src/components/McpConfigModal.tsx +127 -28
- 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/0002_snapshot.json +997 -22
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/_journal.json +7 -0
- package/templates/assistkick-product-system/packages/shared/db/schema.ts +1 -0
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MCP config routes — per-user MCP server configuration management.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
6
9
|
*/
|
|
7
10
|
|
|
8
11
|
import { Router } from 'express';
|
|
@@ -16,16 +19,15 @@ interface McpConfigRoutesDeps {
|
|
|
16
19
|
export const createMcpConfigRoutes = ({ mcpConfigService, log }: McpConfigRoutesDeps): Router => {
|
|
17
20
|
const router: Router = Router();
|
|
18
21
|
|
|
19
|
-
// GET /api/mcp-config
|
|
22
|
+
// GET /api/mcp-config?projectId=...
|
|
20
23
|
router.get('/', async (req, res) => {
|
|
21
24
|
const userId = (req as any).user?.id;
|
|
22
|
-
if (!userId) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
25
|
+
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
|
|
26
|
+
|
|
27
|
+
const projectId = (req.query.projectId as string) || null;
|
|
26
28
|
|
|
27
29
|
try {
|
|
28
|
-
const configs = await mcpConfigService.getAllConfigs(userId);
|
|
30
|
+
const configs = await mcpConfigService.getAllConfigs(userId, projectId);
|
|
29
31
|
res.json({ configs });
|
|
30
32
|
} catch (err: any) {
|
|
31
33
|
log('MCP_CONFIG', `List configs failed: ${err.message}`);
|
|
@@ -33,15 +35,13 @@ export const createMcpConfigRoutes = ({ mcpConfigService, log }: McpConfigRoutes
|
|
|
33
35
|
}
|
|
34
36
|
});
|
|
35
37
|
|
|
36
|
-
// PUT /api/mcp-config/:type
|
|
38
|
+
// PUT /api/mcp-config/:type?projectId=...
|
|
37
39
|
router.put('/:type', async (req, res) => {
|
|
38
40
|
const userId = (req as any).user?.id;
|
|
39
|
-
if (!userId) {
|
|
40
|
-
res.status(401).json({ error: 'Unauthorized' });
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
41
|
+
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
|
|
43
42
|
|
|
44
43
|
const configType = req.params.type;
|
|
44
|
+
const projectId = (req.query.projectId as string) || (req.body.projectId as string) || null;
|
|
45
45
|
const { mcpServersJson } = req.body;
|
|
46
46
|
|
|
47
47
|
if (!mcpServersJson || typeof mcpServersJson !== 'string') {
|
|
@@ -50,7 +50,7 @@ export const createMcpConfigRoutes = ({ mcpConfigService, log }: McpConfigRoutes
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
try {
|
|
53
|
-
const config = await mcpConfigService.upsertConfig(userId, configType, mcpServersJson);
|
|
53
|
+
const config = await mcpConfigService.upsertConfig(userId, configType, mcpServersJson, projectId);
|
|
54
54
|
res.json({ config });
|
|
55
55
|
} catch (err: any) {
|
|
56
56
|
log('MCP_CONFIG', `Upsert config failed: ${err.message}`);
|
|
@@ -62,18 +62,16 @@ export const createMcpConfigRoutes = ({ mcpConfigService, log }: McpConfigRoutes
|
|
|
62
62
|
}
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
-
// DELETE /api/mcp-config/:type
|
|
65
|
+
// DELETE /api/mcp-config/:type?projectId=...
|
|
66
66
|
router.delete('/:type', async (req, res) => {
|
|
67
67
|
const userId = (req as any).user?.id;
|
|
68
|
-
if (!userId) {
|
|
69
|
-
res.status(401).json({ error: 'Unauthorized' });
|
|
70
|
-
return;
|
|
71
|
-
}
|
|
68
|
+
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
|
|
72
69
|
|
|
73
70
|
const configType = req.params.type;
|
|
71
|
+
const projectId = (req.query.projectId as string) || null;
|
|
74
72
|
|
|
75
73
|
try {
|
|
76
|
-
const deleted = await mcpConfigService.deleteConfig(userId, configType);
|
|
74
|
+
const deleted = await mcpConfigService.deleteConfig(userId, configType, projectId);
|
|
77
75
|
if (!deleted) {
|
|
78
76
|
res.status(404).json({ error: 'Config not found' });
|
|
79
77
|
return;
|
package/templates/assistkick-product-system/packages/backend/src/services/chat_cli_bridge.ts
CHANGED
|
@@ -33,8 +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
|
-
/**
|
|
37
|
-
|
|
36
|
+
/** Path to a temporary MCP config JSON file to pass via --mcp-config */
|
|
37
|
+
mcpConfigPath?: string;
|
|
38
38
|
onEvent: (event: Record<string, unknown>) => void;
|
|
39
39
|
}
|
|
40
40
|
|
|
@@ -92,9 +92,9 @@ export class ChatCliBridge {
|
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
// MCP server configuration (
|
|
96
|
-
if (options.
|
|
97
|
-
args.push('--mcp-config', options.
|
|
95
|
+
// MCP server configuration (temp file path)
|
|
96
|
+
if (options.mcpConfigPath) {
|
|
97
|
+
args.push('--mcp-config', options.mcpConfigPath);
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
// Prompt via stdin
|
package/templates/assistkick-product-system/packages/backend/src/services/chat_ws_handler.ts
CHANGED
|
@@ -30,6 +30,9 @@ 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
32
|
import type { McpConfigService } from './mcp_config_service.js';
|
|
33
|
+
import { writeFileSync, unlinkSync } from 'node:fs';
|
|
34
|
+
import { tmpdir } from 'node:os';
|
|
35
|
+
import { join } from 'node:path';
|
|
33
36
|
|
|
34
37
|
export interface ChatWsHandlerDeps {
|
|
35
38
|
wss: WebSocketServer;
|
|
@@ -425,15 +428,18 @@ export class ChatWsHandler {
|
|
|
425
428
|
}
|
|
426
429
|
}
|
|
427
430
|
|
|
428
|
-
// Build
|
|
429
|
-
let
|
|
431
|
+
// Build MCP config temp file for this spawn (if user has MCP servers configured)
|
|
432
|
+
let mcpConfigPath: string | undefined;
|
|
433
|
+
let mcpConfigContent: string | undefined;
|
|
430
434
|
const userId = this.wsUserIds.get(ws);
|
|
431
435
|
if (userId && this.mcpConfigService) {
|
|
432
436
|
try {
|
|
433
|
-
const mergedServers = await this.mcpConfigService.buildMergedConfig(userId);
|
|
437
|
+
const mergedServers = await this.mcpConfigService.buildMergedConfig(userId, projectId);
|
|
434
438
|
if (mergedServers) {
|
|
435
|
-
|
|
436
|
-
|
|
439
|
+
mcpConfigContent = JSON.stringify({ mcpServers: mergedServers }, null, 2);
|
|
440
|
+
mcpConfigPath = join(tmpdir(), `mcp-${claudeSessionId}.json`);
|
|
441
|
+
writeFileSync(mcpConfigPath, mcpConfigContent, 'utf-8');
|
|
442
|
+
this.log('CHAT_WS', `MCP config for user ${userId}: ${mcpConfigContent}`);
|
|
437
443
|
}
|
|
438
444
|
} catch (err: any) {
|
|
439
445
|
this.log('CHAT_WS', `Failed to build MCP config: ${err.message}`);
|
|
@@ -449,7 +455,7 @@ export class ChatWsHandler {
|
|
|
449
455
|
permissionMode,
|
|
450
456
|
allowedTools: msg.allowedTools,
|
|
451
457
|
continuationContext,
|
|
452
|
-
|
|
458
|
+
mcpConfigPath,
|
|
453
459
|
onEvent: (event) => {
|
|
454
460
|
// Forward to current WS (may have been re-attached after disconnect)
|
|
455
461
|
const currentWs = this.sessionToWs.get(claudeSessionId);
|
|
@@ -542,6 +548,11 @@ export class ChatWsHandler {
|
|
|
542
548
|
|
|
543
549
|
completion
|
|
544
550
|
.then((result) => {
|
|
551
|
+
// Clean up MCP config temp file
|
|
552
|
+
if (mcpConfigPath) {
|
|
553
|
+
try { unlinkSync(mcpConfigPath); } catch { /* already deleted */ }
|
|
554
|
+
}
|
|
555
|
+
|
|
545
556
|
// Look up the current WS — may differ from the original if client reconnected
|
|
546
557
|
const currentWs = this.sessionToWs.get(claudeSessionId);
|
|
547
558
|
if (currentWs) {
|
|
@@ -581,6 +592,7 @@ export class ChatWsHandler {
|
|
|
581
592
|
const parts: string[] = [];
|
|
582
593
|
if (result.stderr) parts.push(result.stderr);
|
|
583
594
|
if (result.stdoutNonJsonLines) parts.push(result.stdoutNonJsonLines);
|
|
595
|
+
if (mcpConfigContent) parts.push(`MCP config passed: ${mcpConfigContent}`);
|
|
584
596
|
errorDetail = parts.length > 0
|
|
585
597
|
? parts.join('\n')
|
|
586
598
|
: `CLI exited with code ${result.exitCode}`;
|
package/templates/assistkick-product-system/packages/backend/src/services/mcp_config_service.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* McpConfigService — CRUD operations for per-user MCP server configurations.
|
|
3
|
-
* Each user has two config slots: 'localhost' (
|
|
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).
|
|
4
5
|
* Uses deterministic IDs to enforce uniqueness at the PK level.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
|
-
import {eq} from 'drizzle-orm';
|
|
8
|
+
import {eq, and, isNull, or} from 'drizzle-orm';
|
|
8
9
|
import {userMcpConfigs} from '@assistkick/shared/db/schema.js';
|
|
10
|
+
import {existsSync} from 'node:fs';
|
|
9
11
|
|
|
10
12
|
interface McpConfigServiceDeps {
|
|
11
13
|
getDb: () => any;
|
|
@@ -15,6 +17,7 @@ interface McpConfigServiceDeps {
|
|
|
15
17
|
export interface McpConfigRecord {
|
|
16
18
|
id: string;
|
|
17
19
|
userId: string;
|
|
20
|
+
projectId: string | null;
|
|
18
21
|
configType: string;
|
|
19
22
|
mcpServersJson: string;
|
|
20
23
|
createdAt: string;
|
|
@@ -25,33 +28,38 @@ export class McpConfigService {
|
|
|
25
28
|
private readonly getDb: () => any;
|
|
26
29
|
private readonly log: McpConfigServiceDeps['log'];
|
|
27
30
|
|
|
28
|
-
constructor({
|
|
31
|
+
constructor({getDb, log}: McpConfigServiceDeps) {
|
|
29
32
|
this.getDb = getDb;
|
|
30
33
|
this.log = log;
|
|
31
34
|
}
|
|
32
35
|
|
|
33
|
-
/** Generate a deterministic ID to enforce (userId, configType) uniqueness. */
|
|
34
|
-
private makeId = (userId: string, configType: string): string => {
|
|
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}`;
|
|
36
40
|
};
|
|
37
41
|
|
|
38
|
-
/** Get all MCP configs for a user
|
|
39
|
-
getAllConfigs = async (userId: string): Promise<McpConfigRecord[]> => {
|
|
42
|
+
/** Get all MCP configs for a user at a given scope. */
|
|
43
|
+
getAllConfigs = async (userId: string, projectId?: string | null): Promise<McpConfigRecord[]> => {
|
|
40
44
|
const db = this.getDb();
|
|
41
|
-
|
|
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
|
+
);
|
|
42
51
|
};
|
|
43
52
|
|
|
44
|
-
/** Get a single config by user and type. */
|
|
45
|
-
getConfig = async (userId: string, configType: string): Promise<McpConfigRecord | null> => {
|
|
53
|
+
/** Get a single config by user, scope, and type. */
|
|
54
|
+
getConfig = async (userId: string, configType: string, projectId?: string | null): Promise<McpConfigRecord | null> => {
|
|
46
55
|
const db = this.getDb();
|
|
47
|
-
const id = this.makeId(userId, configType);
|
|
56
|
+
const id = this.makeId(userId, configType, projectId);
|
|
48
57
|
const [row] = await db.select().from(userMcpConfigs).where(eq(userMcpConfigs.id, id));
|
|
49
58
|
return row || null;
|
|
50
59
|
};
|
|
51
60
|
|
|
52
61
|
/** Create or update a config. Validates JSON before storing. */
|
|
53
|
-
upsertConfig = async (userId: string, configType: string, mcpServersJson: string): Promise<McpConfigRecord> => {
|
|
54
|
-
// Validate JSON
|
|
62
|
+
upsertConfig = async (userId: string, configType: string, mcpServersJson: string, projectId?: string | null): Promise<McpConfigRecord> => {
|
|
55
63
|
try {
|
|
56
64
|
JSON.parse(mcpServersJson);
|
|
57
65
|
} catch {
|
|
@@ -63,22 +71,23 @@ export class McpConfigService {
|
|
|
63
71
|
}
|
|
64
72
|
|
|
65
73
|
const db = this.getDb();
|
|
66
|
-
const id = this.makeId(userId, configType);
|
|
74
|
+
const id = this.makeId(userId, configType, projectId);
|
|
67
75
|
const now = new Date().toISOString();
|
|
68
76
|
|
|
69
|
-
const existing = await this.getConfig(userId, configType);
|
|
77
|
+
const existing = await this.getConfig(userId, configType, projectId);
|
|
70
78
|
|
|
71
79
|
if (existing) {
|
|
72
80
|
await db.update(userMcpConfigs)
|
|
73
|
-
.set({
|
|
81
|
+
.set({mcpServersJson, updatedAt: now})
|
|
74
82
|
.where(eq(userMcpConfigs.id, id));
|
|
75
|
-
this.log('MCP_CONFIG', `Updated ${configType} config for user ${userId}`);
|
|
76
|
-
return {
|
|
83
|
+
this.log('MCP_CONFIG', `Updated ${configType} config for user ${userId} (project: ${projectId || 'global'})`);
|
|
84
|
+
return {...existing, mcpServersJson, updatedAt: now};
|
|
77
85
|
}
|
|
78
86
|
|
|
79
87
|
const record: McpConfigRecord = {
|
|
80
88
|
id,
|
|
81
89
|
userId,
|
|
90
|
+
projectId: projectId || null,
|
|
82
91
|
configType,
|
|
83
92
|
mcpServersJson,
|
|
84
93
|
createdAt: now,
|
|
@@ -86,37 +95,51 @@ export class McpConfigService {
|
|
|
86
95
|
};
|
|
87
96
|
|
|
88
97
|
await db.insert(userMcpConfigs).values(record);
|
|
89
|
-
this.log('MCP_CONFIG', `Created ${configType} config for user ${userId}`);
|
|
98
|
+
this.log('MCP_CONFIG', `Created ${configType} config for user ${userId} (project: ${projectId || 'global'})`);
|
|
90
99
|
return record;
|
|
91
100
|
};
|
|
92
101
|
|
|
93
102
|
/** Delete a config. */
|
|
94
|
-
deleteConfig = async (userId: string, configType: string): Promise<boolean> => {
|
|
103
|
+
deleteConfig = async (userId: string, configType: string, projectId?: string | null): Promise<boolean> => {
|
|
95
104
|
const db = this.getDb();
|
|
96
|
-
const id = this.makeId(userId, configType);
|
|
97
|
-
const existing = await this.getConfig(userId, configType);
|
|
105
|
+
const id = this.makeId(userId, configType, projectId);
|
|
106
|
+
const existing = await this.getConfig(userId, configType, projectId);
|
|
98
107
|
if (!existing) return false;
|
|
99
108
|
|
|
100
109
|
await db.delete(userMcpConfigs).where(eq(userMcpConfigs.id, id));
|
|
101
|
-
this.log('MCP_CONFIG', `Deleted ${configType} config for user ${userId}`);
|
|
110
|
+
this.log('MCP_CONFIG', `Deleted ${configType} config for user ${userId} (project: ${projectId || 'global'})`);
|
|
102
111
|
return true;
|
|
103
112
|
};
|
|
104
113
|
|
|
105
114
|
/**
|
|
106
|
-
* Build a merged mcpServers object for a user based on the current environment.
|
|
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).
|
|
107
117
|
* In Docker: only remote configs are included.
|
|
108
118
|
* Locally: both localhost and remote configs are merged.
|
|
109
119
|
*/
|
|
110
|
-
buildMergedConfig = async (userId: string): Promise<Record<string, unknown> | null> => {
|
|
111
|
-
const
|
|
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);
|
|
112
132
|
if (configs.length === 0) return null;
|
|
113
133
|
|
|
114
|
-
const isDocker = process.env.DOCKER === '1'
|
|
115
|
-
|| (await import('node:fs')).existsSync('/.dockerenv');
|
|
134
|
+
const isDocker = process.env.DOCKER === '1' || existsSync('/.dockerenv');
|
|
116
135
|
|
|
117
136
|
const merged: Record<string, unknown> = {};
|
|
118
137
|
|
|
119
|
-
|
|
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]) {
|
|
120
143
|
if (isDocker && cfg.configType === 'localhost') continue;
|
|
121
144
|
|
|
122
145
|
try {
|
|
@@ -943,16 +943,18 @@ export class ApiClient {
|
|
|
943
943
|
|
|
944
944
|
// --- MCP Config ---
|
|
945
945
|
|
|
946
|
-
/** Fetch
|
|
947
|
-
getMcpConfigs = async (): Promise<{ configs: Array<{ id: string; userId: string; configType: string; mcpServersJson: string; createdAt: string; updatedAt: string }> }> => {
|
|
948
|
-
const
|
|
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}`);
|
|
949
950
|
if (!resp.ok) throw new Error(`Failed to fetch MCP configs: ${resp.status}`);
|
|
950
951
|
return resp.json();
|
|
951
952
|
};
|
|
952
953
|
|
|
953
|
-
/** Save (create or update) an MCP config for
|
|
954
|
-
saveMcpConfig = async (configType: 'localhost' | 'remote', mcpServersJson: string): Promise<{ config: any }> => {
|
|
955
|
-
const
|
|
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}`, {
|
|
956
958
|
method: 'PUT',
|
|
957
959
|
headers: { 'Content-Type': 'application/json' },
|
|
958
960
|
body: JSON.stringify({ mcpServersJson }),
|
|
@@ -964,9 +966,10 @@ export class ApiClient {
|
|
|
964
966
|
return resp.json();
|
|
965
967
|
};
|
|
966
968
|
|
|
967
|
-
/** Delete an MCP config for
|
|
968
|
-
deleteMcpConfig = async (configType: 'localhost' | 'remote'): Promise<void> => {
|
|
969
|
-
const
|
|
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}`, {
|
|
970
973
|
method: 'DELETE',
|
|
971
974
|
});
|
|
972
975
|
if (!resp.ok) {
|
package/templates/assistkick-product-system/packages/frontend/src/components/McpConfigModal.tsx
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* McpConfigModal — Modal dialog for editing per-user MCP server configurations.
|
|
3
|
-
*
|
|
3
|
+
* Scope toggle: Global (user-level) or Project (project-scoped).
|
|
4
|
+
* Two tabs per scope: Localhost (local dev only) and Remote (all environments).
|
|
4
5
|
* Each tab has a JSON textarea editor with validation and save functionality.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
|
-
import {
|
|
8
|
-
import { X
|
|
9
|
-
import {
|
|
8
|
+
import {useCallback, useEffect, useState} from 'react';
|
|
9
|
+
import {Globe, X} from 'lucide-react';
|
|
10
|
+
import {apiClient} from '../api/client';
|
|
11
|
+
import {useProjectStore} from '../stores/useProjectStore';
|
|
10
12
|
|
|
11
13
|
interface McpConfigModalProps {
|
|
12
14
|
onClose: () => void;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
type ConfigTab = 'localhost' | 'remote';
|
|
18
|
+
type Scope = 'global' | 'project';
|
|
16
19
|
|
|
17
20
|
const PLACEHOLDER_CONFIG = JSON.stringify(
|
|
18
21
|
{
|
|
@@ -24,10 +27,18 @@ const PLACEHOLDER_CONFIG = JSON.stringify(
|
|
|
24
27
|
2,
|
|
25
28
|
);
|
|
26
29
|
|
|
27
|
-
export function McpConfigModal({
|
|
30
|
+
export function McpConfigModal({onClose}: McpConfigModalProps) {
|
|
31
|
+
const selectedProjectId = useProjectStore((s) => s.selectedProjectId);
|
|
32
|
+
|
|
33
|
+
const [scope, setScope] = useState<Scope>('global');
|
|
28
34
|
const [activeTab, setActiveTab] = useState<ConfigTab>('localhost');
|
|
29
|
-
|
|
30
|
-
|
|
35
|
+
|
|
36
|
+
// Separate state for global and project configs
|
|
37
|
+
const [globalLocalhostJson, setGlobalLocalhostJson] = useState('{}');
|
|
38
|
+
const [globalRemoteJson, setGlobalRemoteJson] = useState('{}');
|
|
39
|
+
const [projectLocalhostJson, setProjectLocalhostJson] = useState('{}');
|
|
40
|
+
const [projectRemoteJson, setProjectRemoteJson] = useState('{}');
|
|
41
|
+
|
|
31
42
|
const [saving, setSaving] = useState(false);
|
|
32
43
|
const [error, setError] = useState<string | null>(null);
|
|
33
44
|
const [success, setSuccess] = useState<string | null>(null);
|
|
@@ -36,11 +47,22 @@ export function McpConfigModal({ onClose }: McpConfigModalProps) {
|
|
|
36
47
|
useEffect(() => {
|
|
37
48
|
const load = async () => {
|
|
38
49
|
try {
|
|
39
|
-
|
|
40
|
-
|
|
50
|
+
// Load global configs
|
|
51
|
+
const {configs: globalConfigs} = await apiClient.getMcpConfigs();
|
|
52
|
+
for (const cfg of globalConfigs) {
|
|
41
53
|
const formatted = JSON.stringify(JSON.parse(cfg.mcpServersJson), null, 2);
|
|
42
|
-
if (cfg.configType === 'localhost')
|
|
43
|
-
if (cfg.configType === 'remote')
|
|
54
|
+
if (cfg.configType === 'localhost') setGlobalLocalhostJson(formatted);
|
|
55
|
+
if (cfg.configType === 'remote') setGlobalRemoteJson(formatted);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Load project configs if a project is selected
|
|
59
|
+
if (selectedProjectId) {
|
|
60
|
+
const {configs: projectConfigs} = await apiClient.getMcpConfigs(selectedProjectId);
|
|
61
|
+
for (const cfg of projectConfigs) {
|
|
62
|
+
const formatted = JSON.stringify(JSON.parse(cfg.mcpServersJson), null, 2);
|
|
63
|
+
if (cfg.configType === 'localhost') setProjectLocalhostJson(formatted);
|
|
64
|
+
if (cfg.configType === 'remote') setProjectRemoteJson(formatted);
|
|
65
|
+
}
|
|
44
66
|
}
|
|
45
67
|
} catch (err: any) {
|
|
46
68
|
setError(`Failed to load configs: ${err.message}`);
|
|
@@ -49,10 +71,24 @@ export function McpConfigModal({ onClose }: McpConfigModalProps) {
|
|
|
49
71
|
}
|
|
50
72
|
};
|
|
51
73
|
load();
|
|
52
|
-
}, []);
|
|
74
|
+
}, [selectedProjectId]);
|
|
75
|
+
|
|
76
|
+
const getCurrentJson = (): string => {
|
|
77
|
+
if (scope === 'global') return activeTab === 'localhost' ? globalLocalhostJson : globalRemoteJson;
|
|
78
|
+
return activeTab === 'localhost' ? projectLocalhostJson : projectRemoteJson;
|
|
79
|
+
};
|
|
53
80
|
|
|
54
|
-
const
|
|
55
|
-
|
|
81
|
+
const setCurrentJson = (value: string) => {
|
|
82
|
+
if (scope === 'global') {
|
|
83
|
+
if (activeTab === 'localhost') setGlobalLocalhostJson(value);
|
|
84
|
+
else setGlobalRemoteJson(value);
|
|
85
|
+
} else {
|
|
86
|
+
if (activeTab === 'localhost') setProjectLocalhostJson(value);
|
|
87
|
+
else setProjectRemoteJson(value);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const currentJson = getCurrentJson();
|
|
56
92
|
|
|
57
93
|
const validateJson = useCallback((json: string): string | null => {
|
|
58
94
|
try {
|
|
@@ -67,7 +103,7 @@ export function McpConfigModal({ onClose }: McpConfigModalProps) {
|
|
|
67
103
|
}, []);
|
|
68
104
|
|
|
69
105
|
const handleSave = useCallback(async () => {
|
|
70
|
-
const jsonToSave =
|
|
106
|
+
const jsonToSave = getCurrentJson();
|
|
71
107
|
const validationError = validateJson(jsonToSave);
|
|
72
108
|
if (validationError) {
|
|
73
109
|
setError(validationError);
|
|
@@ -78,16 +114,20 @@ export function McpConfigModal({ onClose }: McpConfigModalProps) {
|
|
|
78
114
|
setError(null);
|
|
79
115
|
setSuccess(null);
|
|
80
116
|
|
|
117
|
+
const projectId = scope === 'project' ? selectedProjectId : null;
|
|
118
|
+
|
|
81
119
|
try {
|
|
82
|
-
await apiClient.saveMcpConfig(activeTab, jsonToSave);
|
|
83
|
-
|
|
120
|
+
await apiClient.saveMcpConfig(activeTab, jsonToSave, projectId);
|
|
121
|
+
const scopeLabel = scope === 'project' ? 'Project' : 'Global';
|
|
122
|
+
const typeLabel = activeTab === 'localhost' ? 'localhost' : 'remote';
|
|
123
|
+
setSuccess(`${scopeLabel} ${typeLabel} config saved`);
|
|
84
124
|
setTimeout(() => setSuccess(null), 3000);
|
|
85
125
|
} catch (err: any) {
|
|
86
126
|
setError(err.message);
|
|
87
127
|
} finally {
|
|
88
128
|
setSaving(false);
|
|
89
129
|
}
|
|
90
|
-
}, [activeTab,
|
|
130
|
+
}, [scope, activeTab, selectedProjectId, globalLocalhostJson, globalRemoteJson, projectLocalhostJson, projectRemoteJson, validateJson]);
|
|
91
131
|
|
|
92
132
|
const handleKeyDown = useCallback(
|
|
93
133
|
(e: React.KeyboardEvent) => {
|
|
@@ -105,7 +145,9 @@ export function McpConfigModal({ onClose }: McpConfigModalProps) {
|
|
|
105
145
|
return (
|
|
106
146
|
<div
|
|
107
147
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
|
|
108
|
-
onClick={(e) => {
|
|
148
|
+
onClick={(e) => {
|
|
149
|
+
if (e.target === e.currentTarget) onClose();
|
|
150
|
+
}}
|
|
109
151
|
onKeyDown={handleKeyDown}
|
|
110
152
|
>
|
|
111
153
|
<div className="bg-surface border border-edge rounded-lg shadow-xl w-[640px] max-h-[80vh] flex flex-col">
|
|
@@ -121,11 +163,63 @@ export function McpConfigModal({ onClose }: McpConfigModalProps) {
|
|
|
121
163
|
</button>
|
|
122
164
|
</div>
|
|
123
165
|
|
|
166
|
+
{/* Scope toggle */}
|
|
167
|
+
<div className="flex items-center gap-1 px-4 py-2 border-b border-edge">
|
|
168
|
+
<span className="text-[11px] text-content-muted mr-2">Scope:</span>
|
|
169
|
+
<button
|
|
170
|
+
type="button"
|
|
171
|
+
className={`h-6 px-2.5 rounded text-[11px] font-mono transition-colors ${
|
|
172
|
+
scope === 'global'
|
|
173
|
+
? 'bg-accent text-white'
|
|
174
|
+
: 'text-content-muted bg-transparent border border-edge hover:bg-surface-raised'
|
|
175
|
+
}`}
|
|
176
|
+
onClick={() => {
|
|
177
|
+
setScope('global');
|
|
178
|
+
setError(null);
|
|
179
|
+
setSuccess(null);
|
|
180
|
+
}}
|
|
181
|
+
>
|
|
182
|
+
Global
|
|
183
|
+
</button>
|
|
184
|
+
<button
|
|
185
|
+
type="button"
|
|
186
|
+
className={`h-6 px-2.5 rounded text-[11px] font-mono transition-colors ${
|
|
187
|
+
scope === 'project'
|
|
188
|
+
? 'bg-accent text-white'
|
|
189
|
+
: 'text-content-muted bg-transparent border border-edge hover:bg-surface-raised'
|
|
190
|
+
}`}
|
|
191
|
+
onClick={() => {
|
|
192
|
+
setScope('project');
|
|
193
|
+
setError(null);
|
|
194
|
+
setSuccess(null);
|
|
195
|
+
}}
|
|
196
|
+
disabled={!selectedProjectId}
|
|
197
|
+
title={selectedProjectId ? `Project: ${selectedProjectId}` : 'No project selected'}
|
|
198
|
+
>
|
|
199
|
+
Project
|
|
200
|
+
</button>
|
|
201
|
+
{scope === 'project' && selectedProjectId && (
|
|
202
|
+
<span className="text-[10px] text-content-muted ml-1 font-mono truncate max-w-[200px]">
|
|
203
|
+
{selectedProjectId}
|
|
204
|
+
</span>
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
|
|
124
208
|
{/* Description */}
|
|
125
209
|
<div className="px-4 py-2 text-[11px] text-content-muted border-b border-edge">
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
210
|
+
{scope === 'global' ? (
|
|
211
|
+
<>
|
|
212
|
+
<strong>Global</strong> configs apply to all projects for your user.{' '}
|
|
213
|
+
Project-specific configs are merged on top (project overrides global).
|
|
214
|
+
</>
|
|
215
|
+
) : (
|
|
216
|
+
<>
|
|
217
|
+
<strong>Project</strong> configs only apply to this project and override global configs
|
|
218
|
+
with the same server name.
|
|
219
|
+
</>
|
|
220
|
+
)}
|
|
221
|
+
{' '}<strong>Localhost</strong> servers are excluded in Docker.{' '}
|
|
222
|
+
<strong>Remote</strong> servers are used everywhere.
|
|
129
223
|
</div>
|
|
130
224
|
|
|
131
225
|
{/* Tabs */}
|
|
@@ -139,7 +233,11 @@ export function McpConfigModal({ onClose }: McpConfigModalProps) {
|
|
|
139
233
|
? 'text-accent border-b-2 border-accent bg-surface-alt'
|
|
140
234
|
: 'text-content-muted hover:text-content hover:bg-surface-raised'
|
|
141
235
|
}`}
|
|
142
|
-
onClick={() => {
|
|
236
|
+
onClick={() => {
|
|
237
|
+
setActiveTab(tab);
|
|
238
|
+
setError(null);
|
|
239
|
+
setSuccess(null);
|
|
240
|
+
}}
|
|
143
241
|
>
|
|
144
242
|
{tab === 'localhost' ? 'Localhost' : 'Remote'}
|
|
145
243
|
</button>
|
|
@@ -167,12 +265,13 @@ export function McpConfigModal({ onClose }: McpConfigModalProps) {
|
|
|
167
265
|
placeholder={PLACEHOLDER_CONFIG}
|
|
168
266
|
spellCheck={false}
|
|
169
267
|
/>
|
|
170
|
-
{jsonError &&
|
|
171
|
-
<p className="text-[11px] text-error mt-1">{jsonError}</p>
|
|
172
|
-
)}
|
|
268
|
+
{jsonError && <p className="text-[11px] text-error mt-1">{jsonError}</p>}
|
|
173
269
|
<p className="text-[10px] text-content-muted mt-1.5">
|
|
174
|
-
Enter the content of the
|
|
175
|
-
|
|
270
|
+
Enter the content of the{' '}
|
|
271
|
+
<code className="bg-surface-raised px-1 rounded">mcpServers</code> object. Example:{' '}
|
|
272
|
+
<code className="bg-surface-raised px-1 rounded">
|
|
273
|
+
{'{ "jetbrains": { "url": "http://localhost:6637/sse" } }'}
|
|
274
|
+
</code>
|
|
176
275
|
</p>
|
|
177
276
|
</>
|
|
178
277
|
)}
|
package/templates/assistkick-product-system/packages/shared/db/migrations/0002_noisy_maelstrom.sql
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE `user_mcp_configs` ADD `project_id` text;
|