morpheus-cli 0.2.4 → 0.2.6

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.
@@ -30,6 +30,7 @@ export const initCommand = new Command('init')
30
30
  choices: [
31
31
  { name: 'OpenAI', value: 'openai' },
32
32
  { name: 'Anthropic', value: 'anthropic' },
33
+ { name: 'OpenRouter', value: 'openrouter' },
33
34
  { name: 'Ollama', value: 'ollama' },
34
35
  { name: 'Google Gemini', value: 'gemini' },
35
36
  ],
@@ -43,6 +44,9 @@ export const initCommand = new Command('init')
43
44
  case 'anthropic':
44
45
  defaultModel = 'claude-3-5-sonnet-20240620';
45
46
  break;
47
+ case 'openrouter':
48
+ defaultModel = 'openrouter/auto';
49
+ break;
46
50
  case 'ollama':
47
51
  defaultModel = 'llama3';
48
52
  break;
@@ -59,10 +63,23 @@ export const initCommand = new Command('init')
59
63
  });
60
64
  let apiKey;
61
65
  const hasExistingKey = !!currentConfig.llm.api_key;
62
- const apiKeyMessage = hasExistingKey
66
+ let apiKeyMessage = hasExistingKey
63
67
  ? 'Enter API Key (leave empty to preserve existing, or if using env vars):'
64
68
  : 'Enter API Key (leave empty if using env vars):';
