@kirosnn/mosaic 0.71.0 → 0.74.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 (79) hide show
  1. package/README.md +1 -5
  2. package/package.json +4 -2
  3. package/src/agent/Agent.ts +353 -131
  4. package/src/agent/context.ts +4 -4
  5. package/src/agent/prompts/systemPrompt.ts +15 -6
  6. package/src/agent/prompts/toolsPrompt.ts +136 -10
  7. package/src/agent/provider/anthropic.ts +100 -100
  8. package/src/agent/provider/google.ts +102 -102
  9. package/src/agent/provider/mistral.ts +95 -95
  10. package/src/agent/provider/ollama.ts +77 -60
  11. package/src/agent/provider/openai.ts +42 -38
  12. package/src/agent/provider/rateLimit.ts +178 -0
  13. package/src/agent/provider/xai.ts +99 -99
  14. package/src/agent/tools/definitions.ts +19 -9
  15. package/src/agent/tools/executor.ts +95 -85
  16. package/src/agent/tools/exploreExecutor.ts +8 -10
  17. package/src/agent/tools/grep.ts +30 -29
  18. package/src/agent/tools/question.ts +7 -1
  19. package/src/agent/types.ts +9 -8
  20. package/src/components/App.tsx +45 -45
  21. package/src/components/CustomInput.tsx +214 -36
  22. package/src/components/Main.tsx +552 -339
  23. package/src/components/Setup.tsx +1 -1
  24. package/src/components/Welcome.tsx +1 -1
  25. package/src/components/main/ApprovalPanel.tsx +4 -3
  26. package/src/components/main/ChatPage.tsx +858 -675
  27. package/src/components/main/HomePage.tsx +53 -38
  28. package/src/components/main/QuestionPanel.tsx +52 -7
  29. package/src/components/main/ThinkingIndicator.tsx +2 -1
  30. package/src/index.tsx +50 -20
  31. package/src/mcp/approvalPolicy.ts +156 -0
  32. package/src/mcp/cli/add.ts +185 -0
  33. package/src/mcp/cli/doctor.ts +74 -0
  34. package/src/mcp/cli/index.ts +85 -0
  35. package/src/mcp/cli/list.ts +50 -0
  36. package/src/mcp/cli/logs.ts +24 -0
  37. package/src/mcp/cli/manage.ts +99 -0
  38. package/src/mcp/cli/show.ts +53 -0
  39. package/src/mcp/cli/tools.ts +77 -0
  40. package/src/mcp/config.ts +234 -0
  41. package/src/mcp/index.ts +80 -0
  42. package/src/mcp/processManager.ts +304 -0
  43. package/src/mcp/rateLimiter.ts +50 -0
  44. package/src/mcp/registry.ts +151 -0
  45. package/src/mcp/schemaConverter.ts +100 -0
  46. package/src/mcp/servers/navigation/browser.ts +151 -0
  47. package/src/mcp/servers/navigation/index.ts +23 -0
  48. package/src/mcp/servers/navigation/tools.ts +263 -0
  49. package/src/mcp/servers/navigation/types.ts +17 -0
  50. package/src/mcp/servers/navigation/utils.ts +20 -0
  51. package/src/mcp/toolCatalog.ts +182 -0
  52. package/src/mcp/types.ts +116 -0
  53. package/src/utils/approvalBridge.ts +17 -5
  54. package/src/utils/commands/compact.ts +30 -0
  55. package/src/utils/commands/echo.ts +1 -1
  56. package/src/utils/commands/index.ts +4 -6
  57. package/src/utils/commands/new.ts +15 -0
  58. package/src/utils/commands/types.ts +3 -0
  59. package/src/utils/config.ts +3 -1
  60. package/src/utils/diffRendering.tsx +1 -3
  61. package/src/utils/exploreBridge.ts +10 -0
  62. package/src/utils/markdown.tsx +220 -122
  63. package/src/utils/models.ts +31 -9
  64. package/src/utils/questionBridge.ts +36 -1
  65. package/src/utils/tokenEstimator.ts +32 -0
  66. package/src/utils/toolFormatting.ts +317 -7
  67. package/src/web/app.tsx +72 -72
  68. package/src/web/components/HomePage.tsx +7 -7
  69. package/src/web/components/MessageItem.tsx +66 -35
  70. package/src/web/components/QuestionPanel.tsx +72 -12
  71. package/src/web/components/Sidebar.tsx +0 -2
  72. package/src/web/components/ThinkingIndicator.tsx +1 -0
  73. package/src/web/server.tsx +767 -683
  74. package/src/utils/commands/redo.ts +0 -74
  75. package/src/utils/commands/sessions.ts +0 -129
  76. package/src/utils/commands/undo.ts +0 -75
  77. package/src/utils/undoRedo.ts +0 -429
  78. package/src/utils/undoRedoBridge.ts +0 -45
  79. package/src/utils/undoRedoDb.ts +0 -338
