morpheus-cli 0.2.5 → 0.2.7
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 +942 -493
- package/dist/channels/telegram.js +338 -1
- package/dist/cli/commands/restart.js +167 -0
- package/dist/cli/index.js +2 -0
- package/dist/config/mcp-manager.js +140 -0
- package/dist/http/__tests__/status_api.test.js +55 -0
- package/dist/http/__tests__/status_with_server_api.test.js +60 -0
- package/dist/http/api.js +85 -0
- package/dist/ui/assets/index-Dx1lwaMu.js +96 -0
- package/dist/ui/assets/index-QHZ08tDL.css +1 -0
- package/dist/ui/index.html +2 -2
- package/package.json +1 -1
- package/dist/ui/assets/index-3USYAgWN.css +0 -1
- package/dist/ui/assets/index-DKCPYzx2.js +0 -58
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { MCPConfigFileSchema, MCPServerConfigSchema } from './schemas.js';
|
|
4
|
+
import { DEFAULT_MCP_TEMPLATE } from '../types/mcp.js';
|
|
5
|
+
import { MORPHEUS_ROOT } from './paths.js';
|
|
6
|
+
const MCP_FILE_NAME = 'mcps.json';
|
|
7
|
+
const RESERVED_KEYS = new Set(['$schema']);
|
|
8
|
+
const readConfigFile = async () => {
|
|
9
|
+
const configPath = path.join(MORPHEUS_ROOT, MCP_FILE_NAME);
|
|
10
|
+
try {
|
|
11
|
+
const raw = await fs.readFile(configPath, 'utf-8');
|
|
12
|
+
const parsed = JSON.parse(raw);
|
|
13
|
+
return MCPConfigFileSchema.parse(parsed);
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (error.code === 'ENOENT') {
|
|
17
|
+
return DEFAULT_MCP_TEMPLATE;
|
|
18
|
+
}
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
const writeConfigFile = async (config) => {
|
|
23
|
+
const configPath = path.join(MORPHEUS_ROOT, MCP_FILE_NAME);
|
|
24
|
+
const serialized = JSON.stringify(config, null, 2) + '\n';
|
|
25
|
+
await fs.writeFile(configPath, serialized, 'utf-8');
|
|
26
|
+
};
|
|
27
|
+
const isMetadataKey = (key) => key.startsWith('_') || RESERVED_KEYS.has(key);
|
|
28
|
+
const normalizeName = (rawName) => rawName.replace(/^\$/, '');
|
|
29
|
+
const findRawKey = (config, name) => {
|
|
30
|
+
const direct = name in config ? name : null;
|
|
31
|
+
if (direct)
|
|
32
|
+
return direct;
|
|
33
|
+
const prefixed = `$${name}`;
|
|
34
|
+
if (prefixed in config)
|
|
35
|
+
return prefixed;
|
|
36
|
+
return null;
|
|
37
|
+
};
|
|
38
|
+
const ensureValidName = (name) => {
|
|
39
|
+
if (!name || name.trim().length === 0) {
|
|
40
|
+
throw new Error('Name is required.');
|
|
41
|
+
}
|
|
42
|
+
if (name.startsWith('_') || name === '$schema') {
|
|
43
|
+
throw new Error('Reserved names cannot be used for MCP servers.');
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
export class MCPManager {
|
|
47
|
+
static async listServers() {
|
|
48
|
+
const config = await readConfigFile();
|
|
49
|
+
const servers = [];
|
|
50
|
+
for (const [rawName, value] of Object.entries(config)) {
|
|
51
|
+
if (isMetadataKey(rawName))
|
|
52
|
+
continue;
|
|
53
|
+
if (rawName === '$schema')
|
|
54
|
+
continue;
|
|
55
|
+
if (!value || typeof value !== 'object')
|
|
56
|
+
continue;
|
|
57
|
+
try {
|
|
58
|
+
const parsed = MCPServerConfigSchema.parse(value);
|
|
59
|
+
const enabled = !rawName.startsWith('$');
|
|
60
|
+
servers.push({
|
|
61
|
+
name: normalizeName(rawName),
|
|
62
|
+
enabled,
|
|
63
|
+
config: parsed,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return servers;
|
|
71
|
+
}
|
|
72
|
+
static async addServer(name, config) {
|
|
73
|
+
ensureValidName(name);
|
|
74
|
+
const parsedConfig = MCPServerConfigSchema.parse(config);
|
|
75
|
+
const file = await readConfigFile();
|
|
76
|
+
const existing = findRawKey(file, name);
|
|
77
|
+
if (existing) {
|
|
78
|
+
throw new Error(`Server "${name}" already exists.`);
|
|
79
|
+
}
|
|
80
|
+
const next = {};
|
|
81
|
+
for (const [key, value] of Object.entries(file)) {
|
|
82
|
+
next[key] = value;
|
|
83
|
+
}
|
|
84
|
+
next[name] = parsedConfig;
|
|
85
|
+
await writeConfigFile(next);
|
|
86
|
+
}
|
|
87
|
+
static async updateServer(name, config) {
|
|
88
|
+
ensureValidName(name);
|
|
89
|
+
const parsedConfig = MCPServerConfigSchema.parse(config);
|
|
90
|
+
const file = await readConfigFile();
|
|
91
|
+
const rawKey = findRawKey(file, name);
|
|
92
|
+
if (!rawKey) {
|
|
93
|
+
throw new Error(`Server "${name}" not found.`);
|
|
94
|
+
}
|
|
95
|
+
const next = {};
|
|
96
|
+
for (const [key, value] of Object.entries(file)) {
|
|
97
|
+
if (key === rawKey) {
|
|
98
|
+
next[key] = parsedConfig;
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
next[key] = value;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
await writeConfigFile(next);
|
|
105
|
+
}
|
|
106
|
+
static async deleteServer(name) {
|
|
107
|
+
ensureValidName(name);
|
|
108
|
+
const file = await readConfigFile();
|
|
109
|
+
const rawKey = findRawKey(file, name);
|
|
110
|
+
if (!rawKey) {
|
|
111
|
+
throw new Error(`Server "${name}" not found.`);
|
|
112
|
+
}
|
|
113
|
+
const next = {};
|
|
114
|
+
for (const [key, value] of Object.entries(file)) {
|
|
115
|
+
if (key === rawKey)
|
|
116
|
+
continue;
|
|
117
|
+
next[key] = value;
|
|
118
|
+
}
|
|
119
|
+
await writeConfigFile(next);
|
|
120
|
+
}
|
|
121
|
+
static async setServerEnabled(name, enabled) {
|
|
122
|
+
ensureValidName(name);
|
|
123
|
+
const file = await readConfigFile();
|
|
124
|
+
const rawKey = findRawKey(file, name);
|
|
125
|
+
if (!rawKey) {
|
|
126
|
+
throw new Error(`Server "${name}" not found.`);
|
|
127
|
+
}
|
|
128
|
+
const targetKey = enabled ? normalizeName(rawKey) : `$${normalizeName(rawKey)}`;
|
|
129
|
+
const next = {};
|
|
130
|
+
for (const [key, value] of Object.entries(file)) {
|
|
131
|
+
if (key === rawKey) {
|
|
132
|
+
next[targetKey] = value;
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
next[key] = value;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
await writeConfigFile(next);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import request from 'supertest';
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import bodyParser from 'body-parser';
|
|
5
|
+
import { createApiRouter } from '../api.js';
|
|
6
|
+
import { ConfigManager } from '../../config/manager.js';
|
|
7
|
+
import fs from 'fs-extra';
|
|
8
|
+
// Mock dependencies
|
|
9
|
+
vi.mock('../../config/manager.js');
|
|
10
|
+
vi.mock('fs-extra');
|
|
11
|
+
describe('Status API', () => {
|
|
12
|
+
let app;
|
|
13
|
+
let mockConfigManager;
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
// Reset mocks
|
|
16
|
+
vi.clearAllMocks();
|
|
17
|
+
// Mock ConfigManager instance
|
|
18
|
+
mockConfigManager = {
|
|
19
|
+
get: vi.fn(),
|
|
20
|
+
};
|
|
21
|
+
ConfigManager.getInstance.mockReturnValue(mockConfigManager);
|
|
22
|
+
// Setup App
|
|
23
|
+
app = express();
|
|
24
|
+
app.use(bodyParser.json());
|
|
25
|
+
// Create router without server instance to test fallback
|
|
26
|
+
app.use('/api', createApiRouter());
|
|
27
|
+
});
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
vi.restoreAllMocks();
|
|
30
|
+
});
|
|
31
|
+
describe('GET /api/status', () => {
|
|
32
|
+
it('should return status information including server port', async () => {
|
|
33
|
+
const mockConfig = {
|
|
34
|
+
agent: { name: 'TestAgent' },
|
|
35
|
+
llm: { provider: 'openai', model: 'gpt-4' },
|
|
36
|
+
ui: { port: 3333 }
|
|
37
|
+
};
|
|
38
|
+
mockConfigManager.get.mockReturnValue(mockConfig);
|
|
39
|
+
// Mock fs.readJson to return a version
|
|
40
|
+
fs.readJson.mockResolvedValue({ version: '1.0.0' });
|
|
41
|
+
const res = await request(app).get('/api/status');
|
|
42
|
+
expect(res.status).toBe(200);
|
|
43
|
+
expect(res.body).toHaveProperty('status');
|
|
44
|
+
expect(res.body).toHaveProperty('uptimeSeconds');
|
|
45
|
+
expect(res.body).toHaveProperty('pid');
|
|
46
|
+
expect(res.body).toHaveProperty('projectVersion');
|
|
47
|
+
expect(res.body).toHaveProperty('nodeVersion');
|
|
48
|
+
expect(res.body).toHaveProperty('agentName');
|
|
49
|
+
expect(res.body).toHaveProperty('llmProvider');
|
|
50
|
+
expect(res.body).toHaveProperty('llmModel');
|
|
51
|
+
expect(res.body).toHaveProperty('serverPort');
|
|
52
|
+
expect(res.body.serverPort).toBe(3333);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import request from 'supertest';
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import bodyParser from 'body-parser';
|
|
5
|
+
import { createApiRouter } from '../api.js';
|
|
6
|
+
import { ConfigManager } from '../../config/manager.js';
|
|
7
|
+
import fs from 'fs-extra';
|
|
8
|
+
// Mock dependencies
|
|
9
|
+
vi.mock('../../config/manager.js');
|
|
10
|
+
vi.mock('fs-extra');
|
|
11
|
+
describe('Status API with Server Instance', () => {
|
|
12
|
+
let app;
|
|
13
|
+
let mockConfigManager;
|
|
14
|
+
let mockServerInstance;
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
// Reset mocks
|
|
17
|
+
vi.clearAllMocks();
|
|
18
|
+
// Mock ConfigManager instance
|
|
19
|
+
mockConfigManager = {
|
|
20
|
+
get: vi.fn(),
|
|
21
|
+
};
|
|
22
|
+
ConfigManager.getInstance.mockReturnValue(mockConfigManager);
|
|
23
|
+
// Mock server instance with getPort method
|
|
24
|
+
mockServerInstance = {
|
|
25
|
+
getPort: vi.fn().mockReturnValue(4567),
|
|
26
|
+
};
|
|
27
|
+
// Setup App
|
|
28
|
+
app = express();
|
|
29
|
+
app.use(bodyParser.json());
|
|
30
|
+
// Create router with server instance
|
|
31
|
+
app.use('/api', createApiRouter(mockServerInstance));
|
|
32
|
+
});
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
vi.restoreAllMocks();
|
|
35
|
+
});
|
|
36
|
+
describe('GET /api/status', () => {
|
|
37
|
+
it('should return status information with server instance port', async () => {
|
|
38
|
+
const mockConfig = {
|
|
39
|
+
agent: { name: 'TestAgent' },
|
|
40
|
+
llm: { provider: 'openai', model: 'gpt-4' },
|
|
41
|
+
ui: { port: 3333 } // This should be overridden by server instance
|
|
42
|
+
};
|
|
43
|
+
mockConfigManager.get.mockReturnValue(mockConfig);
|
|
44
|
+
// Mock fs.readJson to return a version
|
|
45
|
+
fs.readJson.mockResolvedValue({ version: '1.0.0' });
|
|
46
|
+
const res = await request(app).get('/api/status');
|
|
47
|
+
expect(res.status).toBe(200);
|
|
48
|
+
expect(res.body).toHaveProperty('status');
|
|
49
|
+
expect(res.body).toHaveProperty('uptimeSeconds');
|
|
50
|
+
expect(res.body).toHaveProperty('pid');
|
|
51
|
+
expect(res.body).toHaveProperty('projectVersion');
|
|
52
|
+
expect(res.body).toHaveProperty('nodeVersion');
|
|
53
|
+
expect(res.body).toHaveProperty('agentName');
|
|
54
|
+
expect(res.body).toHaveProperty('llmProvider');
|
|
55
|
+
expect(res.body).toHaveProperty('llmModel');
|
|
56
|
+
expect(res.body).toHaveProperty('serverPort');
|
|
57
|
+
expect(res.body.serverPort).toBe(4567); // Should come from server instance
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|
package/dist/http/api.js
CHANGED
|
@@ -6,6 +6,10 @@ import fs from 'fs-extra';
|
|
|
6
6
|
import path from 'path';
|
|
7
7
|
import { SQLiteChatMessageHistory } from '../runtime/memory/sqlite.js';
|
|
8
8
|
import { SatiRepository } from '../runtime/memory/sati/repository.js';
|
|
9
|
+
import { spawn } from 'child_process';
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
import { MCPManager } from '../config/mcp-manager.js';
|
|
12
|
+
import { MCPServerConfigSchema } from '../config/schemas.js';
|
|
9
13
|
async function readLastLines(filePath, n) {
|
|
10
14
|
try {
|
|
11
15
|
const content = await fs.readFile(filePath, 'utf8');
|
|
@@ -38,6 +42,29 @@ export function createApiRouter() {
|
|
|
38
42
|
llmModel: config.llm.model
|
|
39
43
|
});
|
|
40
44
|
});
|
|
45
|
+
router.post('/restart', async (req, res) => {
|
|
46
|
+
try {
|
|
47
|
+
// Send response immediately before restarting
|
|
48
|
+
res.json({
|
|
49
|
+
success: true,
|
|
50
|
+
message: 'Restart initiated. Process will shut down and restart shortly.'
|
|
51
|
+
});
|
|
52
|
+
// Delay the actual restart to allow response to be sent
|
|
53
|
+
setTimeout(() => {
|
|
54
|
+
// Execute the restart command using the CLI
|
|
55
|
+
const restartProcess = spawn(process.execPath, [process.argv[1], 'restart'], {
|
|
56
|
+
detached: true,
|
|
57
|
+
stdio: 'ignore'
|
|
58
|
+
});
|
|
59
|
+
restartProcess.unref();
|
|
60
|
+
// Exit the current process
|
|
61
|
+
process.exit(0);
|
|
62
|
+
}, 100);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
res.status(500).json({ error: error.message });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
41
68
|
router.get('/config', (req, res) => {
|
|
42
69
|
res.json(configManager.get());
|
|
43
70
|
});
|
|
@@ -224,6 +251,64 @@ export function createApiRouter() {
|
|
|
224
251
|
res.status(500).json({ error: error.message });
|
|
225
252
|
}
|
|
226
253
|
});
|
|
254
|
+
const MCPUpsertSchema = z.object({
|
|
255
|
+
name: z.string().min(1),
|
|
256
|
+
config: MCPServerConfigSchema,
|
|
257
|
+
});
|
|
258
|
+
const MCPToggleSchema = z.object({
|
|
259
|
+
enabled: z.boolean(),
|
|
260
|
+
});
|
|
261
|
+
router.get('/mcp/servers', async (_req, res) => {
|
|
262
|
+
try {
|
|
263
|
+
const servers = await MCPManager.listServers();
|
|
264
|
+
res.json({ servers });
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
res.status(500).json({ error: 'Failed to load MCP servers.', details: String(error) });
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
router.post('/mcp/servers', async (req, res) => {
|
|
271
|
+
try {
|
|
272
|
+
const body = MCPUpsertSchema.parse(req.body);
|
|
273
|
+
await MCPManager.addServer(body.name, body.config);
|
|
274
|
+
res.status(201).json({ ok: true });
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
const status = error instanceof z.ZodError ? 400 : 500;
|
|
278
|
+
res.status(status).json({ error: 'Failed to create MCP server.', details: error });
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
router.put('/mcp/servers/:name', async (req, res) => {
|
|
282
|
+
try {
|
|
283
|
+
const body = MCPUpsertSchema.parse({ name: req.params.name, config: req.body?.config ?? req.body });
|
|
284
|
+
await MCPManager.updateServer(body.name, body.config);
|
|
285
|
+
res.json({ ok: true });
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
const status = error instanceof z.ZodError ? 400 : 500;
|
|
289
|
+
res.status(status).json({ error: 'Failed to update MCP server.', details: error });
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
router.delete('/mcp/servers/:name', async (req, res) => {
|
|
293
|
+
try {
|
|
294
|
+
await MCPManager.deleteServer(req.params.name);
|
|
295
|
+
res.json({ ok: true });
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
res.status(500).json({ error: 'Failed to delete MCP server.', details: String(error) });
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
router.patch('/mcp/servers/:name/toggle', async (req, res) => {
|
|
302
|
+
try {
|
|
303
|
+
const body = MCPToggleSchema.parse(req.body);
|
|
304
|
+
await MCPManager.setServerEnabled(req.params.name, body.enabled);
|
|
305
|
+
res.json({ ok: true });
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
const status = error instanceof z.ZodError ? 400 : 500;
|
|
309
|
+
res.status(status).json({ error: 'Failed to toggle MCP server.', details: error });
|
|
310
|
+
}
|
|
311
|
+
});
|
|
227
312
|
// Keep PUT for backward compatibility if needed, or remove.
|
|
228
313
|
// Tasks says Implement POST. I'll remove PUT to avoid confusion or redirect it.
|
|
229
314
|
router.put('/config', async (req, res) => {
|