morpheus-cli 0.7.4 → 0.7.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.
- package/README.md +4 -0
- package/dist/channels/discord.js +111 -1
- package/dist/channels/telegram.js +2 -1
- package/dist/cli/commands/start.js +22 -0
- package/dist/config/manager.js +34 -0
- package/dist/config/schemas.js +12 -0
- package/dist/devkit/registry.js +18 -3
- package/dist/devkit/tools/browser.js +1 -1
- package/dist/devkit/tools/filesystem.js +25 -9
- package/dist/devkit/tools/git.js +19 -3
- package/dist/devkit/tools/network.js +9 -2
- package/dist/devkit/tools/packages.js +1 -1
- package/dist/devkit/tools/processes.js +1 -1
- package/dist/devkit/tools/shell.js +15 -3
- package/dist/devkit/tools/system.js +1 -1
- package/dist/http/api.js +37 -2
- package/dist/runtime/apoc.js +11 -5
- package/dist/runtime/hot-reload.js +96 -0
- package/dist/runtime/keymaker.js +10 -4
- package/dist/runtime/oracle.js +16 -1
- package/dist/runtime/providers/factory.js +14 -0
- package/dist/runtime/tools/__tests__/construtor.test.js +40 -23
- package/dist/runtime/tools/apoc-tool.js +6 -0
- package/dist/runtime/tools/cache.js +227 -0
- package/dist/runtime/tools/factory.js +38 -116
- package/dist/runtime/tools/morpheus-tools.js +9 -0
- package/dist/runtime/tools/neo-tool.js +6 -0
- package/dist/runtime/tools/trinity-tool.js +6 -0
- package/dist/runtime/trinity-connector.js +40 -1
- package/dist/types/config.js +12 -1
- package/dist/ui/assets/{index-Dz_qYlIb.css → index-B6deYCij.css} +1 -1
- package/dist/ui/assets/{index-CsMDzmtQ.js → index-BTQ0jjvm.js} +52 -52
- package/dist/ui/index.html +2 -2
- package/dist/ui/sw.js +1 -1
- package/package.json +1 -1
package/dist/runtime/apoc.js
CHANGED
|
@@ -42,17 +42,23 @@ export class Apoc {
|
|
|
42
42
|
async initialize() {
|
|
43
43
|
const apocConfig = this.config.apoc || this.config.llm;
|
|
44
44
|
// console.log(`Apoc configuration: ${JSON.stringify(apocConfig)}`);
|
|
45
|
-
const
|
|
46
|
-
const timeout_ms = this.config.apoc?.timeout_ms || 30_000;
|
|
45
|
+
const devkit = ConfigManager.getInstance().getDevKitConfig();
|
|
46
|
+
const timeout_ms = devkit.timeout_ms || this.config.apoc?.timeout_ms || 30_000;
|
|
47
47
|
const personality = this.config.apoc?.personality || 'pragmatic_dev';
|
|
48
48
|
// Import all devkit tool factories (side-effect registration)
|
|
49
49
|
await import("../devkit/index.js");
|
|
50
50
|
const tools = buildDevKit({
|
|
51
|
-
working_dir,
|
|
52
|
-
allowed_commands: [],
|
|
51
|
+
working_dir: devkit.sandbox_dir || process.cwd(),
|
|
52
|
+
allowed_commands: devkit.allowed_shell_commands || [],
|
|
53
53
|
timeout_ms,
|
|
54
|
+
sandbox_dir: devkit.sandbox_dir,
|
|
55
|
+
readonly_mode: devkit.readonly_mode,
|
|
56
|
+
enable_filesystem: devkit.enable_filesystem,
|
|
57
|
+
enable_shell: devkit.enable_shell,
|
|
58
|
+
enable_git: devkit.enable_git,
|
|
59
|
+
enable_network: devkit.enable_network,
|
|
54
60
|
});
|
|
55
|
-
this.display.log(`Apoc initialized with ${tools.length} DevKit tools (
|
|
61
|
+
this.display.log(`Apoc initialized with ${tools.length} DevKit tools (sandbox_dir: ${devkit.sandbox_dir}, personality: ${personality})`, { source: "Apoc" });
|
|
56
62
|
try {
|
|
57
63
|
this.agent = await ProviderFactory.createBare(apocConfig, tools);
|
|
58
64
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hot-reload module for Morpheus configuration changes.
|
|
3
|
+
*
|
|
4
|
+
* This module allows config changes to take effect without restarting the daemon.
|
|
5
|
+
* It reinitializes agents that have already been initialized, and resets lazy-loaded
|
|
6
|
+
* singletons so they pick up new config on next use.
|
|
7
|
+
*/
|
|
8
|
+
import { ConfigManager } from '../config/manager.js';
|
|
9
|
+
import { DisplayManager } from './display.js';
|
|
10
|
+
import { Apoc } from './apoc.js';
|
|
11
|
+
import { Neo } from './neo.js';
|
|
12
|
+
import { Trinity } from './trinity.js';
|
|
13
|
+
let currentOracle = null;
|
|
14
|
+
/**
|
|
15
|
+
* Register the current Oracle instance for hot-reload.
|
|
16
|
+
* Called from start.ts after Oracle initialization.
|
|
17
|
+
*/
|
|
18
|
+
export function registerOracleForHotReload(oracle) {
|
|
19
|
+
currentOracle = oracle;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Hot-reload configuration changes.
|
|
23
|
+
*
|
|
24
|
+
* This function:
|
|
25
|
+
* 1. Reloads config from disk (zaion.yaml)
|
|
26
|
+
* 2. Reinitializes Oracle with new config
|
|
27
|
+
* 3. Resets subagent singletons (they reinitialize lazily on next use)
|
|
28
|
+
*
|
|
29
|
+
* Note: Some changes still require full restart:
|
|
30
|
+
* - Channel tokens (Telegram, Discord)
|
|
31
|
+
* - UI port changes
|
|
32
|
+
* - Chronos check_interval_ms
|
|
33
|
+
*/
|
|
34
|
+
export async function hotReloadConfig() {
|
|
35
|
+
const display = DisplayManager.getInstance();
|
|
36
|
+
const reinitialized = [];
|
|
37
|
+
try {
|
|
38
|
+
// 1. Reload configuration from disk
|
|
39
|
+
await ConfigManager.getInstance().load();
|
|
40
|
+
display.log('Configuration reloaded from disk', { source: 'HotReload', level: 'info' });
|
|
41
|
+
// 2. Reinitialize Oracle if it exists
|
|
42
|
+
if (currentOracle && typeof currentOracle.reinitialize === 'function') {
|
|
43
|
+
await currentOracle.reinitialize();
|
|
44
|
+
reinitialized.push('Oracle');
|
|
45
|
+
display.log('Oracle reinitialized with new config', { source: 'HotReload', level: 'info' });
|
|
46
|
+
}
|
|
47
|
+
// 3. Reset subagent singletons - they will reinitialize with new config on next use
|
|
48
|
+
Apoc.resetInstance();
|
|
49
|
+
Neo.resetInstance();
|
|
50
|
+
Trinity.resetInstance();
|
|
51
|
+
reinitialized.push('Apoc', 'Neo', 'Trinity');
|
|
52
|
+
display.log('Subagent singletons reset (will reinitialize on next use)', { source: 'HotReload', level: 'info' });
|
|
53
|
+
return {
|
|
54
|
+
success: true,
|
|
55
|
+
reinitialized,
|
|
56
|
+
message: `Hot-reload complete. Reinitialized: ${reinitialized.join(', ')}`
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
display.log(`Hot-reload failed: ${error.message}`, { source: 'HotReload', level: 'error' });
|
|
61
|
+
return {
|
|
62
|
+
success: false,
|
|
63
|
+
reinitialized,
|
|
64
|
+
message: `Hot-reload failed: ${error.message}`
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Check which config changes require a full restart vs hot-reload.
|
|
70
|
+
*/
|
|
71
|
+
export function getRestartRequiredChanges(oldConfig, newConfig) {
|
|
72
|
+
const restartRequired = [];
|
|
73
|
+
// Channel token changes require restart
|
|
74
|
+
if (oldConfig.channels?.telegram?.token !== newConfig.channels?.telegram?.token) {
|
|
75
|
+
restartRequired.push('Telegram token');
|
|
76
|
+
}
|
|
77
|
+
if (oldConfig.channels?.discord?.token !== newConfig.channels?.discord?.token) {
|
|
78
|
+
restartRequired.push('Discord token');
|
|
79
|
+
}
|
|
80
|
+
// Channel enabled state changes require restart
|
|
81
|
+
if (oldConfig.channels?.telegram?.enabled !== newConfig.channels?.telegram?.enabled) {
|
|
82
|
+
restartRequired.push('Telegram enabled state');
|
|
83
|
+
}
|
|
84
|
+
if (oldConfig.channels?.discord?.enabled !== newConfig.channels?.discord?.enabled) {
|
|
85
|
+
restartRequired.push('Discord enabled state');
|
|
86
|
+
}
|
|
87
|
+
// UI port changes require restart
|
|
88
|
+
if (oldConfig.ui?.port !== newConfig.ui?.port) {
|
|
89
|
+
restartRequired.push('UI port');
|
|
90
|
+
}
|
|
91
|
+
// Chronos interval requires restart (it's read once in constructor)
|
|
92
|
+
if (oldConfig.chronos?.check_interval_ms !== newConfig.chronos?.check_interval_ms) {
|
|
93
|
+
restartRequired.push('Chronos check interval');
|
|
94
|
+
}
|
|
95
|
+
return restartRequired;
|
|
96
|
+
}
|
package/dist/runtime/keymaker.js
CHANGED
|
@@ -34,13 +34,19 @@ export class Keymaker {
|
|
|
34
34
|
const keymakerConfig = this.config.keymaker || this.config.llm;
|
|
35
35
|
const personality = this.config.keymaker?.personality || 'versatile_specialist';
|
|
36
36
|
// Build DevKit tools (filesystem, shell, git, browser, network, etc.)
|
|
37
|
-
const
|
|
38
|
-
const timeout_ms = 30_000;
|
|
37
|
+
const devkit = ConfigManager.getInstance().getDevKitConfig();
|
|
38
|
+
const timeout_ms = devkit.timeout_ms || 30_000;
|
|
39
39
|
await import("../devkit/index.js");
|
|
40
40
|
const devKitTools = buildDevKit({
|
|
41
|
-
working_dir,
|
|
42
|
-
allowed_commands: [],
|
|
41
|
+
working_dir: devkit.sandbox_dir || process.cwd(),
|
|
42
|
+
allowed_commands: devkit.allowed_shell_commands || [],
|
|
43
43
|
timeout_ms,
|
|
44
|
+
sandbox_dir: devkit.sandbox_dir,
|
|
45
|
+
readonly_mode: devkit.readonly_mode,
|
|
46
|
+
enable_filesystem: devkit.enable_filesystem,
|
|
47
|
+
enable_shell: devkit.enable_shell,
|
|
48
|
+
enable_git: devkit.enable_git,
|
|
49
|
+
enable_network: devkit.enable_network,
|
|
44
50
|
});
|
|
45
51
|
// Load MCP tools from configured servers
|
|
46
52
|
const mcpTools = await Construtor.create();
|
package/dist/runtime/oracle.js
CHANGED
|
@@ -14,6 +14,7 @@ import { NeoDelegateTool } from "./tools/neo-tool.js";
|
|
|
14
14
|
import { ApocDelegateTool } from "./tools/apoc-tool.js";
|
|
15
15
|
import { TrinityDelegateTool } from "./tools/trinity-tool.js";
|
|
16
16
|
import { TaskQueryTool, chronosTools, timeVerifierTool } from "./tools/index.js";
|
|
17
|
+
import { Construtor } from "./tools/factory.js";
|
|
17
18
|
import { MCPManager } from "../config/mcp-manager.js";
|
|
18
19
|
import { SkillRegistry, SkillExecuteTool, SkillDelegateTool, updateSkillToolDescriptions } from "./skills/index.js";
|
|
19
20
|
export class Oracle {
|
|
@@ -167,6 +168,17 @@ export class Oracle {
|
|
|
167
168
|
throw new ProviderError(this.config.llm.provider || 'unknown', err, "Oracle initialization failed");
|
|
168
169
|
}
|
|
169
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* Reinitialize Oracle with fresh configuration.
|
|
173
|
+
* Used for hot-reloading config changes without daemon restart.
|
|
174
|
+
*/
|
|
175
|
+
async reinitialize() {
|
|
176
|
+
// Reload config from ConfigManager
|
|
177
|
+
this.config = ConfigManager.getInstance().get();
|
|
178
|
+
// Reinitialize the provider with new config
|
|
179
|
+
await this.initialize();
|
|
180
|
+
this.display.log('Oracle reinitialized with updated configuration', { source: 'Oracle', level: 'info' });
|
|
181
|
+
}
|
|
170
182
|
async chat(message, extraUsage, isTelephonist, taskContext) {
|
|
171
183
|
if (!this.provider) {
|
|
172
184
|
throw new Error("Oracle not initialized. Call initialize() first.");
|
|
@@ -213,6 +225,7 @@ Rules:
|
|
|
213
225
|
9. Avoid duplicate delegations to the same tool or agent.
|
|
214
226
|
10. After enqueuing all required delegated tasks for the current message, stop calling tools and return a concise acknowledgement.
|
|
215
227
|
11. If a delegation is rejected as "not atomic", immediately split into smaller delegations and retry.
|
|
228
|
+
12. When the user message contains @neo, @apoc, or @trinity (case-insensitive), delegate to that specific agent. The mention is an explicit routing directive — respect it even if another agent might also handle the request.
|
|
216
229
|
|
|
217
230
|
## Chronos Channel Routing
|
|
218
231
|
When calling chronos_schedule, set notify_channels based on the user's message:
|
|
@@ -546,10 +559,12 @@ Use it to inform your response and tool selection (if needed), but do not assume
|
|
|
546
559
|
if (!this.provider) {
|
|
547
560
|
throw new Error("Oracle not initialized. Call initialize() first.");
|
|
548
561
|
}
|
|
562
|
+
// Reload MCP tool cache from servers (slow path)
|
|
563
|
+
await Construtor.reload();
|
|
549
564
|
await Neo.refreshDelegateCatalog().catch(() => { });
|
|
550
565
|
await Trinity.refreshDelegateCatalog().catch(() => { });
|
|
551
566
|
updateSkillToolDescriptions();
|
|
552
|
-
this.provider = await ProviderFactory.create(this.config.llm, [TaskQueryTool, NeoDelegateTool, ApocDelegateTool, TrinityDelegateTool, SkillExecuteTool, SkillDelegateTool, ...chronosTools]);
|
|
567
|
+
this.provider = await ProviderFactory.create(this.config.llm, [TaskQueryTool, NeoDelegateTool, ApocDelegateTool, TrinityDelegateTool, SkillExecuteTool, SkillDelegateTool, timeVerifierTool, ...chronosTools]);
|
|
553
568
|
await Neo.getInstance().reload();
|
|
554
569
|
this.display.log(`Oracle and Neo tools reloaded`, { source: 'Oracle' });
|
|
555
570
|
}
|
|
@@ -6,6 +6,11 @@ import { ProviderError } from "../errors.js";
|
|
|
6
6
|
import { createAgent, createMiddleware } from "langchain";
|
|
7
7
|
import { DisplayManager } from "../display.js";
|
|
8
8
|
import { getUsableApiKey } from "../trinity-crypto.js";
|
|
9
|
+
import { ConfigManager } from "../../config/manager.js";
|
|
10
|
+
import { TaskRequestContext } from "../tasks/context.js";
|
|
11
|
+
import { ChannelRegistry } from "../../channels/registry.js";
|
|
12
|
+
/** Channels that should NOT receive verbose tool notifications */
|
|
13
|
+
const SILENT_CHANNELS = new Set(['api', 'ui']);
|
|
9
14
|
export class ProviderFactory {
|
|
10
15
|
static buildMonitoringMiddleware() {
|
|
11
16
|
const display = DisplayManager.getInstance();
|
|
@@ -14,6 +19,15 @@ export class ProviderFactory {
|
|
|
14
19
|
wrapToolCall: (request, handler) => {
|
|
15
20
|
display.log(`Executing tool: ${request.toolCall.name}`, { level: "warning", source: 'ConstructLoad' });
|
|
16
21
|
display.log(`Arguments: ${JSON.stringify(request.toolCall.args)}`, { level: "info", source: 'ConstructLoad' });
|
|
22
|
+
// Verbose mode: notify originating channel about which tool is running
|
|
23
|
+
const verboseEnabled = ConfigManager.getInstance().get().verbose_mode !== false;
|
|
24
|
+
if (verboseEnabled) {
|
|
25
|
+
const ctx = TaskRequestContext.get();
|
|
26
|
+
if (ctx?.origin_channel && ctx.origin_user_id && !SILENT_CHANNELS.has(ctx.origin_channel)) {
|
|
27
|
+
ChannelRegistry.sendToUser(ctx.origin_channel, ctx.origin_user_id, `🔧 executing: ${request.toolCall.name}`)
|
|
28
|
+
.catch(() => { });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
17
31
|
try {
|
|
18
32
|
const result = handler(request);
|
|
19
33
|
display.log(`Tool completed successfully. Result: ${JSON.stringify(result)}`, { level: "info", source: 'ConstructLoad' });
|
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
2
|
import { Construtor } from '../factory.js';
|
|
3
|
-
import {
|
|
4
|
-
vi.mock("
|
|
3
|
+
import { MCPToolCache } from '../cache.js';
|
|
4
|
+
vi.mock("../cache.js", () => {
|
|
5
|
+
const mockCache = {
|
|
6
|
+
ensureLoaded: vi.fn().mockResolvedValue(undefined),
|
|
7
|
+
getTools: vi.fn().mockReturnValue([{ name: 'tool1' }, { name: 'tool2' }]),
|
|
8
|
+
getStats: vi.fn().mockReturnValue({
|
|
9
|
+
totalTools: 2,
|
|
10
|
+
servers: [{ name: 'server1', toolCount: 2, ok: true }],
|
|
11
|
+
lastLoadedAt: new Date(),
|
|
12
|
+
isLoading: false,
|
|
13
|
+
}),
|
|
14
|
+
reload: vi.fn().mockResolvedValue(undefined),
|
|
15
|
+
};
|
|
5
16
|
return {
|
|
6
|
-
|
|
17
|
+
MCPToolCache: {
|
|
18
|
+
getInstance: () => mockCache,
|
|
19
|
+
},
|
|
7
20
|
};
|
|
8
21
|
});
|
|
9
22
|
vi.mock("../../display.js", () => ({
|
|
@@ -15,28 +28,32 @@ vi.mock("../../display.js", () => ({
|
|
|
15
28
|
}));
|
|
16
29
|
describe('Construtor', () => {
|
|
17
30
|
beforeEach(() => {
|
|
18
|
-
vi.
|
|
31
|
+
vi.clearAllMocks();
|
|
19
32
|
});
|
|
20
|
-
it('should create tools successfully', async () => {
|
|
21
|
-
const mockGetTools = vi.fn().mockResolvedValue(['tool1', 'tool2']);
|
|
22
|
-
// Mock the constructor and getTools method
|
|
23
|
-
MultiServerMCPClient.mockImplementation(function () {
|
|
24
|
-
return {
|
|
25
|
-
getTools: mockGetTools
|
|
26
|
-
};
|
|
27
|
-
});
|
|
33
|
+
it('should create tools from cache successfully', async () => {
|
|
28
34
|
const tools = await Construtor.create();
|
|
29
|
-
|
|
30
|
-
expect(
|
|
31
|
-
expect(
|
|
35
|
+
const cache = MCPToolCache.getInstance();
|
|
36
|
+
expect(cache.ensureLoaded).toHaveBeenCalled();
|
|
37
|
+
expect(cache.getTools).toHaveBeenCalled();
|
|
38
|
+
expect(tools).toHaveLength(2);
|
|
32
39
|
});
|
|
33
|
-
it('should
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
expect(
|
|
40
|
+
it('should probe servers from cache stats', async () => {
|
|
41
|
+
const results = await Construtor.probe();
|
|
42
|
+
const cache = MCPToolCache.getInstance();
|
|
43
|
+
expect(cache.ensureLoaded).toHaveBeenCalled();
|
|
44
|
+
expect(results).toHaveLength(1);
|
|
45
|
+
expect(results[0].name).toBe('server1');
|
|
46
|
+
expect(results[0].toolCount).toBe(2);
|
|
47
|
+
expect(results[0].ok).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
it('should reload cache when reload is called', async () => {
|
|
50
|
+
await Construtor.reload();
|
|
51
|
+
const cache = MCPToolCache.getInstance();
|
|
52
|
+
expect(cache.reload).toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
it('should return cache stats', () => {
|
|
55
|
+
const stats = Construtor.getStats();
|
|
56
|
+
expect(stats.totalTools).toBe(2);
|
|
57
|
+
expect(stats.servers).toHaveLength(1);
|
|
41
58
|
});
|
|
42
59
|
});
|
|
@@ -6,6 +6,7 @@ import { compositeDelegationError, isLikelyCompositeDelegationTask } from "./del
|
|
|
6
6
|
import { DisplayManager } from "../display.js";
|
|
7
7
|
import { ConfigManager } from "../../config/manager.js";
|
|
8
8
|
import { Apoc } from "../apoc.js";
|
|
9
|
+
import { ChannelRegistry } from "../../channels/registry.js";
|
|
9
10
|
/**
|
|
10
11
|
* Returns true when Apoc is configured to execute synchronously (inline).
|
|
11
12
|
*/
|
|
@@ -42,6 +43,11 @@ export const ApocDelegateTool = tool(async ({ task, context }) => {
|
|
|
42
43
|
});
|
|
43
44
|
const ctx = TaskRequestContext.get();
|
|
44
45
|
const sessionId = ctx?.session_id ?? "default";
|
|
46
|
+
// Notify originating channel that the agent is working
|
|
47
|
+
if (ctx?.origin_channel && ctx.origin_user_id && ctx.origin_channel !== 'api' && ctx.origin_channel !== 'ui') {
|
|
48
|
+
ChannelRegistry.sendToUser(ctx.origin_channel, ctx.origin_user_id, '🧑🔬 Apoc is executing your request...')
|
|
49
|
+
.catch(() => { });
|
|
50
|
+
}
|
|
45
51
|
const apoc = Apoc.getInstance();
|
|
46
52
|
const result = await apoc.execute(task, context, sessionId);
|
|
47
53
|
TaskRequestContext.incrementSyncDelegation();
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { MultiServerMCPClient } from "@langchain/mcp-adapters";
|
|
2
|
+
import { loadMCPConfig } from "../../config/mcp-loader.js";
|
|
3
|
+
import { DisplayManager } from "../display.js";
|
|
4
|
+
const display = DisplayManager.getInstance();
|
|
5
|
+
/** Fields not supported by Google Gemini API */
|
|
6
|
+
const UNSUPPORTED_SCHEMA_FIELDS = ['examples', 'additionalInfo', 'default', '$schema'];
|
|
7
|
+
/**
|
|
8
|
+
* Recursively removes unsupported fields from JSON schema objects.
|
|
9
|
+
*/
|
|
10
|
+
function sanitizeSchema(obj) {
|
|
11
|
+
if (obj === null || typeof obj !== 'object')
|
|
12
|
+
return obj;
|
|
13
|
+
if (Array.isArray(obj))
|
|
14
|
+
return obj.map(sanitizeSchema);
|
|
15
|
+
const sanitized = {};
|
|
16
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
17
|
+
if (!UNSUPPORTED_SCHEMA_FIELDS.includes(key)) {
|
|
18
|
+
sanitized[key] = sanitizeSchema(value);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return sanitized;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Wraps a tool to sanitize its schema for Gemini compatibility.
|
|
25
|
+
*/
|
|
26
|
+
function wrapToolWithSanitizedSchema(tool) {
|
|
27
|
+
const originalSchema = tool.schema;
|
|
28
|
+
if (originalSchema && typeof originalSchema === 'object') {
|
|
29
|
+
tool.schema = sanitizeSchema(originalSchema);
|
|
30
|
+
}
|
|
31
|
+
return tool;
|
|
32
|
+
}
|
|
33
|
+
/** Timeout (ms) for connecting to each MCP server */
|
|
34
|
+
const MCP_CONNECT_TIMEOUT_MS = 60_000;
|
|
35
|
+
function connectTimeout(serverName, ms) {
|
|
36
|
+
return new Promise((_, reject) => setTimeout(() => reject(new Error(`MCP server '${serverName}' timed out after ${ms}ms`)), ms));
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* MCPToolCache is a singleton that caches MCP tools in memory.
|
|
40
|
+
*
|
|
41
|
+
* Tools are loaded once at startup (or on first access) and cached.
|
|
42
|
+
* Subsequent calls to `getTools()` return the cached tools instantly.
|
|
43
|
+
* Call `reload()` to refresh tools from MCP servers.
|
|
44
|
+
*
|
|
45
|
+
* This eliminates the slow re-connection to MCP servers on every agent invocation.
|
|
46
|
+
*/
|
|
47
|
+
export class MCPToolCache {
|
|
48
|
+
static instance = null;
|
|
49
|
+
allTools = [];
|
|
50
|
+
serverInfos = [];
|
|
51
|
+
lastLoadedAt = null;
|
|
52
|
+
isLoading = false;
|
|
53
|
+
loadPromise = null;
|
|
54
|
+
hasLoaded = false;
|
|
55
|
+
constructor() { }
|
|
56
|
+
static getInstance() {
|
|
57
|
+
if (!MCPToolCache.instance) {
|
|
58
|
+
MCPToolCache.instance = new MCPToolCache();
|
|
59
|
+
}
|
|
60
|
+
return MCPToolCache.instance;
|
|
61
|
+
}
|
|
62
|
+
/** Reset singleton - for testing */
|
|
63
|
+
static resetInstance() {
|
|
64
|
+
MCPToolCache.instance = null;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Ensure tools are loaded. If already loaded, returns immediately.
|
|
68
|
+
* If loading is in progress, waits for that load to complete.
|
|
69
|
+
* Otherwise, triggers a new load.
|
|
70
|
+
*/
|
|
71
|
+
async ensureLoaded() {
|
|
72
|
+
if (this.hasLoaded)
|
|
73
|
+
return;
|
|
74
|
+
if (this.isLoading && this.loadPromise) {
|
|
75
|
+
await this.loadPromise;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
await this.load();
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Load tools from all MCP servers. Called once at startup.
|
|
82
|
+
* If already loading, returns the existing promise.
|
|
83
|
+
*/
|
|
84
|
+
async load() {
|
|
85
|
+
if (this.isLoading && this.loadPromise) {
|
|
86
|
+
return this.loadPromise;
|
|
87
|
+
}
|
|
88
|
+
this.isLoading = true;
|
|
89
|
+
this.loadPromise = this._doLoad();
|
|
90
|
+
try {
|
|
91
|
+
await this.loadPromise;
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
this.isLoading = false;
|
|
95
|
+
this.loadPromise = null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async _doLoad() {
|
|
99
|
+
const startTime = Date.now();
|
|
100
|
+
const mcpServers = await loadMCPConfig();
|
|
101
|
+
const serverNames = Object.keys(mcpServers);
|
|
102
|
+
if (serverNames.length === 0) {
|
|
103
|
+
display.log('No MCP servers configured in mcps.json', { level: 'info', source: 'MCPToolCache' });
|
|
104
|
+
this.allTools = [];
|
|
105
|
+
this.serverInfos = [];
|
|
106
|
+
this.lastLoadedAt = new Date();
|
|
107
|
+
this.hasLoaded = true;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
display.log(`Loading MCP tools from ${serverNames.length} servers...`, { level: 'info', source: 'MCPToolCache' });
|
|
111
|
+
const newTools = [];
|
|
112
|
+
const newServerInfos = [];
|
|
113
|
+
for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
|
|
114
|
+
const serverStart = Date.now();
|
|
115
|
+
try {
|
|
116
|
+
display.log(`Connecting to MCP server '${serverName}'... (timeout: ${MCP_CONNECT_TIMEOUT_MS / 1000}s)`, {
|
|
117
|
+
level: 'info',
|
|
118
|
+
source: 'MCPToolCache',
|
|
119
|
+
meta: { server: serverName, transport: serverConfig.transport }
|
|
120
|
+
});
|
|
121
|
+
const client = new MultiServerMCPClient({
|
|
122
|
+
mcpServers: { [serverName]: serverConfig },
|
|
123
|
+
onConnectionError: "ignore",
|
|
124
|
+
});
|
|
125
|
+
const tools = await Promise.race([
|
|
126
|
+
client.getTools(),
|
|
127
|
+
connectTimeout(serverName, MCP_CONNECT_TIMEOUT_MS),
|
|
128
|
+
]);
|
|
129
|
+
// Rename tools with server prefix to avoid collisions
|
|
130
|
+
tools.forEach(tool => {
|
|
131
|
+
const newName = `${serverName}_${tool.name}`;
|
|
132
|
+
Object.defineProperty(tool, "name", { value: newName });
|
|
133
|
+
});
|
|
134
|
+
// Sanitize schemas for Gemini compatibility
|
|
135
|
+
const sanitizedTools = tools.map(wrapToolWithSanitizedSchema);
|
|
136
|
+
newTools.push(...sanitizedTools);
|
|
137
|
+
newServerInfos.push({
|
|
138
|
+
name: serverName,
|
|
139
|
+
toolCount: sanitizedTools.length,
|
|
140
|
+
tools: sanitizedTools,
|
|
141
|
+
ok: true,
|
|
142
|
+
loadedAt: new Date(),
|
|
143
|
+
});
|
|
144
|
+
const elapsed = Date.now() - serverStart;
|
|
145
|
+
display.log(`Loaded ${sanitizedTools.length} tools from '${serverName}' in ${elapsed}ms`, {
|
|
146
|
+
level: 'info',
|
|
147
|
+
source: 'MCPToolCache'
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
const elapsed = Date.now() - serverStart;
|
|
152
|
+
const errorMsg = String(error);
|
|
153
|
+
display.log(`Failed to load tools from '${serverName}' (${elapsed}ms): ${errorMsg}`, {
|
|
154
|
+
level: 'warning',
|
|
155
|
+
source: 'MCPToolCache'
|
|
156
|
+
});
|
|
157
|
+
newServerInfos.push({
|
|
158
|
+
name: serverName,
|
|
159
|
+
toolCount: 0,
|
|
160
|
+
tools: [],
|
|
161
|
+
ok: false,
|
|
162
|
+
error: errorMsg,
|
|
163
|
+
loadedAt: new Date(),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
this.allTools = newTools;
|
|
168
|
+
this.serverInfos = newServerInfos;
|
|
169
|
+
this.lastLoadedAt = new Date();
|
|
170
|
+
this.hasLoaded = true;
|
|
171
|
+
const totalElapsed = Date.now() - startTime;
|
|
172
|
+
display.log(`MCP tool cache loaded: ${newTools.length} tools from ${serverNames.length} servers in ${totalElapsed}ms`, {
|
|
173
|
+
level: 'info',
|
|
174
|
+
source: 'MCPToolCache'
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Force reload tools from MCP servers.
|
|
179
|
+
* Clears the cache and loads fresh tools.
|
|
180
|
+
*/
|
|
181
|
+
async reload() {
|
|
182
|
+
this.hasLoaded = false;
|
|
183
|
+
await this.load();
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Get all cached MCP tools.
|
|
187
|
+
* Returns cached tools instantly (no server connection).
|
|
188
|
+
* Call `ensureLoaded()` first if tools may not be loaded yet.
|
|
189
|
+
*/
|
|
190
|
+
getTools() {
|
|
191
|
+
return this.allTools;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Get detailed info about each MCP server's tools.
|
|
195
|
+
*/
|
|
196
|
+
getServerInfos() {
|
|
197
|
+
return this.serverInfos;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Get stats for UI display (without full tool objects).
|
|
201
|
+
*/
|
|
202
|
+
getStats() {
|
|
203
|
+
return {
|
|
204
|
+
totalTools: this.allTools.length,
|
|
205
|
+
servers: this.serverInfos.map(s => ({
|
|
206
|
+
name: s.name,
|
|
207
|
+
toolCount: s.toolCount,
|
|
208
|
+
ok: s.ok,
|
|
209
|
+
error: s.error,
|
|
210
|
+
})),
|
|
211
|
+
lastLoadedAt: this.lastLoadedAt,
|
|
212
|
+
isLoading: this.isLoading,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Check if cache has been loaded at least once.
|
|
217
|
+
*/
|
|
218
|
+
isLoaded() {
|
|
219
|
+
return this.hasLoaded;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Get timestamp of last load (or null if never loaded).
|
|
223
|
+
*/
|
|
224
|
+
getLastLoadedAt() {
|
|
225
|
+
return this.lastLoadedAt;
|
|
226
|
+
}
|
|
227
|
+
}
|