@assistkick/create 1.21.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 +19 -21
- package/templates/assistkick-product-system/packages/backend/src/services/chat_ws_handler.ts +1 -1
- 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_ws_handler.ts
CHANGED
|
@@ -430,7 +430,7 @@ export class ChatWsHandler {
|
|
|
430
430
|
const userId = this.wsUserIds.get(ws);
|
|
431
431
|
if (userId && this.mcpConfigService) {
|
|
432
432
|
try {
|
|
433
|
-
const mergedServers = await this.mcpConfigService.buildMergedConfig(userId);
|
|
433
|
+
const mergedServers = await this.mcpConfigService.buildMergedConfig(userId, projectId);
|
|
434
434
|
if (mergedServers) {
|
|
435
435
|
mcpConfigJson = JSON.stringify({ mcpServers: mergedServers });
|
|
436
436
|
this.log('CHAT_WS', `MCP config: ${Object.keys(mergedServers).length} server(s) for user ${userId}`);
|
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;
|