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.
@@ -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) => {