@assistkick/create 1.18.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.
Files changed (30) hide show
  1. package/package.json +1 -1
  2. package/templates/assistkick-product-system/packages/backend/src/routes/mcp_config.ts +89 -0
  3. package/templates/assistkick-product-system/packages/backend/src/server.ts +8 -1
  4. package/templates/assistkick-product-system/packages/backend/src/services/chat_cli_bridge.ts +7 -0
  5. package/templates/assistkick-product-system/packages/backend/src/services/chat_ws_handler.ts +26 -1
  6. package/templates/assistkick-product-system/packages/backend/src/services/mcp_config_service.ts +134 -0
  7. package/templates/assistkick-product-system/packages/frontend/src/api/client.ts +34 -0
  8. package/templates/assistkick-product-system/packages/frontend/src/components/ChatView.tsx +14 -1
  9. package/templates/assistkick-product-system/packages/frontend/src/components/McpConfigModal.tsx +208 -0
  10. package/templates/assistkick-product-system/packages/shared/db/migrations/0001_superb_roxanne_simpson.sql +8 -0
  11. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0001_snapshot.json +1019 -23
  12. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/_journal.json +7 -0
  13. package/templates/assistkick-product-system/packages/shared/db/schema.ts +10 -0
  14. package/templates/assistkick-product-system/packages/shared/lib/app_use_flow.ts +484 -0
  15. package/templates/assistkick-product-system/packages/shared/lib/openapi.ts +146 -0
  16. package/templates/assistkick-product-system/packages/shared/package.json +2 -1
  17. package/templates/assistkick-product-system/packages/shared/tools/agent_builder.ts +341 -0
  18. package/templates/assistkick-product-system/packages/shared/tools/app_use_record.ts +268 -0
  19. package/templates/assistkick-product-system/packages/shared/tools/app_use_run.ts +348 -0
  20. package/templates/assistkick-product-system/packages/shared/tools/app_use_validate.ts +67 -0
  21. package/templates/assistkick-product-system/packages/shared/tools/openapi_describe.ts +59 -0
  22. package/templates/assistkick-product-system/packages/shared/tools/openapi_list.ts +69 -0
  23. package/templates/assistkick-product-system/packages/shared/tools/openapi_schema.ts +67 -0
  24. package/templates/assistkick-product-system/packages/shared/tools/workflow_builder.ts +754 -0
  25. package/templates/skills/assistkick-agent-builder/SKILL.md +168 -0
  26. package/templates/skills/assistkick-app-use/SKILL.md +296 -0
  27. package/templates/skills/assistkick-app-use/references/agent-browser.md +1156 -0
  28. package/templates/skills/assistkick-openapi-explorer/SKILL.md +78 -0
  29. package/templates/skills/assistkick-openapi-explorer/cache/.gitignore +2 -0
  30. package/templates/skills/assistkick-workflow-builder/SKILL.md +234 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistkick/create",
3
- "version": "1.18.0",
3
+ "version": "1.21.0",
4
4
  "description": "Scaffold assistkick-product-system into any project",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 });
@@ -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
 
@@ -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
- constructor({ wss, authService, chatCliBridge, permissionService, chatMessageRepository, chatSessionService, titleGeneratorService, log }: ChatWsHandlerDeps) {
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);
@@ -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
  }
@@ -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
+ }
@@ -0,0 +1,8 @@
1
+ CREATE TABLE `user_mcp_configs` (
2
+ `id` text PRIMARY KEY NOT NULL,
3
+ `user_id` text NOT NULL,
4
+ `config_type` text NOT NULL,
5
+ `mcp_servers_json` text DEFAULT '{}' NOT NULL,
6
+ `created_at` text NOT NULL,
7
+ `updated_at` text NOT NULL
8
+ );