@indiccoder/mentis-cli 1.0.3
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 +90 -0
- package/console.log(tick) +0 -0
- package/debug_fs.js +12 -0
- package/dist/checkpoint/CheckpointManager.js +53 -0
- package/dist/config/ConfigManager.js +55 -0
- package/dist/context/ContextManager.js +55 -0
- package/dist/context/RepoMapper.js +112 -0
- package/dist/index.js +12 -0
- package/dist/llm/AnthropicClient.js +70 -0
- package/dist/llm/ModelInterface.js +2 -0
- package/dist/llm/OpenAIClient.js +58 -0
- package/dist/mcp/JsonRpcClient.js +117 -0
- package/dist/mcp/McpClient.js +59 -0
- package/dist/repl/PersistentShell.js +75 -0
- package/dist/repl/ReplManager.js +813 -0
- package/dist/tools/FileTools.js +100 -0
- package/dist/tools/GitTools.js +127 -0
- package/dist/tools/PersistentShellTool.js +30 -0
- package/dist/tools/SearchTools.js +83 -0
- package/dist/tools/Tool.js +2 -0
- package/dist/tools/WebSearchTool.js +60 -0
- package/dist/ui/UIManager.js +40 -0
- package/package.json +63 -0
- package/screenshot_1765779883482_9b30.png +0 -0
- package/scripts/test_features.ts +48 -0
- package/scripts/test_glm.ts +53 -0
- package/scripts/test_models.ts +38 -0
- package/src/checkpoint/CheckpointManager.ts +61 -0
- package/src/config/ConfigManager.ts +77 -0
- package/src/context/ContextManager.ts +63 -0
- package/src/context/RepoMapper.ts +119 -0
- package/src/index.ts +12 -0
- package/src/llm/ModelInterface.ts +47 -0
- package/src/llm/OpenAIClient.ts +64 -0
- package/src/mcp/JsonRpcClient.ts +103 -0
- package/src/mcp/McpClient.ts +75 -0
- package/src/repl/PersistentShell.ts +85 -0
- package/src/repl/ReplManager.ts +842 -0
- package/src/tools/FileTools.ts +89 -0
- package/src/tools/GitTools.ts +113 -0
- package/src/tools/PersistentShellTool.ts +32 -0
- package/src/tools/SearchTools.ts +74 -0
- package/src/tools/Tool.ts +6 -0
- package/src/tools/WebSearchTool.ts +63 -0
- package/src/ui/UIManager.ts +41 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { ChatMessage } from '../llm/ModelInterface';
|
|
5
|
+
|
|
6
|
+
export interface Checkpoint {
|
|
7
|
+
timestamp: number;
|
|
8
|
+
name: string;
|
|
9
|
+
history: ChatMessage[];
|
|
10
|
+
files: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class CheckpointManager {
|
|
14
|
+
private checkpointDir: string;
|
|
15
|
+
|
|
16
|
+
constructor() {
|
|
17
|
+
this.checkpointDir = path.join(os.homedir(), '.mentis', 'checkpoints');
|
|
18
|
+
fs.ensureDirSync(this.checkpointDir);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
public save(name: string, history: ChatMessage[], files: string[]) {
|
|
22
|
+
const checkpoint: Checkpoint = {
|
|
23
|
+
timestamp: Date.now(),
|
|
24
|
+
name,
|
|
25
|
+
history,
|
|
26
|
+
files
|
|
27
|
+
};
|
|
28
|
+
const filePath = path.join(this.checkpointDir, `${name}.json`);
|
|
29
|
+
fs.writeJsonSync(filePath, checkpoint, { spaces: 2 });
|
|
30
|
+
return filePath;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
public load(name: string): Checkpoint | null {
|
|
34
|
+
const filePath = path.join(this.checkpointDir, `${name}.json`);
|
|
35
|
+
if (fs.existsSync(filePath)) {
|
|
36
|
+
return fs.readJsonSync(filePath) as Checkpoint;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
public list(): string[] {
|
|
42
|
+
if (!fs.existsSync(this.checkpointDir)) return [];
|
|
43
|
+
return fs.readdirSync(this.checkpointDir)
|
|
44
|
+
.filter(f => f.endsWith('.json'))
|
|
45
|
+
.map(f => f.replace('.json', ''));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
public delete(name: string): boolean {
|
|
49
|
+
const filePath = path.join(this.checkpointDir, `${name}.json`);
|
|
50
|
+
if (fs.existsSync(filePath)) {
|
|
51
|
+
fs.removeSync(filePath);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
public exists(name: string): boolean {
|
|
58
|
+
const filePath = path.join(this.checkpointDir, `${name}.json`);
|
|
59
|
+
return fs.existsSync(filePath);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
|
|
5
|
+
interface MentisConfig {
|
|
6
|
+
defaultProvider: string;
|
|
7
|
+
openai?: {
|
|
8
|
+
apiKey?: string;
|
|
9
|
+
baseUrl?: string;
|
|
10
|
+
model?: string;
|
|
11
|
+
};
|
|
12
|
+
ollama?: {
|
|
13
|
+
baseUrl?: string;
|
|
14
|
+
model?: string;
|
|
15
|
+
};
|
|
16
|
+
gemini?: {
|
|
17
|
+
apiKey?: string;
|
|
18
|
+
model?: string;
|
|
19
|
+
};
|
|
20
|
+
glm?: {
|
|
21
|
+
apiKey?: string;
|
|
22
|
+
model?: string; // e.g. glm-4
|
|
23
|
+
baseUrl?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class ConfigManager {
|
|
29
|
+
private configPath: string;
|
|
30
|
+
private config: MentisConfig;
|
|
31
|
+
|
|
32
|
+
constructor() {
|
|
33
|
+
this.configPath = path.join(os.homedir(), '.mentisrc');
|
|
34
|
+
this.config = {
|
|
35
|
+
defaultProvider: 'ollama',
|
|
36
|
+
ollama: {
|
|
37
|
+
baseUrl: 'http://localhost:11434/v1',
|
|
38
|
+
model: 'llama3:latest'
|
|
39
|
+
},
|
|
40
|
+
gemini: {
|
|
41
|
+
model: 'gemini-2.5-flash'
|
|
42
|
+
},
|
|
43
|
+
glm: {
|
|
44
|
+
model: 'glm-4.6',
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
this.loadConfig();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private loadConfig() {
|
|
51
|
+
try {
|
|
52
|
+
if (fs.existsSync(this.configPath)) {
|
|
53
|
+
const fileContent = fs.readFileSync(this.configPath, 'utf-8');
|
|
54
|
+
this.config = { ...this.config, ...JSON.parse(fileContent) };
|
|
55
|
+
}
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error('Error loading config:', error);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
public saveConfig() {
|
|
62
|
+
try {
|
|
63
|
+
fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2));
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.error('Error saving config:', error);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
public getConfig(): MentisConfig {
|
|
70
|
+
return this.config;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
public updateConfig(newConfig: Partial<MentisConfig>) {
|
|
74
|
+
this.config = { ...this.config, ...newConfig };
|
|
75
|
+
this.saveConfig();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
export interface FileContext {
|
|
5
|
+
path: string;
|
|
6
|
+
content: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
import { RepoMapper } from './RepoMapper';
|
|
10
|
+
|
|
11
|
+
export class ContextManager {
|
|
12
|
+
private files: Map<string, string> = new Map();
|
|
13
|
+
private repoMapper: RepoMapper;
|
|
14
|
+
|
|
15
|
+
constructor() {
|
|
16
|
+
this.repoMapper = new RepoMapper(process.cwd());
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
public async addFile(filePath: string): Promise<string> {
|
|
20
|
+
try {
|
|
21
|
+
const absolutePath = path.resolve(process.cwd(), filePath);
|
|
22
|
+
if (!fs.existsSync(absolutePath)) {
|
|
23
|
+
throw new Error(`File not found: ${filePath}`);
|
|
24
|
+
}
|
|
25
|
+
const content = await fs.readFile(absolutePath, 'utf-8');
|
|
26
|
+
this.files.set(absolutePath, content);
|
|
27
|
+
return `Added ${filePath} to context.`;
|
|
28
|
+
} catch (error: any) {
|
|
29
|
+
return `Error adding file: ${error.message}`;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
public removeFile(filePath: string): string {
|
|
34
|
+
const absolutePath = path.resolve(process.cwd(), filePath);
|
|
35
|
+
if (this.files.has(absolutePath)) {
|
|
36
|
+
this.files.delete(absolutePath);
|
|
37
|
+
return `Removed ${filePath} from context.`;
|
|
38
|
+
}
|
|
39
|
+
return `File not in context: ${filePath}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
public clear(): void {
|
|
43
|
+
this.files.clear();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
public getContextString(): string {
|
|
47
|
+
const repoMap = this.repoMapper.generateTree();
|
|
48
|
+
let context = `Repository Structure:\n${repoMap}\n\n`;
|
|
49
|
+
|
|
50
|
+
if (this.files.size > 0) {
|
|
51
|
+
context += 'Current File Context:\n\n';
|
|
52
|
+
for (const [filePath, content] of this.files.entries()) {
|
|
53
|
+
context += `--- File: ${path.basename(filePath)} ---\n${content}\n\n`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return context;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
public getFiles(): string[] {
|
|
61
|
+
return Array.from(this.files.keys());
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { execSync } from 'child_process';
|
|
4
|
+
|
|
5
|
+
export class RepoMapper {
|
|
6
|
+
private rootDir: string;
|
|
7
|
+
private ignorePatterns: Set<string>;
|
|
8
|
+
|
|
9
|
+
constructor(rootDir: string) {
|
|
10
|
+
this.rootDir = rootDir;
|
|
11
|
+
this.ignorePatterns = new Set(['.git', 'node_modules', 'dist', 'coverage', '.DS_Store']);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
public generateTree(): string {
|
|
15
|
+
try {
|
|
16
|
+
// Try using git first for speed
|
|
17
|
+
const stdout = execSync('git ls-files --cached --others --exclude-standard', {
|
|
18
|
+
cwd: this.rootDir,
|
|
19
|
+
encoding: 'utf-8',
|
|
20
|
+
stdio: ['ignore', 'pipe', 'ignore'] // Ignore stderr to avoid noise
|
|
21
|
+
});
|
|
22
|
+
const files = stdout.split('\n').filter(Boolean);
|
|
23
|
+
return this.buildTreeFromPaths(files);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
// Fallback to manual walk if not a git repo or git fails
|
|
26
|
+
return this.walk(this.rootDir, '');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private buildTreeFromPaths(paths: string[]): string {
|
|
31
|
+
const tree: any = {};
|
|
32
|
+
|
|
33
|
+
// Build object tree
|
|
34
|
+
for (const p of paths) {
|
|
35
|
+
const parts = p.split('/');
|
|
36
|
+
let current = tree;
|
|
37
|
+
for (const part of parts) {
|
|
38
|
+
if (!current[part]) {
|
|
39
|
+
current[part] = {};
|
|
40
|
+
}
|
|
41
|
+
current = current[part];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Convert object tree to string
|
|
46
|
+
return this.renderTree(tree, '');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private renderTree(node: any, indent: string): string {
|
|
50
|
+
let result = '';
|
|
51
|
+
const keys = Object.keys(node).sort((a, b) => {
|
|
52
|
+
const aIsLeaf = Object.keys(node[a]).length === 0;
|
|
53
|
+
const bIsLeaf = Object.keys(node[b]).length === 0;
|
|
54
|
+
// Dirs first
|
|
55
|
+
if (!aIsLeaf && bIsLeaf) return -1;
|
|
56
|
+
if (aIsLeaf && !bIsLeaf) return 1;
|
|
57
|
+
return a.localeCompare(b);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
keys.forEach((key, index) => {
|
|
61
|
+
const isLast = index === keys.length - 1;
|
|
62
|
+
const isDir = Object.keys(node[key]).length > 0;
|
|
63
|
+
const prefix = isLast ? '└── ' : '├── ';
|
|
64
|
+
const childIndent = isLast ? ' ' : '│ ';
|
|
65
|
+
|
|
66
|
+
result += `${indent}${prefix}${key}${isDir ? '/' : ''}\n`;
|
|
67
|
+
|
|
68
|
+
if (isDir) {
|
|
69
|
+
result += this.renderTree(node[key], indent + childIndent);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private walk(currentPath: string, indent: string): string {
|
|
77
|
+
try {
|
|
78
|
+
const files = fs.readdirSync(currentPath);
|
|
79
|
+
let result = '';
|
|
80
|
+
|
|
81
|
+
// Cache stats to avoid repeated calls during sort
|
|
82
|
+
const fileStats = files.map(file => {
|
|
83
|
+
try {
|
|
84
|
+
return {
|
|
85
|
+
name: file,
|
|
86
|
+
isDir: fs.statSync(path.join(currentPath, file)).isDirectory()
|
|
87
|
+
};
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}).filter(f => f) as { name: string, isDir: boolean }[];
|
|
92
|
+
|
|
93
|
+
// Sort: Directories first, then files
|
|
94
|
+
fileStats.sort((a, b) => {
|
|
95
|
+
if (a.isDir && !b.isDir) return -1;
|
|
96
|
+
if (!a.isDir && b.isDir) return 1;
|
|
97
|
+
return a.name.localeCompare(b.name);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const filteredFiles = fileStats.filter(f => !this.ignorePatterns.has(f.name));
|
|
101
|
+
|
|
102
|
+
filteredFiles.forEach((fileObj, index) => {
|
|
103
|
+
const isLast = index === filteredFiles.length - 1;
|
|
104
|
+
const prefix = isLast ? '└── ' : '├── ';
|
|
105
|
+
const childIndent = isLast ? ' ' : '│ ';
|
|
106
|
+
|
|
107
|
+
result += `${indent}${prefix}${fileObj.name}${fileObj.isDir ? '/' : ''}\n`;
|
|
108
|
+
|
|
109
|
+
if (fileObj.isDir) {
|
|
110
|
+
result += this.walk(path.join(currentPath, fileObj.name), indent + childIndent);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return result;
|
|
115
|
+
} catch (e) {
|
|
116
|
+
return `${indent}Error reading directory: ${e}\n`;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { ReplManager } from './repl/ReplManager';
|
|
3
|
+
|
|
4
|
+
async function main() {
|
|
5
|
+
const repl = new ReplManager();
|
|
6
|
+
await repl.start();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
main().catch((error) => {
|
|
10
|
+
console.error('Fatal error:', error);
|
|
11
|
+
process.exit(1);
|
|
12
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface ToolCall {
|
|
2
|
+
id: string;
|
|
3
|
+
type: 'function';
|
|
4
|
+
function: {
|
|
5
|
+
name: string;
|
|
6
|
+
arguments: string;
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ChatMessage {
|
|
11
|
+
role: 'system' | 'user' | 'assistant' | 'tool'; // Added 'tool' role
|
|
12
|
+
content: string | null; // Content can be null for tool calls
|
|
13
|
+
tool_calls?: ToolCall[];
|
|
14
|
+
tool_call_id?: string; // For tool role messages
|
|
15
|
+
name?: string; // For tool role messages
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ChatResponse {
|
|
19
|
+
content: string;
|
|
20
|
+
tool_calls?: ToolCall[];
|
|
21
|
+
usage?: {
|
|
22
|
+
input_tokens: number;
|
|
23
|
+
output_tokens: number;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ModelResponse {
|
|
28
|
+
content: string | null;
|
|
29
|
+
tool_calls?: ToolCall[];
|
|
30
|
+
usage?: {
|
|
31
|
+
input_tokens: number;
|
|
32
|
+
output_tokens: number;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ToolDefinition {
|
|
37
|
+
type: 'function';
|
|
38
|
+
function: {
|
|
39
|
+
name: string;
|
|
40
|
+
description: string;
|
|
41
|
+
parameters: object;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ModelClient {
|
|
46
|
+
chat(messages: ChatMessage[], tools?: ToolDefinition[], signal?: AbortSignal): Promise<ModelResponse>;
|
|
47
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { ModelClient, ChatMessage, ModelResponse, ToolDefinition } from './ModelInterface';
|
|
3
|
+
|
|
4
|
+
export class OpenAIClient implements ModelClient {
|
|
5
|
+
private baseUrl: string;
|
|
6
|
+
private apiKey: string;
|
|
7
|
+
private model: string;
|
|
8
|
+
|
|
9
|
+
constructor(baseUrl: string, apiKey: string, model: string) {
|
|
10
|
+
this.baseUrl = baseUrl.replace(/\/$/, ''); // Remove trailing slash
|
|
11
|
+
this.apiKey = apiKey;
|
|
12
|
+
this.model = model;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async chat(messages: ChatMessage[], tools?: ToolDefinition[], signal?: AbortSignal): Promise<ModelResponse> {
|
|
16
|
+
try {
|
|
17
|
+
const requestBody: any = {
|
|
18
|
+
model: this.model,
|
|
19
|
+
messages: [
|
|
20
|
+
{ role: 'system', content: 'You are Mentis, an expert AI coding assistant. You help users write code, debug issues, and explain concepts. You are concise, accurate, and professional.' },
|
|
21
|
+
...messages
|
|
22
|
+
],
|
|
23
|
+
temperature: 0.7,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
if (tools && tools.length > 0) {
|
|
27
|
+
requestBody.tools = tools;
|
|
28
|
+
requestBody.tool_choice = 'auto';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const response = await axios.post(
|
|
32
|
+
`${this.baseUrl}/chat/completions`,
|
|
33
|
+
requestBody,
|
|
34
|
+
{
|
|
35
|
+
headers: {
|
|
36
|
+
'Content-Type': 'application/json',
|
|
37
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
38
|
+
},
|
|
39
|
+
signal: signal // Pass AbortSignal to Axios
|
|
40
|
+
}
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const choice = response.data.choices[0];
|
|
44
|
+
return {
|
|
45
|
+
content: response.data.choices[0].message.content,
|
|
46
|
+
tool_calls: response.data.choices[0].message.tool_calls,
|
|
47
|
+
usage: {
|
|
48
|
+
input_tokens: response.data.usage?.prompt_tokens || 0,
|
|
49
|
+
output_tokens: response.data.usage?.completion_tokens || 0
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
} catch (error: any) {
|
|
53
|
+
if (axios.isCancel(error) || error.name === 'AbortError' || error.code === 'ERR_CANCELED') {
|
|
54
|
+
// Rethrow as specific cancellation to be handled by caller
|
|
55
|
+
throw new Error('Request cancelled by user');
|
|
56
|
+
}
|
|
57
|
+
console.error('Error calling model API:', error.message);
|
|
58
|
+
if (error.response) {
|
|
59
|
+
console.error('Response data:', error.response.data);
|
|
60
|
+
}
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { spawn, ChildProcess } from 'child_process';
|
|
2
|
+
import * as readline from 'readline';
|
|
3
|
+
|
|
4
|
+
export interface JsonRpcRequest {
|
|
5
|
+
jsonrpc: '2.0';
|
|
6
|
+
method: string;
|
|
7
|
+
params?: any;
|
|
8
|
+
id?: number | string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface JsonRpcResponse {
|
|
12
|
+
jsonrpc: '2.0';
|
|
13
|
+
id: number | string | null;
|
|
14
|
+
result?: any;
|
|
15
|
+
error?: {
|
|
16
|
+
code: number;
|
|
17
|
+
message: string;
|
|
18
|
+
data?: any;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class JsonRpcClient {
|
|
23
|
+
private process: ChildProcess;
|
|
24
|
+
private sequence = 0;
|
|
25
|
+
private pendingRequests = new Map<number | string, { resolve: Function; reject: Function }>();
|
|
26
|
+
|
|
27
|
+
constructor(command: string, args: string[]) {
|
|
28
|
+
this.process = spawn(command, args, {
|
|
29
|
+
stdio: ['pipe', 'pipe', 'inherit'], // stdin, stdout, stderr
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (!this.process.stdout) {
|
|
33
|
+
throw new Error('Failed to open stdout for MCP process');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const rl = readline.createInterface({ input: this.process.stdout });
|
|
37
|
+
rl.on('line', (line) => this.handleMessage(line));
|
|
38
|
+
|
|
39
|
+
this.process.on('error', (err) => console.error('MCP Process Error:', err));
|
|
40
|
+
this.process.on('exit', (code) => console.log('MCP Process Exited:', code));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private handleMessage(line: string) {
|
|
44
|
+
try {
|
|
45
|
+
if (!line.trim()) return;
|
|
46
|
+
const message = JSON.parse(line);
|
|
47
|
+
|
|
48
|
+
// Handle Response
|
|
49
|
+
if (message.id !== undefined && (message.result !== undefined || message.error !== undefined)) {
|
|
50
|
+
const handler = this.pendingRequests.get(message.id);
|
|
51
|
+
if (handler) {
|
|
52
|
+
this.pendingRequests.delete(message.id);
|
|
53
|
+
if (message.error) {
|
|
54
|
+
handler.reject(new Error(message.error.message));
|
|
55
|
+
} else {
|
|
56
|
+
handler.resolve(message.result);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
// Handle Notification or Request from server (not implemented deep yet)
|
|
61
|
+
// console.log('Received notification from MCP:', message);
|
|
62
|
+
}
|
|
63
|
+
} catch (e) {
|
|
64
|
+
console.error('Failed to parse MCP message:', line, e);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
public async sendRequest(method: string, params?: any): Promise<any> {
|
|
69
|
+
const id = this.sequence++;
|
|
70
|
+
const request: JsonRpcRequest = {
|
|
71
|
+
jsonrpc: '2.0',
|
|
72
|
+
method,
|
|
73
|
+
params,
|
|
74
|
+
id,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
this.pendingRequests.set(id, { resolve, reject });
|
|
79
|
+
try {
|
|
80
|
+
if (!this.process.stdin) throw new Error('Stdin not available');
|
|
81
|
+
const data = JSON.stringify(request) + '\n';
|
|
82
|
+
this.process.stdin.write(data);
|
|
83
|
+
} catch (e) {
|
|
84
|
+
this.pendingRequests.delete(id);
|
|
85
|
+
reject(e);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
public sendNotification(method: string, params?: any) {
|
|
91
|
+
if (!this.process.stdin) return;
|
|
92
|
+
const notification: JsonRpcRequest = {
|
|
93
|
+
jsonrpc: '2.0',
|
|
94
|
+
method,
|
|
95
|
+
params,
|
|
96
|
+
};
|
|
97
|
+
this.process.stdin.write(JSON.stringify(notification) + '\n');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
public disconnect() {
|
|
101
|
+
this.process.kill();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { JsonRpcClient } from './JsonRpcClient';
|
|
2
|
+
import { Tool } from '../tools/Tool';
|
|
3
|
+
|
|
4
|
+
export class McpClient {
|
|
5
|
+
private rpc: JsonRpcClient;
|
|
6
|
+
public serverName: string = 'unknown';
|
|
7
|
+
|
|
8
|
+
constructor(command: string, args: string[]) {
|
|
9
|
+
this.rpc = new JsonRpcClient(command, args);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async initialize() {
|
|
13
|
+
const result = await this.rpc.sendRequest('initialize', {
|
|
14
|
+
protocolVersion: '2024-11-05',
|
|
15
|
+
capabilities: {},
|
|
16
|
+
clientInfo: {
|
|
17
|
+
name: 'mentis-cli',
|
|
18
|
+
version: '1.0.0',
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
this.serverName = result.serverInfo.name;
|
|
23
|
+
|
|
24
|
+
// Notify initialized
|
|
25
|
+
this.rpc.sendNotification('notifications/initialized');
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async listTools(): Promise<Tool[]> {
|
|
30
|
+
const result = await this.rpc.sendRequest('tools/list');
|
|
31
|
+
const mcpTools = result.tools || [];
|
|
32
|
+
|
|
33
|
+
return mcpTools.map((t: any) => new McpToolAdapter(this, t));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async callTool(name: string, args: any): Promise<any> {
|
|
37
|
+
return this.rpc.sendRequest('tools/call', {
|
|
38
|
+
name,
|
|
39
|
+
arguments: args,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
disconnect() {
|
|
44
|
+
this.rpc.disconnect();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
class McpToolAdapter implements Tool {
|
|
49
|
+
private client: McpClient;
|
|
50
|
+
public name: string;
|
|
51
|
+
public description: string;
|
|
52
|
+
public parameters: any;
|
|
53
|
+
|
|
54
|
+
constructor(client: McpClient, toolDef: any) {
|
|
55
|
+
this.client = client;
|
|
56
|
+
this.name = toolDef.name;
|
|
57
|
+
this.description = toolDef.description || '';
|
|
58
|
+
this.parameters = toolDef.inputSchema || {};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async execute(args: any): Promise<string> {
|
|
62
|
+
const result = await this.client.callTool(this.name, args);
|
|
63
|
+
// MCP returns { content: [ { type: 'text', text: '...' } ], isError: boolean }
|
|
64
|
+
if (result.isError) {
|
|
65
|
+
throw new Error('MCP Tool Error');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Extract text content
|
|
69
|
+
if (result.content && Array.isArray(result.content)) {
|
|
70
|
+
return result.content.map((c: any) => c.text).join('\n');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return JSON.stringify(result);
|
|
74
|
+
}
|
|
75
|
+
}
|