morpheus-cli 0.7.5 → 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 +33 -0
- package/dist/config/schemas.js +11 -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/tools/__tests__/construtor.test.js +40 -23
- package/dist/runtime/tools/cache.js +227 -0
- package/dist/runtime/tools/factory.js +38 -116
- package/dist/runtime/tools/morpheus-tools.js +8 -0
- package/dist/runtime/trinity-connector.js +40 -1
- package/dist/types/config.js +10 -0
- package/dist/ui/assets/{index-Dz_qYlIb.css → index-B6deYCij.css} +1 -1
- package/dist/ui/assets/{index-DlwA5wEh.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
|
}
|
|
@@ -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
|
});
|
|
@@ -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
|
+
}
|
|
@@ -1,124 +1,46 @@
|
|
|
1
|
-
import { MultiServerMCPClient } from "@langchain/mcp-adapters";
|
|
2
1
|
import { DisplayManager } from "../display.js";
|
|
3
|
-
import {
|
|
2
|
+
import { MCPToolCache } from "./cache.js";
|
|
4
3
|
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
|
-
* This is needed because some MCP servers (like Coolify) return schemas
|
|
10
|
-
* with fields that Gemini doesn't accept.
|
|
11
|
-
*/
|
|
12
|
-
function sanitizeSchema(obj) {
|
|
13
|
-
if (obj === null || typeof obj !== 'object') {
|
|
14
|
-
return obj;
|
|
15
|
-
}
|
|
16
|
-
if (Array.isArray(obj)) {
|
|
17
|
-
return obj.map(sanitizeSchema);
|
|
18
|
-
}
|
|
19
|
-
const sanitized = {};
|
|
20
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
21
|
-
if (!UNSUPPORTED_SCHEMA_FIELDS.includes(key)) {
|
|
22
|
-
sanitized[key] = sanitizeSchema(value);
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
return sanitized;
|
|
26
|
-
}
|
|
27
|
-
/**
|
|
28
|
-
* Wraps a tool to sanitize its schema for Gemini compatibility.
|
|
29
|
-
* Creates a proxy that intercepts schema access and sanitizes the output.
|
|
30
|
-
*/
|
|
31
|
-
function wrapToolWithSanitizedSchema(tool) {
|
|
32
|
-
// display.log('Tool loaded: - '+ tool.name, { source: 'Construtor' });
|
|
33
|
-
// The MCP tools have a schema property that returns JSON Schema
|
|
34
|
-
// We need to intercept and sanitize it
|
|
35
|
-
const originalSchema = tool.schema;
|
|
36
|
-
if (originalSchema && typeof originalSchema === 'object') {
|
|
37
|
-
// Sanitize the schema object directly
|
|
38
|
-
const sanitized = sanitizeSchema(originalSchema);
|
|
39
|
-
tool.schema = sanitized;
|
|
40
|
-
}
|
|
41
|
-
return tool;
|
|
42
|
-
}
|
|
43
|
-
/**
|
|
44
|
-
* Timeout (ms) for connecting to each MCP server and fetching its tools list.
|
|
45
|
-
* Increased to 60s to allow time for npx to download and install packages.
|
|
46
|
-
* First connection may take longer as npx downloads the package.
|
|
47
|
-
*/
|
|
48
|
-
const MCP_CONNECT_TIMEOUT_MS = 60_000;
|
|
49
|
-
/**
|
|
50
|
-
* Returns a promise that rejects after `ms` milliseconds with a timeout error.
|
|
51
|
-
* Used to guard `client.getTools()` against servers that never respond.
|
|
52
|
-
*/
|
|
53
|
-
function connectTimeout(serverName, ms) {
|
|
54
|
-
return new Promise((_, reject) => setTimeout(() => reject(new Error(`MCP server '${serverName}' timed out after ${ms}ms. If using 'npx', first run may take longer to download packages.`)), ms));
|
|
55
|
-
}
|
|
56
4
|
export class Construtor {
|
|
5
|
+
/**
|
|
6
|
+
* Probe MCP servers by checking cache stats.
|
|
7
|
+
* If cache is not loaded, loads it first.
|
|
8
|
+
*/
|
|
57
9
|
static async probe() {
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
client.getTools(),
|
|
68
|
-
connectTimeout(serverName, MCP_CONNECT_TIMEOUT_MS),
|
|
69
|
-
]);
|
|
70
|
-
results.push({ name: serverName, ok: true, toolCount: tools.length });
|
|
71
|
-
}
|
|
72
|
-
catch (error) {
|
|
73
|
-
results.push({ name: serverName, ok: false, toolCount: 0, error: String(error) });
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
return results;
|
|
10
|
+
const cache = MCPToolCache.getInstance();
|
|
11
|
+
await cache.ensureLoaded();
|
|
12
|
+
const stats = cache.getStats();
|
|
13
|
+
return stats.servers.map(s => ({
|
|
14
|
+
name: s.name,
|
|
15
|
+
ok: s.ok,
|
|
16
|
+
toolCount: s.toolCount,
|
|
17
|
+
error: s.error,
|
|
18
|
+
}));
|
|
77
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Get MCP tools from cache (fast path).
|
|
22
|
+
* If cache is not loaded, loads it first.
|
|
23
|
+
* Tools are cached and returned instantly on subsequent calls.
|
|
24
|
+
*/
|
|
78
25
|
static async create() {
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
source: 'Construtor',
|
|
99
|
-
meta: { server: serverName, transport: serverConfig.transport }
|
|
100
|
-
});
|
|
101
|
-
const tools = await Promise.race([
|
|
102
|
-
client.getTools(),
|
|
103
|
-
connectTimeout(serverName, MCP_CONNECT_TIMEOUT_MS),
|
|
104
|
-
]);
|
|
105
|
-
// Rename tools to include server prefix to avoid collisions
|
|
106
|
-
tools.forEach(tool => {
|
|
107
|
-
const newName = `${serverName}_${tool.name}`;
|
|
108
|
-
Object.defineProperty(tool, "name", { value: newName });
|
|
109
|
-
display.log(`Loaded MCP tool: ${tool.name} (from ${serverName})`, { level: 'info', source: 'Construtor' });
|
|
110
|
-
});
|
|
111
|
-
// Sanitize tool schemas to remove fields not supported by Gemini
|
|
112
|
-
const sanitizedTools = tools.map(tool => wrapToolWithSanitizedSchema(tool));
|
|
113
|
-
allTools.push(...sanitizedTools);
|
|
114
|
-
display.log(`Successfully loaded ${tools.length} tools from MCP server '${serverName}'`, { level: 'info', source: 'Construtor' });
|
|
115
|
-
}
|
|
116
|
-
catch (error) {
|
|
117
|
-
display.log(`Failed to initialize MCP tools for server '${serverName}': ${error}`, { level: 'warning', source: 'Construtor' });
|
|
118
|
-
// Continue to other servers even if one fails
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
display.log(`Loaded ${allTools.length} total MCP tools (schemas sanitized for Gemini compatibility)`, { level: 'info', source: 'Construtor' });
|
|
122
|
-
return allTools;
|
|
26
|
+
const cache = MCPToolCache.getInstance();
|
|
27
|
+
await cache.ensureLoaded();
|
|
28
|
+
const tools = cache.getTools();
|
|
29
|
+
display.log(`Returning ${tools.length} cached MCP tools`, { level: 'debug', source: 'Construtor' });
|
|
30
|
+
return tools;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Force reload MCP tools from servers (slow path).
|
|
34
|
+
* Use when MCP configuration changes.
|
|
35
|
+
*/
|
|
36
|
+
static async reload() {
|
|
37
|
+
const cache = MCPToolCache.getInstance();
|
|
38
|
+
await cache.reload();
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Get cache stats for UI display.
|
|
42
|
+
*/
|
|
43
|
+
static getStats() {
|
|
44
|
+
return MCPToolCache.getInstance().getStats();
|
|
123
45
|
}
|
|
124
46
|
}
|