@@ -24,25 +24,36 @@ const TIPS = [
24
24
  "Paste an image with Ctrl/Alt + V (or Cmd + V on macOS).",
25
25
  ];
26
26
 
27
- export function HomePage({ onSubmit, pasteRequestId, shortcutsOpen }: HomePageProps) {
28
- const [currentTipIndex, setCurrentTipIndex] = useState(0);
29
- const [displayedText, setDisplayedText] = useState("");
30
- const [isDeleting, setIsDeleting] = useState(false);
31
- const [isWaiting, setIsWaiting] = useState(false);
32
- const [cursorVisible, setCursorVisible] = useState(true);
27
+ export function HomePage({ onSubmit, pasteRequestId, shortcutsOpen }: HomePageProps) {
28
+ const [terminalWidth, setTerminalWidth] = useState(process.stdout.columns || 80);
29
+ const [currentTipIndex, setCurrentTipIndex] = useState(0);
30
+ const [displayedText, setDisplayedText] = useState("");
31
+ const [isDeleting, setIsDeleting] = useState(false);
32
+ const [isWaiting, setIsWaiting] = useState(false);
33
+ const [cursorVisible, setCursorVisible] = useState(true);
33
34
 
34
- useEffect(() => {
35
- const cursorInterval = setInterval(() => {
36
- setCursorVisible((v) => !v);
37
- }, 500);
38
- return () => clearInterval(cursorInterval);
39
- }, []);
40
-
41
- useEffect(() => {
42
- if (isWaiting) {
43
- const timeout = setTimeout(() => {
44
- setIsWaiting(false);
45
- setIsDeleting(true);
35
+ useEffect(() => {
36
+ const cursorInterval = setInterval(() => {
37
+ setCursorVisible((v) => !v);
38
+ }, 500);
39
+ return () => clearInterval(cursorInterval);
40
+ }, []);
41
+
42
+ useEffect(() => {
43
+ const handleResize = () => {
44
+ setTerminalWidth(process.stdout.columns || 80);
45
+ };
46
+ process.stdout.on('resize', handleResize);
47
+ return () => {
48
+ process.stdout.off('resize', handleResize);
49
+ };
50
+ }, []);
51
+
52
+ useEffect(() => {
53
+ if (isWaiting) {
54
+ const timeout = setTimeout(() => {
55
+ setIsWaiting(false);
56
+ setIsDeleting(true);
46
57
  }, 5000);
47
58
  return () => clearTimeout(timeout);
48
59
  }
@@ -73,10 +84,13 @@ export function HomePage({ onSubmit, pasteRequestId, shortcutsOpen }: HomePagePr
73
84
  return () => clearTimeout(timeout);
74
85
  }
75
86
  }
76
- }, [displayedText, isDeleting, isWaiting, currentTipIndex]);
77
-
78
- return (
79
- <box flexDirection="column" width="100%" height="100%" justifyContent="center" alignItems="center">
87
+ }, [displayedText, isDeleting, isWaiting, currentTipIndex]);
88
+
89
+ const containerWidth = Math.min(80, Math.floor(terminalWidth * 0.8));
90
+ const inputWidth = Math.max(10, containerWidth - 4);
91
+
92
+ return (
93
+ <box flexDirection="column" width="100%" height="100%" justifyContent="center" alignItems="center">
80
94
  <box flexDirection="column" alignItems="center" marginBottom={2}>
81
95
  <text fg="#ffca38" attributes={TextAttributes.BOLD}>███╗ ███╗</text>
82
96
  <text fg="#ffca38" attributes={TextAttributes.BOLD}>████╗ ████║</text>
@@ -84,22 +98,23 @@ export function HomePage({ onSubmit, pasteRequestId, shortcutsOpen }: HomePagePr
84
98
  </box>
85
99
 
86
100
  <box width="80%" maxWidth={80}>
87
- <box
88
- flexDirection="row"
89
- backgroundColor="#1a1a1a"
90
- paddingLeft={2}
91
- paddingRight={2}
92
- paddingTop={1}
93
- paddingBottom={1}
94
- >
95
- <CustomInput
96
- onSubmit={onSubmit}
97
- placeholder="Ask anything..."
98
- focused={!shortcutsOpen}
99
- pasteRequestId={shortcutsOpen ? 0 : pasteRequestId}
100
- />
101
- </box>
102
- </box>
101
+ <box
102
+ flexDirection="row"
103
+ backgroundColor="#1a1a1a"
104
+ paddingLeft={2}
105
+ paddingRight={2}
106
+ paddingTop={1}
107
+ paddingBottom={1}
108
+ >
109
+ <CustomInput
110
+ onSubmit={onSubmit}
111
+ placeholder="Ask anything..."
112
+ focused={!shortcutsOpen}
113
+ pasteRequestId={shortcutsOpen ? 0 : pasteRequestId}
114
+ maxWidth={inputWidth}
115
+ />
116
+ </box>
117
+ </box>
103
118
 
104
119
  <box width="80%" maxWidth={80} marginTop={3} flexDirection="row" justifyContent="center">
105
120
  <text fg="#ffca38" attributes={TextAttributes.BOLD}>ⓘ TIPS: </text>
@@ -8,15 +8,28 @@ interface QuestionPanelProps {
8
8
  request: QuestionRequest;
9
9
  disabled?: boolean;
10
10
  onAnswer: (index: number, customText?: string) => void;
11
+ maxWidth?: number;
11
12
  }
12
13
 
13
- export function QuestionPanel({ request, disabled = false, onAnswer }: QuestionPanelProps) {
14
+ export function QuestionPanel({ request, disabled = false, onAnswer, maxWidth }: QuestionPanelProps) {
14
15
  const [selectedIndex, setSelectedIndex] = useState(0);
16
+ const [remaining, setRemaining] = useState<number | null>(request.timeout ?? null);
17
+ const [validationError, setValidationError] = useState<string | null>(null);
15
18
 
16
19
  useEffect(() => {
17
20
  setSelectedIndex(0);
21
+ setValidationError(null);
22
+ setRemaining(request.timeout ?? null);
18
23
  }, [request.id]);
19
24
 
25
+ useEffect(() => {
26
+ if (remaining === null || remaining <= 0) return;
27
+ const id = setInterval(() => {
28
+ setRemaining(prev => (prev !== null && prev > 0 ? prev - 1 : prev));
29
+ }, 1000);
30
+ return () => clearInterval(id);
31
+ }, [remaining !== null]);
32
+
20
33
  useKeyboard((key) => {
21
34
  if (disabled) return;
22
35
 
@@ -47,13 +60,30 @@ export function QuestionPanel({ request, disabled = false, onAnswer }: QuestionP
47
60
  if (!text || !text.trim()) {
48
61
  return;
49
62
  }
63
+ if (request.validation) {
64
+ try {
65
+ if (!new RegExp(request.validation.pattern).test(text)) {
66
+ setValidationError(request.validation.message || `Input must match: ${request.validation.pattern}`);
67
+ return;
68
+ }
69
+ } catch {
70
+ setValidationError('Invalid validation pattern');
71
+ return;
72
+ }
73
+ }
74
+ setValidationError(null);
50
75
  onAnswer(0, text);
51
76
  };
52
77
 
78
+ let lastGroup: string | undefined;
79
+
53
80
  return (
54
81
  <box flexDirection="column" width="100%" backgroundColor="#1a1a1a" paddingLeft={1} paddingRight={1} paddingTop={1} paddingBottom={1}>
55
82
  <box flexDirection="row" marginBottom={1}>
56
83
  <text fg="#ffca38" attributes={TextAttributes.BOLD}>Question</text>
84
+ {remaining !== null && (
85
+ <text fg={remaining <= 5 ? '#ff4444' : '#888888'}> Timeout: {remaining}s</text>
86
+ )}
57
87
  </box>
58
88
 
59
89
  <box flexDirection="column" marginBottom={1}>
@@ -67,19 +97,34 @@ export function QuestionPanel({ request, disabled = false, onAnswer }: QuestionP
67
97
  const selected = index === selectedIndex;
68
98
  const prefix = selected ? '> ' : ' ';
69
99
  const number = index <= 8 ? `${index + 1}. ` : ' ';
100
+ const showGroupHeader = option.group && option.group !== lastGroup;
101
+ lastGroup = option.group;
70
102
  return (
71
- <box key={`${request.id}-${index}`} flexDirection="row" backgroundColor={selected ? '#2a2a2a' : 'transparent'} paddingLeft={1} paddingRight={1}>
72
- <text fg={selected ? '#ffca38' : 'white'} attributes={selected ? TextAttributes.BOLD : TextAttributes.NONE}>
73
- {prefix}{number}{option.label}
74
- </text>
103
+ <box key={`${request.id}-${index}`} flexDirection="column">
104
+ {showGroupHeader && (
105
+ <box paddingLeft={1} marginTop={index > 0 ? 1 : 0}>
106
+ <text fg="#888888" attributes={TextAttributes.BOLD}>{option.group}</text>
107
+ </box>
108
+ )}
109
+ <box flexDirection="row" backgroundColor={selected ? '#2a2a2a' : 'transparent'} paddingLeft={1} paddingRight={1}>
110
+ <text fg={selected ? '#ffca38' : 'white'} attributes={selected ? TextAttributes.BOLD : TextAttributes.NONE}>
111
+ {prefix}{number}{option.label}
112
+ </text>
113
+ </box>
75
114
  </box>
76
115
  );
77
116
  })}
78
117
  </box>
79
118
 
119
+ {validationError && (
120
+ <box marginBottom={1} paddingLeft={1}>
121
+ <text fg="#ff4444">{validationError}</text>
122
+ </box>
123
+ )}
124
+
80
125
  <box flexDirection="row">
81
- <CustomInput onSubmit={handleCustomSubmit} placeholder="Tell Mosaic what it should do and press Enter" focused={!disabled} disableHistory={true} />
126
+ <CustomInput onSubmit={handleCustomSubmit} placeholder="Tell Mosaic what it should do and press Enter" focused={!disabled} disableHistory={true} maxWidth={maxWidth} />
82
127
  </box>
83
128
  </box>
84
129
  );
85
- }
130
+ }
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect } from "react";
1
+ import { useState, useEffect } from "react";
2
2
  import { TextAttributes } from "@opentui/core";
3
3
  import { THINKING_WORDS } from "./types";
4
4
 
@@ -7,6 +7,7 @@ interface ThinkingIndicatorProps {
7
7
  hasQuestion: boolean;
8
8
  startTime?: number | null;
9
9
  tokens?: number;
10
+ inProgressStep?: string;
10
11
  nextStep?: string;
11
12
  }
12
13
 
package/src/index.tsx CHANGED
@@ -37,6 +37,8 @@ interface ParsedArgs {
37
37
  uninstall?: boolean;
38
38
  forceUninstall?: boolean;
39
39
  webServer?: boolean;
40
+ mcpCommand?: boolean;
41
+ mcpArgs?: string[];
40
42
  }
41
43
 
42
44
  class CLI {
@@ -65,6 +67,10 @@ class CLI {
65
67
  } else {
66
68
  i++;
67
69
  }
70
+ } else if (arg === 'mcp') {
71
+ parsed.mcpCommand = true;
72
+ parsed.mcpArgs = args.slice(i + 1);
73
+ i = args.length;
68
74
  } else if (arg === 'web') {
69
75
  parsed.webServer = true;
70
76
  i++;
@@ -81,28 +87,39 @@ class CLI {
81
87
 
82
88
  showHelp(): void {
83
89
  const gold = (text: string) => `\x1b[38;2;255;202;56m${text}\x1b[0m`;
90
+ const gray = (text: string) => `\x1b[90m${text}\x1b[0m`;
84
91
 
85
92
  console.log('');
86
93
  console.log(`
87
- Mosaic - AI-powered coding agent
88
-
89
- Usage:
90
- mosaic [options] [directory]
91
-
92
- Options:
93
- -h, --help Show this help message
94
- -d, --directory <path> Open in specific directory
95
- run "<message>" Launch with a message to execute
96
- web Start the web interface server
97
- uninstall [--force] Uninstall Mosaic
98
-
99
- Examples:
100
- mosaic Start in current directory
101
- mosaic ./my-project Start in specific directory
102
- mosaic run "fix the bug" Launch with a task
103
- mosaic web Start web server on http://127.0.0.1:8192
104
- mosaic uninstall Interactive uninstall
105
- mosaic uninstall --force Force uninstall (removes all data)
94
+ ${gold('Mosaic')}
95
+
96
+ ${gold('Usage')}
97
+ $ mosaic [options] [path]
98
+ $ mosaic <command> [options]
99
+
100
+ ${gold('Options')}
101
+ -h, --help ${gray('Show this help message')}
102
+ -d, --directory <path> ${gray('Open Mosaic in a specific directory (default: current)')}
103
+
104
+ ${gold('Commands')}
105
+ run "<message>" ${gray('Launch Mosaic with an initial prompt to execute immediately')}
106
+ web ${gray('Start the Mosaic web interface server (default: http://127.0.0.1:8192)')}
107
+ mcp <subcommand> ${gray('Manage Model Context Protocol (MCP) servers')}
108
+ uninstall [--force] ${gray('Uninstall Mosaic from your system')}
109
+
110
+ ${gold('MCP Subcommands')}
111
+ mosaic mcp list ${gray('List configured MCP servers')}
112
+ mosaic mcp add [name] ${gray('Add a new MCP server')}
113
+ mosaic mcp doctor ${gray('Run diagnostics')}
114
+ mosaic mcp help ${gray('View full list of MCP commands')}
115
+
116
+ ${gold('Examples')}
117
+ ${gray('mosaic')} # Start in current directory
118
+ ${gray('mosaic ./my-project')} # Start in specific directory
119
+ ${gray('mosaic run "Fix the bug in main.ts"')} # Launch with a specific task
120
+ ${gray('mosaic web')} # Start the web UI
121
+ ${gray('mosaic mcp list')} # Check connected tools
122
+ ${gray('mosaic uninstall --force')} # Completely remove Mosaic
106
123
  `);
107
124
  }
108
125
 
@@ -126,6 +143,12 @@ if (parsed.uninstall) {
126
143
  process.exit(0);
127
144
  }
128
145
 
146
+ if (parsed.mcpCommand) {
147
+ const { runMcpCli } = await import('./mcp/cli/index');
148
+ await runMcpCli(parsed.mcpArgs ?? []);
149
+ process.exit(0);
150
+ }
151
+
129
152
  if (parsed.webServer) {
130
153
  const { spawn } = await import('child_process');
131
154
  const path = await import('path');
@@ -187,9 +210,16 @@ if (parsed.directory) {
187
210
  import { addRecentProject } from './utils/config';
188
211
  addRecentProject(process.cwd());
189
212
 
213
+ const { initializeMcp } = await import('./mcp/index');
214
+ await initializeMcp().catch(() => { });
215
+
190
216
  process.title = '⁘ Mosaic';
191
217
 
192
- const cleanup = (code = 0) => {
218
+ const cleanup = async (code = 0) => {
219
+ try {
220
+ const { shutdownMcp } = await import('./mcp/index');
221
+ await shutdownMcp();
222
+ } catch { }
193
223
  process.stdout.write('\x1b[?25h');
194
224
  process.exit(code);
195
225
  };
@@ -0,0 +1,156 @@
1
+ import { createHash } from 'crypto';
2
+ import { requestApproval } from '../utils/approvalBridge';
3
+ import type { McpApprovalCacheEntry, McpRiskHint, McpServerConfig } from './types';
4
+ import { isNativeMcpServer } from './types';
5
+
6
+ const READ_KEYWORDS = ['read', 'get', 'list', 'search', 'find', 'show', 'describe', 'query', 'fetch', 'inspect', 'view', 'ls', 'cat'];
7
+ const WRITE_KEYWORDS = ['write', 'set', 'create', 'update', 'put', 'save', 'modify', 'patch', 'upsert', 'insert', 'append'];
8
+ const EXEC_KEYWORDS = ['exec', 'run', 'execute', 'spawn', 'shell', 'eval', 'invoke', 'call', 'process'];
9
+ const DELETE_KEYWORDS = ['delete', 'remove', 'destroy', 'drop', 'purge', 'clean', 'wipe', 'rm', 'unlink'];
10
+ const NET_KEYWORDS = ['http', 'request', 'download', 'upload', 'send', 'post', 'api', 'webhook', 'socket', 'connect'];
11
+
12
+ export class McpApprovalPolicy {
13
+ private cache = new Map<string, McpApprovalCacheEntry>();
14
+
15
+ inferRiskHint(toolName: string, _args: Record<string, unknown>): McpRiskHint {
16
+ const lower = toolName.toLowerCase();
17
+
18
+ for (const kw of DELETE_KEYWORDS) {
19
+ if (lower.includes(kw)) return 'execute';
20
+ }
21
+ for (const kw of EXEC_KEYWORDS) {
22
+ if (lower.includes(kw)) return 'execute';
23
+ }
24
+ for (const kw of WRITE_KEYWORDS) {
25
+ if (lower.includes(kw)) return 'write';
26
+ }
27
+ for (const kw of NET_KEYWORDS) {
28
+ if (lower.includes(kw)) return 'network';
29
+ }
30
+ for (const kw of READ_KEYWORDS) {
31
+ if (lower.includes(kw)) return 'read';
32
+ }
33
+
34
+ return 'unknown';
35
+ }
36
+
37
+ async requestMcpApproval(request: {
38
+ serverId: string;
39
+ serverName: string;
40
+ toolName: string;
41
+ canonicalId: string;
42
+ args: Record<string, unknown>;
43
+ approvalMode: McpServerConfig['approval'];
44
+ }): Promise<{ approved: boolean; customResponse?: string }> {
45
+ if (request.approvalMode === 'never') {
46
+ return { approved: true };
47
+ }
48
+
49
+ const riskHint = this.inferRiskHint(request.toolName, request.args);
50
+
51
+ if (this.checkCache(request.serverId, request.toolName, request.args)) {
52
+ return { approved: true };
53
+ }
54
+
55
+ const argsStr = formatArgs(request.args);
56
+ const payloadSize = JSON.stringify(request.args).length;
57
+
58
+ const isNative = isNativeMcpServer(request.serverId);
59
+ const preview = {
60
+ title: isNative ? request.toolName : `MCP: ${request.serverName} / ${request.toolName}`,
61
+ content: argsStr,
62
+ details: isNative
63
+ ? [
64
+ `Tool: ${request.toolName}`,
65
+ `Risk: ${riskHint}`,
66
+ `Payload: ${payloadSize} bytes`,
67
+ ]
68
+ : [
69
+ `Server: ${request.serverName} (${request.serverId})`,
70
+ `Tool: ${request.toolName}`,
71
+ `Risk: ${riskHint}`,
72
+ `Payload: ${payloadSize} bytes`,
73
+ ],
74
+ };
75
+
76
+ const mcpMeta = {
77
+ serverId: request.serverId,
78
+ serverName: request.serverName,
79
+ canonicalId: request.canonicalId,
80
+ riskHint,
81
+ payloadSize,
82
+ };
83
+
84
+ const result = await requestApproval(
85
+ request.canonicalId,
86
+ { ...request.args, __mcpMeta: mcpMeta },
87
+ preview
88
+ );
89
+
90
+ if (result.approved && request.approvalMode !== 'always') {
91
+ this.addToCache(request.serverId, request.toolName, request.args, request.approvalMode);
92
+ }
93
+
94
+ return result;
95
+ }
96
+
97
+ private checkCache(serverId: string, toolName: string, args: Record<string, unknown>): boolean {
98
+ const now = Date.now();
99
+
100
+ const serverKey = `server:${serverId}`;
101
+ const serverEntry = this.cache.get(serverKey);
102
+ if (serverEntry && serverEntry.expiresAt > now) return true;
103
+
104
+ const toolKey = `tool:${serverId}:${toolName}`;
105
+ const toolEntry = this.cache.get(toolKey);
106
+ if (toolEntry && toolEntry.expiresAt > now) return true;
107
+
108
+ const argsHash = hashArgs(args);
109
+ const argsKey = `toolArgs:${serverId}:${toolName}:${argsHash}`;
110
+ const argsEntry = this.cache.get(argsKey);
111
+ if (argsEntry && argsEntry.expiresAt > now) return true;
112
+
113
+ return false;
114
+ }
115
+
116
+ private addToCache(serverId: string, toolName: string, _args: Record<string, unknown>, mode: McpServerConfig['approval']): void {
117
+ const ttl = 300000;
118
+ const expiresAt = Date.now() + ttl;
119
+
120
+ switch (mode) {
121
+ case 'once-per-server': {
122
+ const key = `server:${serverId}`;
123
+ this.cache.set(key, { scope: 'server', key, expiresAt });
124
+ break;
125
+ }
126
+ case 'once-per-tool': {
127
+ const key = `tool:${serverId}:${toolName}`;
128
+ this.cache.set(key, { scope: 'tool', key, expiresAt });
129
+ break;
130
+ }
131
+ }
132
+ }
133
+
134
+ clearCache(): void {
135
+ this.cache.clear();
136
+ }
137
+ }
138
+
139
+ function hashArgs(args: Record<string, unknown>): string {
140
+ const str = JSON.stringify(args, Object.keys(args).sort());
141
+ return createHash('sha256').update(str).digest('hex').slice(0, 12);
142
+ }
143
+
144
+ function formatArgs(args: Record<string, unknown>): string {
145
+ const entries = Object.entries(args);
146
+ if (entries.length === 0) return '(no arguments)';
147
+
148
+ const lines: string[] = [];
149
+ for (const [key, value] of entries) {
150
+ const strValue = typeof value === 'string'
151
+ ? (value.length > 100 ? value.slice(0, 100) + '...' : value)
152
+ : JSON.stringify(value);
153
+ lines.push(` ${key}: ${strValue}`);
154
+ }
155
+ return lines.join('\n');
156
+ }
@@ -0,0 +1,185 @@
1
+ import { createInterface } from 'readline';
2
+ import { saveServerConfig, loadMcpConfig } from '../config';
3
+ import { MCP_REGISTRY, findRegistryEntry, type McpRegistryEntry } from '../registry';
4
+ import type { McpServerConfig } from '../types';
5
+
6
+ function ask(rl: ReturnType<typeof createInterface>, question: string, defaultValue?: string): Promise<string> {
7
+ const suffix = defaultValue ? ` [${defaultValue}]` : '';
8
+ return new Promise(resolve => {
9
+ rl.question(`${question}${suffix}: `, (answer: string) => {
10
+ resolve(answer.trim() || defaultValue || '');
11
+ });
12
+ });
13
+ }
14
+
15
+ export async function mcpAdd(nameArg?: string): Promise<void> {
16
+ const existing = loadMcpConfig();
17
+ const existingIds = new Set(existing.map(c => c.id));
18
+
19
+ if (nameArg) {
20
+ const entry = findRegistryEntry(nameArg);
21
+ if (entry) {
22
+ if (existingIds.has(entry.id)) {
23
+ console.log(`Server "${entry.id}" is already configured.`);
24
+ return;
25
+ }
26
+ await addFromRegistry(entry);
27
+ return;
28
+ }
29
+ console.log(`"${nameArg}" not found in the registry.\n`);
30
+ }
31
+
32
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
33
+
34
+ try {
35
+ console.log('Available MCP servers:\n');
36
+
37
+ for (let i = 0; i < MCP_REGISTRY.length; i++) {
38
+ const entry = MCP_REGISTRY[i]!;
39
+ const installed = existingIds.has(entry.id) ? ' (installed)' : '';
40
+ console.log(` ${String(i + 1).padStart(2)}. ${entry.name.padEnd(22)} ${entry.description}${installed}`);
41
+ }
42
+
43
+ console.log(`\n 0. Custom server`);
44
+
45
+ const choice = await ask(rl, '\nChoose a server (number or name)', '');
46
+ rl.close();
47
+
48
+ if (!choice) return;
49
+
50
+ if (choice === '0' || choice.toLowerCase() === 'custom') {
51
+ await addCustom();
52
+ return;
53
+ }
54
+
55
+ const num = parseInt(choice, 10);
56
+ let entry: McpRegistryEntry | null = null;
57
+
58
+ if (!isNaN(num) && num >= 1 && num <= MCP_REGISTRY.length) {
59
+ entry = MCP_REGISTRY[num - 1]!;
60
+ } else {
61
+ entry = findRegistryEntry(choice);
62
+ }
63
+
64
+ if (!entry) {
65
+ console.log(`"${choice}" not found.`);
66
+ return;
67
+ }
68
+
69
+ if (existingIds.has(entry.id)) {
70
+ console.log(`Server "${entry.id}" is already configured.`);
71
+ return;
72
+ }
73
+
74
+ await addFromRegistry(entry);
75
+ } catch (error) {
76
+ if ((error as any)?.code === 'ERR_USE_AFTER_CLOSE') return;
77
+ throw error;
78
+ } finally {
79
+ try { rl.close(); } catch {}
80
+ }
81
+ }
82
+
83
+ async function addFromRegistry(entry: McpRegistryEntry): Promise<void> {
84
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
85
+
86
+ try {
87
+ console.log(`\nAdding ${entry.name}: ${entry.description}\n`);
88
+
89
+ const args = [...entry.args];
90
+ const env: Record<string, string> = {};
91
+
92
+ if (entry.prompts) {
93
+ for (const prompt of entry.prompts) {
94
+ const value = await ask(rl, prompt.question);
95
+ if (!value) {
96
+ console.log('Cancelled.');
97
+ return;
98
+ }
99
+ if (prompt.argIndex !== undefined) {
100
+ args[prompt.argIndex] = value;
101
+ }
102
+ }
103
+ }
104
+
105
+ if (entry.env) {
106
+ for (const [key, meta] of Object.entries(entry.env)) {
107
+ const existing = process.env[key];
108
+ if (existing) {
109
+ console.log(` ${key}: using value from environment`);
110
+ env[key] = existing;
111
+ continue;
112
+ }
113
+
114
+ const suffix = meta.required ? '' : ' (optional, press Enter to skip)';
115
+ const value = await ask(rl, `${meta.description}${suffix}`);
116
+
117
+ if (!value && meta.required) {
118
+ console.log(`${key} is required. Cancelled.`);
119
+ return;
120
+ }
121
+
122
+ if (value) {
123
+ env[key] = value;
124
+ }
125
+ }
126
+ }
127
+
128
+ const config: Partial<McpServerConfig> = {
129
+ id: entry.id,
130
+ name: entry.name,
131
+ command: entry.command,
132
+ args,
133
+ enabled: true,
134
+ autostart: 'startup',
135
+ approval: 'always',
136
+ ...(Object.keys(env).length > 0 && { env }),
137
+ };
138
+
139
+ saveServerConfig(config);
140
+ console.log(`\n"${entry.id}" added successfully.`);
141
+ console.log(`Run "mosaic mcp doctor" to test connectivity.`);
142
+ } finally {
143
+ rl.close();
144
+ }
145
+ }
146
+
147
+ async function addCustom(): Promise<void> {
148
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
149
+
150
+ try {
151
+ console.log('\nCustom MCP server\n');
152
+
153
+ const id = await ask(rl, 'Server ID');
154
+ if (!id || !/^[a-zA-Z0-9_-]+$/.test(id)) {
155
+ console.log('Invalid server ID.');
156
+ return;
157
+ }
158
+
159
+ const name = await ask(rl, 'Display name', id);
160
+ const command = await ask(rl, 'Command (e.g., npx, node, python)');
161
+ if (!command) {
162
+ console.log('Command is required.');
163
+ return;
164
+ }
165
+
166
+ const argsStr = await ask(rl, 'Arguments (space-separated)', '');
167
+ const args = argsStr ? argsStr.split(/\s+/) : [];
168
+
169
+ const config: Partial<McpServerConfig> = {
170
+ id,
171
+ name,
172
+ command,
173
+ args,
174
+ enabled: true,
175
+ autostart: 'startup',
176
+ approval: 'always',
177
+ };
178
+
179
+ saveServerConfig(config);
180
+ console.log(`\n"${id}" added successfully.`);
181
+ console.log(`Run "mosaic mcp doctor" to test connectivity.`);
182
+ } finally {
183
+ rl.close();
184
+ }
185
+ }