65
- if (provider !== 'ollama') {
69
+ // Add info about environment variables to the message
70
+ if (provider === 'openai') {
71
+ apiKeyMessage = `${apiKeyMessage} (Env var: OPENAI_API_KEY)`;
72
+ }
73
+ else if (provider === 'anthropic') {
74
+ apiKeyMessage = `${apiKeyMessage} (Env var: ANTHROPIC_API_KEY)`;
75
+ }
76
+ else if (provider === 'gemini') {
77
+ apiKeyMessage = `${apiKeyMessage} (Env var: GOOGLE_API_KEY)`;
78
+ }
79
+ else if (provider === 'openrouter') {
80
+ apiKeyMessage = `${apiKeyMessage} (Env var: OPENROUTER_API_KEY)`;
81
+ }
82
+ if (provider !== 'ollama' && provider !== 'openrouter') {
66
83
  apiKey = await password({
67
84
  message: apiKeyMessage,
68
85
  });
@@ -75,6 +92,14 @@ export const initCommand = new Command('init')
75
92
  if (apiKey) {
76
93
  await configManager.set('llm.api_key', apiKey);
77
94
  }
95
+ // Base URL Configuration for OpenRouter
96
+ if (provider === 'openrouter') {
97
+ const baseUrl = await input({
98
+ message: 'Enter OpenRouter Base URL:',
99
+ default: currentConfig.llm.base_url || 'https://openrouter.ai/api/v1',
100
+ });
101
+ await configManager.set('llm.base_url', baseUrl);
102
+ }
78
103
  // Context Window Configuration
79
104
  const contextWindow = await input({
80
105
  message: 'Context Window Size (number of messages to send to LLM):',
@@ -105,6 +130,7 @@ export const initCommand = new Command('init')
105
130
  choices: [
106
131
  { name: 'OpenAI', value: 'openai' },
107
132
  { name: 'Anthropic', value: 'anthropic' },
133
+ { name: 'OpenRouter', value: 'openrouter' },
108
134
  { name: 'Ollama', value: 'ollama' },
109
135
  { name: 'Google Gemini', value: 'gemini' },
110
136
  ],
@@ -118,6 +144,9 @@ export const initCommand = new Command('init')
118
144
  case 'anthropic':
119
145
  defaultSatiModel = 'claude-3-5-sonnet-20240620';
120
146
  break;
147
+ case 'openrouter':
148
+ defaultSatiModel = 'openrouter/auto';
149
+ break;
121
150
  case 'ollama':
122
151
  defaultSatiModel = 'llama3';
123
152
  break;
@@ -133,9 +162,22 @@ export const initCommand = new Command('init')
133
162
  default: defaultSatiModel,
134
163
  });
135
164
  const hasExistingSatiKey = !!currentConfig.santi?.api_key;
136
- const santiKeyMsg = hasExistingSatiKey
137
- ? 'Enter Sati API Key (leave empty to preserve existing):'
138
- : 'Enter Sati API Key:';
165
+ let santiKeyMsg = hasExistingSatiKey
166
+ ? 'Enter Sati API Key (leave empty to preserve existing, or if using env vars):'
167
+ : 'Enter Sati API Key (leave empty if using env vars):';
168
+ // Add info about environment variables to the message
169
+ if (santiProvider === 'openai') {
170
+ santiKeyMsg = `${santiKeyMsg} (Env var: OPENAI_API_KEY)`;
171
+ }
172
+ else if (santiProvider === 'anthropic') {
173
+ santiKeyMsg = `${santiKeyMsg} (Env var: ANTHROPIC_API_KEY)`;
174
+ }
175
+ else if (santiProvider === 'gemini') {
176
+ santiKeyMsg = `${santiKeyMsg} (Env var: GOOGLE_API_KEY)`;
177
+ }
178
+ else if (santiProvider === 'openrouter') {
179
+ santiKeyMsg = `${santiKeyMsg} (Env var: OPENROUTER_API_KEY)`;
180
+ }
139
181
  const keyInput = await password({ message: santiKeyMsg });
140
182
  if (keyInput) {
141
183
  santiApiKey = keyInput;
@@ -146,6 +188,14 @@ export const initCommand = new Command('init')
146
188
  else {
147
189
  santiApiKey = undefined; // Ensure we don't accidentally carry over invalid state
148
190
  }
191
+ // Base URL Configuration for Sati OpenRouter
192
+ if (santiProvider === 'openrouter') {
193
+ const satiBaseUrl = await input({
194
+ message: 'Enter Sati OpenRouter Base URL:',
195
+ default: currentConfig.santi?.base_url || 'https://openrouter.ai/api/v1',
196
+ });
197
+ await configManager.set('santi.base_url', satiBaseUrl);
198
+ }
149
199
  }
150
200
  const memoryLimit = await input({
151
201
  message: 'Sati Memory Retrieval Limit (messages):',
@@ -171,9 +221,11 @@ export const initCommand = new Command('init')
171
221
  }
172
222
  else {
173
223
  const hasExistingAudioKey = !!currentConfig.audio?.apiKey;
174
- const audioKeyMessage = hasExistingAudioKey
175
- ? 'Enter Gemini API Key for Audio (leave empty to preserve existing):'
176
- : 'Enter Gemini API Key for Audio:';
224
+ let audioKeyMessage = hasExistingAudioKey
225
+ ? 'Enter Gemini API Key for Audio (leave empty to preserve existing, or if using env vars):'
226
+ : 'Enter Gemini API Key for Audio (leave empty if using env vars):';
227
+ // Add info about environment variables to the message
228
+ audioKeyMessage = `${audioKeyMessage} (Env var: GOOGLE_API_KEY)`;
177
229
  audioKey = await password({
178
230
  message: audioKeyMessage,
179
231
  });
@@ -210,10 +262,13 @@ export const initCommand = new Command('init')
210
262
  display.log(chalk.gray('1. Create a bot via @BotFather to get your token.'));
211
263
  display.log(chalk.gray('2. Get your User ID via @userinfobot.\n'));
212
264
  const hasExistingToken = !!currentConfig.channels.telegram?.token;
265
+ let telegramTokenMessage = hasExistingToken
266
+ ? 'Enter Telegram Bot Token (leave empty to preserve existing, or if using env vars):'
267
+ : 'Enter Telegram Bot Token (leave empty if using env vars):';
268
+ // Add info about environment variables to the message
269
+ telegramTokenMessage = `${telegramTokenMessage} (Env var: TELEGRAM_BOT_TOKEN)`;
213
270
  const token = await password({
214
- message: hasExistingToken
215
- ? 'Enter Telegram Bot Token (leave empty to preserve existing):'
216
- : 'Enter Telegram Bot Token:',
271
+ message: telegramTokenMessage,
217
272
  validate: (value) => {
218
273
  if (value.length > 0)
219
274
  return true;
@@ -0,0 +1,167 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import fs from 'fs-extra';
4
+ import { scaffold } from '../../runtime/scaffold.js';
5
+ import { DisplayManager } from '../../runtime/display.js';
6
+ import { writePid, readPid, isProcessRunning, clearPid, checkStalePid } from '../../runtime/lifecycle.js';
7
+ import { ConfigManager } from '../../config/manager.js';
8
+ import { renderBanner } from '../utils/render.js';
9
+ import { TelegramAdapter } from '../../channels/telegram.js';
10
+ import { PATHS } from '../../config/paths.js';
11
+ import { Oracle } from '../../runtime/oracle.js';
12
+ import { ProviderError } from '../../runtime/errors.js';
13
+ import { HttpServer } from '../../http/server.js';
14
+ import { getVersion } from '../utils/version.js';
15
+ export const restartCommand = new Command('restart')
16
+ .description('Restart the Morpheus agent')
17
+ .option('--ui', 'Enable web UI', true)
18
+ .option('--no-ui', 'Disable web UI')
19
+ .option('-p, --port <number>', 'Port for web UI', '3333')
20
+ .action(async (options) => {
21
+ const display = DisplayManager.getInstance();
22
+ try {
23
+ // First, try to stop the current process
24
+ display.log(chalk.blue('Stopping current Morpheus process...'));
25
+ await checkStalePid();
26
+ const pid = await readPid();
27
+ if (pid) {
28
+ if (isProcessRunning(pid)) {
29
+ process.kill(pid, 'SIGTERM');
30
+ display.log(chalk.green(`Sent stop signal to Morpheus (PID: ${pid}).`));
31
+ // Wait a bit for the process to terminate
32
+ await new Promise(resolve => setTimeout(resolve, 2000));
33
+ }
34
+ else {
35
+ display.log(chalk.yellow('Current process not running, continuing with restart...'));
36
+ await clearPid();
37
+ }
38
+ }
39
+ else {
40
+ display.log(chalk.yellow('No running process found, continuing with restart...'));
41
+ }
42
+ // Now start a new process
43
+ display.log(chalk.blue('Starting new Morpheus process...'));
44
+ renderBanner(getVersion());
45
+ await scaffold(); // Ensure env exists
46
+ // Cleanup stale PID first
47
+ await checkStalePid();
48
+ const existingPid = await readPid();
49
+ if (existingPid !== null && isProcessRunning(existingPid)) {
50
+ display.log(chalk.red(`Morpheus is already running (PID: ${existingPid})`));
51
+ process.exit(1);
52
+ }
53
+ // Check config existence
54
+ if (!await fs.pathExists(PATHS.config)) {
55
+ display.log(chalk.yellow("Configuration not found."));
56
+ display.log(chalk.cyan("Please run 'morpheus init' first to set up your agent."));
57
+ process.exit(1);
58
+ }
59
+ // Write current PID
60
+ await writePid(process.pid);
61
+ const configManager = ConfigManager.getInstance();
62
+ const config = await configManager.load();
63
+ // Initialize persistent logging
64
+ await display.initialize(config.logging);
65
+ display.log(chalk.green(`Morpheus Agent (${config.agent.name}) starting...`));
66
+ display.log(chalk.gray(`PID: ${process.pid}`));
67
+ if (options.ui) {
68
+ display.log(chalk.blue(`Web UI enabled to port ${options.port}`));
69
+ }
70
+ // Initialize Oracle
71
+ const oracle = new Oracle(config);
72
+ try {
73
+ display.startSpinner(`Initializing ${config.llm.provider} oracle...`);
74
+ await oracle.initialize();
75
+ display.stopSpinner();
76
+ display.log(chalk.green('✓ Oracle initialized'), { source: 'Oracle' });
77
+ }
78
+ catch (err) {
79
+ display.stopSpinner();
80
+ if (err instanceof ProviderError) {
81
+ display.log(chalk.red(`\nProvider Error (${err.provider}):`));
82
+ display.log(chalk.white(err.message));
83
+ if (err.suggestion) {
84
+ display.log(chalk.yellow(`Tip: ${err.suggestion}`));
85
+ }
86
+ }
87
+ else {
88
+ display.log(chalk.red('\nOracle initialization failed:'));
89
+ display.log(chalk.white(err.message));
90
+ if (err.message.includes('API Key')) {
91
+ display.log(chalk.yellow('Tip: Check your API key in configuration or environment variables.'));
92
+ }
93
+ }
94
+ await clearPid();
95
+ process.exit(1);
96
+ }
97
+ const adapters = [];
98
+ let httpServer;
99
+ // Initialize Web UI
100
+ if (options.ui && config.ui.enabled) {
101
+ try {
102
+ httpServer = new HttpServer();
103
+ // Use CLI port if provided and valid, otherwise fallback to config or default
104
+ const port = parseInt(options.port) || config.ui.port || 3333;
105
+ httpServer.start(port);
106
+ }
107
+ catch (e) {
108
+ display.log(chalk.red(`Failed to start Web UI: ${e.message}`));
109
+ }
110
+ }
111
+ // Initialize Telegram
112
+ if (config.channels.telegram.enabled) {
113
+ if (config.channels.telegram.token) {
114
+ const telegram = new TelegramAdapter(oracle);
115
+ try {
116
+ await telegram.connect(config.channels.telegram.token, config.channels.telegram.allowedUsers || []);
117
+ adapters.push(telegram);
118
+ }
119
+ catch (e) {
120
+ display.log(chalk.red('Failed to initialize Telegram adapter. Continuing...'));
121
+ }
122
+ }
123
+ else {
124
+ display.log(chalk.yellow('Telegram enabled but no token provided. Skipping.'));
125
+ }
126
+ }
127
+ // Handle graceful shutdown
128
+ const shutdown = async (signal) => {
129
+ display.stopSpinner();
130
+ display.log(`\n${signal} received. Shutting down...`);
131
+ if (httpServer) {
132
+ httpServer.stop();
133
+ }
134
+ for (const adapter of adapters) {
135
+ await adapter.disconnect();
136
+ }
137
+ await clearPid();
138
+ process.exit(0);
139
+ };
140
+ process.on('SIGINT', () => shutdown('SIGINT'));
141
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
142
+ // Allow ESC to exit
143
+ if (process.stdin.isTTY) {
144
+ process.stdin.setRawMode(true);
145
+ process.stdin.resume();
146
+ process.stdin.setEncoding('utf8');
147
+ process.stdin.on('data', (key) => {
148
+ // ESC or Ctrl+C
149
+ if (key === '\u001B' || key === '\u0003') {
150
+ shutdown('User Quit');
151
+ }
152
+ });
153
+ }
154
+ // Keep process alive (Mock Agent Loop)
155
+ display.startSpinner('Agent active and listening... (Press ctrl+c to stop)');
156
+ // Prevent node from exiting
157
+ setInterval(() => {
158
+ // Heartbeat or background tasks would go here
159
+ }, 5000);
160
+ }
161
+ catch (error) {
162
+ display.stopSpinner();
163
+ console.error(chalk.red('Failed to restart Morpheus:'), error.message);
164
+ await clearPid();
165
+ process.exit(1);
166
+ }
167
+ });
package/dist/cli/index.js CHANGED
@@ -5,6 +5,7 @@ import { statusCommand } from './commands/status.js';
5
5
  import { configCommand } from './commands/config.js';
6
6
  import { doctorCommand } from './commands/doctor.js';
7
7
  import { initCommand } from './commands/init.js';
8
+ import { restartCommand } from './commands/restart.js';
8
9
  import { scaffold } from '../runtime/scaffold.js';
9
10
  import { getVersion } from './utils/version.js';
10
11
  export async function cli() {
@@ -19,6 +20,7 @@ export async function cli() {
19
20
  program.addCommand(initCommand);
20
21
  program.addCommand(startCommand);
21
22
  program.addCommand(stopCommand);
23
+ program.addCommand(restartCommand);
22
24
  program.addCommand(statusCommand);
23
25
  program.addCommand(configCommand);
24
26
  program.addCommand(doctorCommand);
@@ -5,6 +5,7 @@ import { PATHS } from './paths.js';
5
5
  import { setByPath } from './utils.js';
6
6
  import { ConfigSchema } from './schemas.js';
7
7
  import { migrateConfigFile } from '../runtime/migration.js';
8
+ import { resolveApiKey, resolveModel, resolveNumeric, resolveString, resolveBoolean, resolveProvider, resolveStringArray } from './precedence.js';
8
9
  export class ConfigManager {
9
10
  static instance;
10
11
  config = DEFAULT_CONFIG;
@@ -18,16 +19,15 @@ export class ConfigManager {
18
19
  async load() {
19
20
  try {
20
21
  await migrateConfigFile();
22
+ let rawConfig = DEFAULT_CONFIG;
21
23
  if (await fs.pathExists(PATHS.config)) {
22
24
  const raw = await fs.readFile(PATHS.config, 'utf8');
23
25
  const parsed = yaml.load(raw);
24
26
  // Validate and merge with defaults via Zod
25
- this.config = ConfigSchema.parse(parsed);
26
- }
27
- else {
28
- // File doesn't exist, use defaults
29
- this.config = DEFAULT_CONFIG;
27
+ rawConfig = ConfigSchema.parse(parsed);
30
28
  }
29
+ // Apply environment variable precedence to the loaded config
30
+ this.config = this.applyEnvironmentVariablePrecedence(rawConfig);
31
31
  }
32
32
  catch (error) {
33
33
  console.error('Failed to load configuration:', error);
@@ -36,6 +36,85 @@ export class ConfigManager {
36
36
  }
37
37
  return this.config;
38
38
  }
39
+ applyEnvironmentVariablePrecedence(config) {
40
+ // Apply precedence to agent config
41
+ const agentConfig = {
42
+ name: resolveString('MORPHEUS_AGENT_NAME', config.agent.name, DEFAULT_CONFIG.agent.name),
43
+ personality: resolveString('MORPHEUS_AGENT_PERSONALITY', config.agent.personality, DEFAULT_CONFIG.agent.personality)
44
+ };
45
+ // Apply precedence to LLM config
46
+ const llmProvider = resolveProvider('MORPHEUS_LLM_PROVIDER', config.llm.provider, DEFAULT_CONFIG.llm.provider);
47
+ const llmConfig = {
48
+ provider: llmProvider,
49
+ model: resolveModel(llmProvider, 'MORPHEUS_LLM_MODEL', config.llm.model),
50
+ temperature: resolveNumeric('MORPHEUS_LLM_TEMPERATURE', config.llm.temperature, DEFAULT_CONFIG.llm.temperature),
51
+ max_tokens: config.llm.max_tokens !== undefined ? resolveNumeric('MORPHEUS_LLM_MAX_TOKENS', config.llm.max_tokens, config.llm.max_tokens) : undefined,
52
+ api_key: resolveApiKey(llmProvider, 'MORPHEUS_LLM_API_KEY', config.llm.api_key),
53
+ base_url: config.llm.base_url, // base_url doesn't have environment variable precedence for now
54
+ context_window: config.llm.context_window !== undefined ? resolveNumeric('MORPHEUS_LLM_CONTEXT_WINDOW', config.llm.context_window, DEFAULT_CONFIG.llm.context_window) : undefined
55
+ };
56
+ // Apply precedence to Sati config
57
+ let santiConfig;
58
+ if (config.santi) {
59
+ const santiProvider = resolveProvider('MORPHEUS_SANTI_PROVIDER', config.santi.provider, llmConfig.provider);
60
+ santiConfig = {
61
+ provider: santiProvider,
62
+ model: resolveModel(santiProvider, 'MORPHEUS_SANTI_MODEL', config.santi.model || llmConfig.model),
63
+ temperature: resolveNumeric('MORPHEUS_SANTI_TEMPERATURE', config.santi.temperature, llmConfig.temperature),
64
+ max_tokens: config.santi.max_tokens !== undefined ? resolveNumeric('MORPHEUS_SANTI_MAX_TOKENS', config.santi.max_tokens, config.santi.max_tokens) : llmConfig.max_tokens,
65
+ api_key: resolveApiKey(santiProvider, 'MORPHEUS_SANTI_API_KEY', config.santi.api_key || llmConfig.api_key),
66
+ base_url: config.santi.base_url || config.llm.base_url,
67
+ context_window: config.santi.context_window !== undefined ? resolveNumeric('MORPHEUS_SANTI_CONTEXT_WINDOW', config.santi.context_window, config.santi.context_window) : llmConfig.context_window,
68
+ memory_limit: config.santi.memory_limit !== undefined ? resolveNumeric('MORPHEUS_SANTI_MEMORY_LIMIT', config.santi.memory_limit, config.santi.memory_limit) : undefined
69
+ };
70
+ }
71
+ // Apply precedence to audio config
72
+ const audioConfig = {
73
+ provider: config.audio.provider, // Audio provider is fixed as 'google'
74
+ model: resolveString('MORPHEUS_AUDIO_MODEL', config.audio.model, DEFAULT_CONFIG.audio.model),
75
+ enabled: resolveBoolean('MORPHEUS_AUDIO_ENABLED', config.audio.enabled, DEFAULT_CONFIG.audio.enabled),
76
+ apiKey: resolveApiKey('gemini', 'MORPHEUS_AUDIO_API_KEY', config.audio.apiKey),
77
+ maxDurationSeconds: resolveNumeric('MORPHEUS_AUDIO_MAX_DURATION', config.audio.maxDurationSeconds, DEFAULT_CONFIG.audio.maxDurationSeconds),
78
+ supportedMimeTypes: config.audio.supportedMimeTypes
79
+ };
80
+ // Apply precedence to channel configs
81
+ const channelsConfig = {
82
+ telegram: {
83
+ enabled: resolveBoolean('MORPHEUS_TELEGRAM_ENABLED', config.channels.telegram.enabled, DEFAULT_CONFIG.channels.telegram.enabled),
84
+ token: resolveString('MORPHEUS_TELEGRAM_TOKEN', config.channels.telegram.token, config.channels.telegram.token || ''),
85
+ allowedUsers: resolveStringArray('MORPHEUS_TELEGRAM_ALLOWED_USERS', config.channels.telegram.allowedUsers, DEFAULT_CONFIG.channels.telegram.allowedUsers)
86
+ },
87
+ discord: {
88
+ enabled: config.channels.discord.enabled, // Discord doesn't have env var precedence for now
89
+ token: config.channels.discord.token
90
+ }
91
+ };
92
+ // Apply precedence to UI config
93
+ const uiConfig = {
94
+ enabled: resolveBoolean('MORPHEUS_UI_ENABLED', config.ui.enabled, DEFAULT_CONFIG.ui.enabled),
95
+ port: resolveNumeric('MORPHEUS_UI_PORT', config.ui.port, DEFAULT_CONFIG.ui.port)
96
+ };
97
+ // Apply precedence to logging config
98
+ const loggingConfig = {
99
+ enabled: resolveBoolean('MORPHEUS_LOGGING_ENABLED', config.logging.enabled, DEFAULT_CONFIG.logging.enabled),
100
+ level: resolveString('MORPHEUS_LOGGING_LEVEL', config.logging.level, DEFAULT_CONFIG.logging.level),
101
+ retention: resolveString('MORPHEUS_LOGGING_RETENTION', config.logging.retention, DEFAULT_CONFIG.logging.retention)
102
+ };
103
+ // Memory config (deprecated, but keeping for backward compatibility)
104
+ const memoryConfig = {
105
+ limit: config.memory.limit // Not applying env var precedence to deprecated field
106
+ };
107
+ return {
108
+ agent: agentConfig,
109
+ llm: llmConfig,
110
+ santi: santiConfig,
111
+ audio: audioConfig,
112
+ channels: channelsConfig,
113
+ ui: uiConfig,
114
+ logging: loggingConfig,
115
+ memory: memoryConfig
116
+ };
117
+ }
39
118
  get() {
40
119
  return this.config;
41
120
  }
@@ -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
+ }