@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistkick/create",
3
- "version": "1.21.0",
3
+ "version": "1.23.0",
4
4
  "description": "Scaffold assistkick-product-system into any project",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,8 +1,11 @@
1
1
  /**
2
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
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 — list all configs for the authenticated user
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
- res.status(401).json({ error: 'Unauthorized' });
24
- return;
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 — upsert a config
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 — delete a config
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;
@@ -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
- /** Inline MCP config JSON string to pass via --mcp-config */
37
- mcpConfigJson?: string;
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 (inline JSON string)
96
- if (options.mcpConfigJson) {
97
- args.push('--mcp-config', options.mcpConfigJson);
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
@@ -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 inline MCP config JSON for this spawn (if user has MCP servers configured)
429
- let mcpConfigJson: string | undefined;
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
- mcpConfigJson = JSON.stringify({ mcpServers: mergedServers });
436
- this.log('CHAT_WS', `MCP config: ${Object.keys(mergedServers).length} server(s) for user ${userId}`);
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
- mcpConfigJson,
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}`;
@@ -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' (for local dev) and 'remote' (for Docker/cloud).
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({ getDb, log }: McpConfigServiceDeps) {
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
- return `mcpcfg_${userId}_${configType}`;
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 (localhost + remote). */
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
- return db.select().from(userMcpConfigs).where(eq(userMcpConfigs.userId, userId));
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({ mcpServersJson, updatedAt: now })
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 { ...existing, mcpServersJson, updatedAt: now };
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 configs = await this.getAllConfigs(userId);
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
- for (const cfg of configs) {
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 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`);
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 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}`, {
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 the authenticated user. */
968
- deleteMcpConfig = async (configType: 'localhost' | 'remote'): Promise<void> => {
969
- const resp = await this.fetchWithRefresh(`${this.baseUrl}/api/mcp-config/${configType}`, {
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) {
@@ -1,18 +1,21 @@
1
1
  /**
2
2
  * McpConfigModal — Modal dialog for editing per-user MCP server configurations.
3
- * Two tabs: Localhost (for local dev) and Remote (for Docker/cloud deployments).
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 { useCallback, useEffect, useState } from 'react';
8
- import { X } from 'lucide-react';
9
- import { apiClient } from '../api/client';
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({ onClose }: McpConfigModalProps) {
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
- const [localhostJson, setLocalhostJson] = useState('{}');
30
- const [remoteJson, setRemoteJson] = useState('{}');
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
- const { configs } = await apiClient.getMcpConfigs();
40
- for (const cfg of configs) {
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') setLocalhostJson(formatted);
43
- if (cfg.configType === 'remote') setRemoteJson(formatted);
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 currentJson = activeTab === 'localhost' ? localhostJson : remoteJson;
55
- const setCurrentJson = activeTab === 'localhost' ? setLocalhostJson : setRemoteJson;
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 = activeTab === 'localhost' ? localhostJson : remoteJson;
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
- setSuccess(`${activeTab === 'localhost' ? 'Localhost' : 'Remote'} config saved`);
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, localhostJson, remoteJson, validateJson]);
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) => { if (e.target === e.currentTarget) onClose(); }}
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
- 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).
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={() => { setActiveTab(tab); setError(null); setSuccess(null); }}
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 <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>
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
  )}
@@ -0,0 +1 @@
1
+ ALTER TABLE `user_mcp_configs` ADD `project_id` text;