@kirosnn/mosaic 0.71.0 → 0.73.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/README.md +1 -5
- package/package.json +4 -2
- package/src/agent/Agent.ts +353 -131
- package/src/agent/context.ts +4 -4
- package/src/agent/prompts/systemPrompt.ts +15 -6
- package/src/agent/prompts/toolsPrompt.ts +75 -10
- package/src/agent/provider/anthropic.ts +100 -100
- package/src/agent/provider/google.ts +102 -102
- package/src/agent/provider/mistral.ts +95 -95
- package/src/agent/provider/ollama.ts +77 -60
- package/src/agent/provider/openai.ts +42 -38
- package/src/agent/provider/rateLimit.ts +178 -0
- package/src/agent/provider/xai.ts +99 -99
- package/src/agent/tools/definitions.ts +19 -9
- package/src/agent/tools/executor.ts +95 -85
- package/src/agent/tools/exploreExecutor.ts +8 -10
- package/src/agent/tools/grep.ts +30 -29
- package/src/agent/tools/question.ts +7 -1
- package/src/agent/types.ts +9 -8
- package/src/components/App.tsx +45 -45
- package/src/components/CustomInput.tsx +214 -36
- package/src/components/Main.tsx +1146 -954
- package/src/components/Setup.tsx +1 -1
- package/src/components/Welcome.tsx +1 -1
- package/src/components/main/ApprovalPanel.tsx +4 -3
- package/src/components/main/ChatPage.tsx +858 -675
- package/src/components/main/HomePage.tsx +53 -38
- package/src/components/main/QuestionPanel.tsx +52 -7
- package/src/components/main/ThinkingIndicator.tsx +2 -1
- package/src/index.tsx +50 -20
- package/src/mcp/approvalPolicy.ts +148 -0
- package/src/mcp/cli/add.ts +185 -0
- package/src/mcp/cli/doctor.ts +77 -0
- package/src/mcp/cli/index.ts +85 -0
- package/src/mcp/cli/list.ts +50 -0
- package/src/mcp/cli/logs.ts +24 -0
- package/src/mcp/cli/manage.ts +99 -0
- package/src/mcp/cli/show.ts +53 -0
- package/src/mcp/cli/tools.ts +77 -0
- package/src/mcp/config.ts +223 -0
- package/src/mcp/index.ts +80 -0
- package/src/mcp/processManager.ts +299 -0
- package/src/mcp/rateLimiter.ts +50 -0
- package/src/mcp/registry.ts +151 -0
- package/src/mcp/schemaConverter.ts +100 -0
- package/src/mcp/servers/navigation.ts +854 -0
- package/src/mcp/toolCatalog.ts +169 -0
- package/src/mcp/types.ts +95 -0
- package/src/utils/approvalBridge.ts +17 -5
- package/src/utils/commands/compact.ts +30 -0
- package/src/utils/commands/echo.ts +1 -1
- package/src/utils/commands/index.ts +4 -6
- package/src/utils/commands/new.ts +15 -0
- package/src/utils/commands/types.ts +3 -0
- package/src/utils/config.ts +3 -1
- package/src/utils/diffRendering.tsx +1 -3
- package/src/utils/exploreBridge.ts +10 -0
- package/src/utils/markdown.tsx +163 -99
- package/src/utils/models.ts +31 -9
- package/src/utils/questionBridge.ts +36 -1
- package/src/utils/tokenEstimator.ts +32 -0
- package/src/utils/toolFormatting.ts +268 -7
- package/src/web/app.tsx +72 -72
- package/src/web/components/HomePage.tsx +7 -7
- package/src/web/components/MessageItem.tsx +22 -22
- package/src/web/components/QuestionPanel.tsx +72 -12
- package/src/web/components/Sidebar.tsx +0 -2
- package/src/web/components/ThinkingIndicator.tsx +1 -0
- package/src/web/server.tsx +767 -683
- package/src/utils/commands/redo.ts +0 -74
- package/src/utils/commands/sessions.ts +0 -129
- package/src/utils/commands/undo.ts +0 -75
- package/src/utils/undoRedo.ts +0 -429
- package/src/utils/undoRedoBridge.ts +0 -45
- 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 [
|
|
29
|
-
const [
|
|
30
|
-
const [
|
|
31
|
-
const [
|
|
32
|
-
const [
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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="
|
|
72
|
-
|
|
73
|
-
{
|
|
74
|
-
|
|
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
|
|
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
|
|
88
|
-
|
|
89
|
-
Usage
|
|
90
|
-
mosaic [options] [
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
mosaic
|
|
105
|
-
mosaic
|
|
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,148 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { requestApproval } from '../utils/approvalBridge';
|
|
3
|
+
import type { McpApprovalCacheEntry, McpRiskHint, McpServerConfig } from './types';
|
|
4
|
+
|
|
5
|
+
const READ_KEYWORDS = ['read', 'get', 'list', 'search', 'find', 'show', 'describe', 'query', 'fetch', 'inspect', 'view', 'ls', 'cat'];
|
|
6
|
+
const WRITE_KEYWORDS = ['write', 'set', 'create', 'update', 'put', 'save', 'modify', 'patch', 'upsert', 'insert', 'append'];
|
|
7
|
+
const EXEC_KEYWORDS = ['exec', 'run', 'execute', 'spawn', 'shell', 'eval', 'invoke', 'call', 'process'];
|
|
8
|
+
const DELETE_KEYWORDS = ['delete', 'remove', 'destroy', 'drop', 'purge', 'clean', 'wipe', 'rm', 'unlink'];
|
|
9
|
+
const NET_KEYWORDS = ['http', 'request', 'download', 'upload', 'send', 'post', 'api', 'webhook', 'socket', 'connect'];
|
|
10
|
+
|
|
11
|
+
export class McpApprovalPolicy {
|
|
12
|
+
private cache = new Map<string, McpApprovalCacheEntry>();
|
|
13
|
+
|
|
14
|
+
inferRiskHint(toolName: string, _args: Record<string, unknown>): McpRiskHint {
|
|
15
|
+
const lower = toolName.toLowerCase();
|
|
16
|
+
|
|
17
|
+
for (const kw of DELETE_KEYWORDS) {
|
|
18
|
+
if (lower.includes(kw)) return 'execute';
|
|
19
|
+
}
|
|
20
|
+
for (const kw of EXEC_KEYWORDS) {
|
|
21
|
+
if (lower.includes(kw)) return 'execute';
|
|
22
|
+
}
|
|
23
|
+
for (const kw of WRITE_KEYWORDS) {
|
|
24
|
+
if (lower.includes(kw)) return 'write';
|
|
25
|
+
}
|
|
26
|
+
for (const kw of NET_KEYWORDS) {
|
|
27
|
+
if (lower.includes(kw)) return 'network';
|
|
28
|
+
}
|
|
29
|
+
for (const kw of READ_KEYWORDS) {
|
|
30
|
+
if (lower.includes(kw)) return 'read';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return 'unknown';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async requestMcpApproval(request: {
|
|
37
|
+
serverId: string;
|
|
38
|
+
serverName: string;
|
|
39
|
+
toolName: string;
|
|
40
|
+
canonicalId: string;
|
|
41
|
+
args: Record<string, unknown>;
|
|
42
|
+
approvalMode: McpServerConfig['approval'];
|
|
43
|
+
}): Promise<{ approved: boolean; customResponse?: string }> {
|
|
44
|
+
if (request.approvalMode === 'never') {
|
|
45
|
+
return { approved: true };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const riskHint = this.inferRiskHint(request.toolName, request.args);
|
|
49
|
+
|
|
50
|
+
if (this.checkCache(request.serverId, request.toolName, request.args)) {
|
|
51
|
+
return { approved: true };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const argsStr = formatArgs(request.args);
|
|
55
|
+
const payloadSize = JSON.stringify(request.args).length;
|
|
56
|
+
|
|
57
|
+
const preview = {
|
|
58
|
+
title: `MCP: ${request.serverName} / ${request.toolName}`,
|
|
59
|
+
content: argsStr,
|
|
60
|
+
details: [
|
|
61
|
+
`Server: ${request.serverName} (${request.serverId})`,
|
|
62
|
+
`Tool: ${request.toolName}`,
|
|
63
|
+
`Risk: ${riskHint}`,
|
|
64
|
+
`Payload: ${payloadSize} bytes`,
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const mcpMeta = {
|
|
69
|
+
serverId: request.serverId,
|
|
70
|
+
serverName: request.serverName,
|
|
71
|
+
canonicalId: request.canonicalId,
|
|
72
|
+
riskHint,
|
|
73
|
+
payloadSize,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const result = await requestApproval(
|
|
77
|
+
request.canonicalId,
|
|
78
|
+
{ ...request.args, __mcpMeta: mcpMeta },
|
|
79
|
+
preview
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if (result.approved && request.approvalMode !== 'always') {
|
|
83
|
+
this.addToCache(request.serverId, request.toolName, request.args, request.approvalMode);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private checkCache(serverId: string, toolName: string, args: Record<string, unknown>): boolean {
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
|
|
92
|
+
const serverKey = `server:${serverId}`;
|
|
93
|
+
const serverEntry = this.cache.get(serverKey);
|
|
94
|
+
if (serverEntry && serverEntry.expiresAt > now) return true;
|
|
95
|
+
|
|
96
|
+
const toolKey = `tool:${serverId}:${toolName}`;
|
|
97
|
+
const toolEntry = this.cache.get(toolKey);
|
|
98
|
+
if (toolEntry && toolEntry.expiresAt > now) return true;
|
|
99
|
+
|
|
100
|
+
const argsHash = hashArgs(args);
|
|
101
|
+
const argsKey = `toolArgs:${serverId}:${toolName}:${argsHash}`;
|
|
102
|
+
const argsEntry = this.cache.get(argsKey);
|
|
103
|
+
if (argsEntry && argsEntry.expiresAt > now) return true;
|
|
104
|
+
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private addToCache(serverId: string, toolName: string, _args: Record<string, unknown>, mode: McpServerConfig['approval']): void {
|
|
109
|
+
const ttl = 300000;
|
|
110
|
+
const expiresAt = Date.now() + ttl;
|
|
111
|
+
|
|
112
|
+
switch (mode) {
|
|
113
|
+
case 'once-per-server': {
|
|
114
|
+
const key = `server:${serverId}`;
|
|
115
|
+
this.cache.set(key, { scope: 'server', key, expiresAt });
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
case 'once-per-tool': {
|
|
119
|
+
const key = `tool:${serverId}:${toolName}`;
|
|
120
|
+
this.cache.set(key, { scope: 'tool', key, expiresAt });
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
clearCache(): void {
|
|
127
|
+
this.cache.clear();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function hashArgs(args: Record<string, unknown>): string {
|
|
132
|
+
const str = JSON.stringify(args, Object.keys(args).sort());
|
|
133
|
+
return createHash('sha256').update(str).digest('hex').slice(0, 12);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function formatArgs(args: Record<string, unknown>): string {
|
|
137
|
+
const entries = Object.entries(args);
|
|
138
|
+
if (entries.length === 0) return '(no arguments)';
|
|
139
|
+
|
|
140
|
+
const lines: string[] = [];
|
|
141
|
+
for (const [key, value] of entries) {
|
|
142
|
+
const strValue = typeof value === 'string'
|
|
143
|
+
? (value.length > 100 ? value.slice(0, 100) + '...' : value)
|
|
144
|
+
: JSON.stringify(value);
|
|
145
|
+
lines.push(` ${key}: ${strValue}`);
|
|
146
|
+
}
|
|
147
|
+
return lines.join('\n');
|
|
148
|
+
}
|
|
@@ -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
|
+
}
|