agent-world 0.11.1 → 0.12.0
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 +17 -7
- package/dist/cli/commands.d.ts +109 -0
- package/dist/cli/commands.js +2024 -0
- package/dist/cli/display.d.ts +124 -0
- package/dist/cli/display.js +381 -0
- package/dist/cli/hitl.d.ts +33 -0
- package/dist/cli/hitl.js +81 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/stream.d.ts +41 -0
- package/dist/cli/stream.js +222 -0
- package/dist/core/activity-tracker.d.ts +16 -0
- package/dist/core/activity-tracker.d.ts.map +1 -0
- package/dist/core/activity-tracker.js +91 -0
- package/dist/core/activity-tracker.js.map +1 -0
- package/dist/core/ai-commands.d.ts +16 -0
- package/dist/core/ai-commands.d.ts.map +1 -0
- package/dist/core/ai-commands.js +24 -0
- package/dist/core/ai-commands.js.map +1 -0
- package/dist/core/ai-sdk-patch.d.ts +24 -0
- package/dist/core/ai-sdk-patch.d.ts.map +1 -0
- package/dist/core/ai-sdk-patch.js +169 -0
- package/dist/core/ai-sdk-patch.js.map +1 -0
- package/dist/core/anthropic-direct.d.ts +52 -0
- package/dist/core/anthropic-direct.d.ts.map +1 -0
- package/dist/core/anthropic-direct.js +301 -0
- package/dist/core/anthropic-direct.js.map +1 -0
- package/dist/core/approval-cache.d.ts +104 -0
- package/dist/core/approval-cache.d.ts.map +1 -0
- package/dist/core/approval-cache.js +150 -0
- package/dist/core/approval-cache.js.map +1 -0
- package/dist/core/chat-constants.d.ts +20 -0
- package/dist/core/chat-constants.d.ts.map +1 -0
- package/dist/core/chat-constants.js +22 -0
- package/dist/core/chat-constants.js.map +1 -0
- package/dist/core/create-agent-tool.d.ts +66 -0
- package/dist/core/create-agent-tool.d.ts.map +1 -0
- package/dist/core/create-agent-tool.js +212 -0
- package/dist/core/create-agent-tool.js.map +1 -0
- package/dist/core/events/approval-checker.d.ts +61 -0
- package/dist/core/events/approval-checker.d.ts.map +1 -0
- package/dist/core/events/approval-checker.js +226 -0
- package/dist/core/events/approval-checker.js.map +1 -0
- package/dist/core/events/index.d.ts +25 -0
- package/dist/core/events/index.d.ts.map +1 -0
- package/dist/core/events/index.js +30 -0
- package/dist/core/events/index.js.map +1 -0
- package/dist/core/events/memory-manager.d.ts +73 -0
- package/dist/core/events/memory-manager.d.ts.map +1 -0
- package/dist/core/events/memory-manager.js +1218 -0
- package/dist/core/events/memory-manager.js.map +1 -0
- package/dist/core/events/mention-logic.d.ts +39 -0
- package/dist/core/events/mention-logic.d.ts.map +1 -0
- package/dist/core/events/mention-logic.js +163 -0
- package/dist/core/events/mention-logic.js.map +1 -0
- package/dist/core/events/orchestrator.d.ts +69 -0
- package/dist/core/events/orchestrator.d.ts.map +1 -0
- package/dist/core/events/orchestrator.js +883 -0
- package/dist/core/events/orchestrator.js.map +1 -0
- package/dist/core/events/persistence.d.ts +41 -0
- package/dist/core/events/persistence.d.ts.map +1 -0
- package/dist/core/events/persistence.js +296 -0
- package/dist/core/events/persistence.js.map +1 -0
- package/dist/core/events/publishers.d.ts +81 -0
- package/dist/core/events/publishers.d.ts.map +1 -0
- package/dist/core/events/publishers.js +272 -0
- package/dist/core/events/publishers.js.map +1 -0
- package/dist/core/events/subscribers.d.ts +45 -0
- package/dist/core/events/subscribers.d.ts.map +1 -0
- package/dist/core/events/subscribers.js +288 -0
- package/dist/core/events/subscribers.js.map +1 -0
- package/dist/core/events/tool-bridge-logging.d.ts +28 -0
- package/dist/core/events/tool-bridge-logging.d.ts.map +1 -0
- package/dist/core/events/tool-bridge-logging.js +94 -0
- package/dist/core/events/tool-bridge-logging.js.map +1 -0
- package/dist/core/events-metadata.d.ts +72 -0
- package/dist/core/events-metadata.d.ts.map +1 -0
- package/dist/core/events-metadata.js +167 -0
- package/dist/core/events-metadata.js.map +1 -0
- package/dist/core/events.d.ts +186 -0
- package/dist/core/events.d.ts.map +1 -0
- package/dist/core/events.js +1248 -0
- package/dist/core/events.js.map +1 -0
- package/dist/core/export.d.ts +106 -0
- package/dist/core/export.d.ts.map +1 -0
- package/dist/core/export.js +705 -0
- package/dist/core/export.js.map +1 -0
- package/dist/core/file-tools.d.ts +114 -0
- package/dist/core/file-tools.d.ts.map +1 -0
- package/dist/core/file-tools.js +370 -0
- package/dist/core/file-tools.js.map +1 -0
- package/dist/core/google-direct.d.ts +58 -0
- package/dist/core/google-direct.d.ts.map +1 -0
- package/dist/core/google-direct.js +298 -0
- package/dist/core/google-direct.js.map +1 -0
- package/dist/core/hitl.d.ts +54 -0
- package/dist/core/hitl.d.ts.map +1 -0
- package/dist/core/hitl.js +153 -0
- package/dist/core/hitl.js.map +1 -0
- package/dist/core/index.d.ts +59 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +70 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/llm-config.d.ts +128 -0
- package/dist/core/llm-config.d.ts.map +1 -0
- package/dist/core/llm-config.js +164 -0
- package/dist/core/llm-config.js.map +1 -0
- package/dist/core/llm-manager.d.ts +163 -0
- package/dist/core/llm-manager.d.ts.map +1 -0
- package/dist/core/llm-manager.js +669 -0
- package/dist/core/llm-manager.js.map +1 -0
- package/dist/core/load-skill-tool.d.ts +55 -0
- package/dist/core/load-skill-tool.d.ts.map +1 -0
- package/dist/core/load-skill-tool.js +468 -0
- package/dist/core/load-skill-tool.js.map +1 -0
- package/dist/core/logger.d.ts +88 -0
- package/dist/core/logger.d.ts.map +1 -0
- package/dist/core/logger.js +358 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/managers.d.ts +131 -0
- package/dist/core/managers.d.ts.map +1 -0
- package/dist/core/managers.js +1223 -0
- package/dist/core/managers.js.map +1 -0
- package/dist/core/mcp-server-registry.d.ts +304 -0
- package/dist/core/mcp-server-registry.d.ts.map +1 -0
- package/dist/core/mcp-server-registry.js +1769 -0
- package/dist/core/mcp-server-registry.js.map +1 -0
- package/dist/core/mcp-tools.d.ts +56 -0
- package/dist/core/mcp-tools.d.ts.map +1 -0
- package/dist/core/mcp-tools.js +186 -0
- package/dist/core/mcp-tools.js.map +1 -0
- package/dist/core/message-prep.d.ts +81 -0
- package/dist/core/message-prep.d.ts.map +1 -0
- package/dist/core/message-prep.js +223 -0
- package/dist/core/message-prep.js.map +1 -0
- package/dist/core/message-processing-control.d.ts +54 -0
- package/dist/core/message-processing-control.d.ts.map +1 -0
- package/dist/core/message-processing-control.js +139 -0
- package/dist/core/message-processing-control.js.map +1 -0
- package/dist/core/openai-direct.d.ts +80 -0
- package/dist/core/openai-direct.d.ts.map +1 -0
- package/dist/core/openai-direct.js +374 -0
- package/dist/core/openai-direct.js.map +1 -0
- package/dist/core/shell-cmd-tool.d.ts +235 -0
- package/dist/core/shell-cmd-tool.d.ts.map +1 -0
- package/dist/core/shell-cmd-tool.js +1157 -0
- package/dist/core/shell-cmd-tool.js.map +1 -0
- package/dist/core/shell-process-registry.d.ts +88 -0
- package/dist/core/shell-process-registry.d.ts.map +1 -0
- package/dist/core/shell-process-registry.js +309 -0
- package/dist/core/shell-process-registry.js.map +1 -0
- package/dist/core/skill-registry.d.ts +75 -0
- package/dist/core/skill-registry.d.ts.map +1 -0
- package/dist/core/skill-registry.js +369 -0
- package/dist/core/skill-registry.js.map +1 -0
- package/dist/core/skill-script-runner.d.ts +89 -0
- package/dist/core/skill-script-runner.d.ts.map +1 -0
- package/dist/core/skill-script-runner.js +274 -0
- package/dist/core/skill-script-runner.js.map +1 -0
- package/dist/core/skill-selector.d.ts +65 -0
- package/dist/core/skill-selector.d.ts.map +1 -0
- package/dist/core/skill-selector.js +190 -0
- package/dist/core/skill-selector.js.map +1 -0
- package/dist/core/skill-settings.d.ts +20 -0
- package/dist/core/skill-settings.d.ts.map +1 -0
- package/dist/core/skill-settings.js +40 -0
- package/dist/core/skill-settings.js.map +1 -0
- package/dist/core/storage/agent-storage.d.ts +134 -0
- package/dist/core/storage/agent-storage.d.ts.map +1 -0
- package/dist/core/storage/agent-storage.js +498 -0
- package/dist/core/storage/agent-storage.js.map +1 -0
- package/dist/core/storage/eventStorage/fileEventStorage.d.ts +100 -0
- package/dist/core/storage/eventStorage/fileEventStorage.d.ts.map +1 -0
- package/dist/core/storage/eventStorage/fileEventStorage.js +494 -0
- package/dist/core/storage/eventStorage/fileEventStorage.js.map +1 -0
- package/dist/core/storage/eventStorage/index.d.ts +31 -0
- package/dist/core/storage/eventStorage/index.d.ts.map +1 -0
- package/dist/core/storage/eventStorage/index.js +31 -0
- package/dist/core/storage/eventStorage/index.js.map +1 -0
- package/dist/core/storage/eventStorage/memoryEventStorage.d.ts +87 -0
- package/dist/core/storage/eventStorage/memoryEventStorage.d.ts.map +1 -0
- package/dist/core/storage/eventStorage/memoryEventStorage.js +244 -0
- package/dist/core/storage/eventStorage/memoryEventStorage.js.map +1 -0
- package/dist/core/storage/eventStorage/sqliteEventStorage.d.ts +45 -0
- package/dist/core/storage/eventStorage/sqliteEventStorage.d.ts.map +1 -0
- package/dist/core/storage/eventStorage/sqliteEventStorage.js +301 -0
- package/dist/core/storage/eventStorage/sqliteEventStorage.js.map +1 -0
- package/dist/core/storage/eventStorage/types.d.ts +142 -0
- package/dist/core/storage/eventStorage/types.d.ts.map +1 -0
- package/dist/core/storage/eventStorage/types.js +43 -0
- package/dist/core/storage/eventStorage/types.js.map +1 -0
- package/dist/core/storage/eventStorage/validation.d.ts +30 -0
- package/dist/core/storage/eventStorage/validation.d.ts.map +1 -0
- package/dist/core/storage/eventStorage/validation.js +68 -0
- package/dist/core/storage/eventStorage/validation.js.map +1 -0
- package/dist/core/storage/legacy-migrations.d.ts +45 -0
- package/dist/core/storage/legacy-migrations.d.ts.map +1 -0
- package/dist/core/storage/legacy-migrations.js +295 -0
- package/dist/core/storage/legacy-migrations.js.map +1 -0
- package/dist/core/storage/memory-storage.d.ts +105 -0
- package/dist/core/storage/memory-storage.d.ts.map +1 -0
- package/dist/core/storage/memory-storage.js +415 -0
- package/dist/core/storage/memory-storage.js.map +1 -0
- package/dist/core/storage/migration-runner.d.ts +96 -0
- package/dist/core/storage/migration-runner.d.ts.map +1 -0
- package/dist/core/storage/migration-runner.js +306 -0
- package/dist/core/storage/migration-runner.js.map +1 -0
- package/dist/core/storage/queue-storage.d.ts +147 -0
- package/dist/core/storage/queue-storage.d.ts.map +1 -0
- package/dist/core/storage/queue-storage.js +290 -0
- package/dist/core/storage/queue-storage.js.map +1 -0
- package/dist/core/storage/skill-storage.d.ts +136 -0
- package/dist/core/storage/skill-storage.d.ts.map +1 -0
- package/dist/core/storage/skill-storage.js +474 -0
- package/dist/core/storage/skill-storage.js.map +1 -0
- package/dist/core/storage/sqlite-schema.d.ts +95 -0
- package/dist/core/storage/sqlite-schema.d.ts.map +1 -0
- package/dist/core/storage/sqlite-schema.js +156 -0
- package/dist/core/storage/sqlite-schema.js.map +1 -0
- package/dist/core/storage/sqlite-storage.d.ts +146 -0
- package/dist/core/storage/sqlite-storage.d.ts.map +1 -0
- package/dist/core/storage/sqlite-storage.js +709 -0
- package/dist/core/storage/sqlite-storage.js.map +1 -0
- package/dist/core/storage/storage-factory.d.ts +61 -0
- package/dist/core/storage/storage-factory.d.ts.map +1 -0
- package/dist/core/storage/storage-factory.js +794 -0
- package/dist/core/storage/storage-factory.js.map +1 -0
- package/dist/core/storage/validation.d.ts +36 -0
- package/dist/core/storage/validation.d.ts.map +1 -0
- package/dist/core/storage/validation.js +79 -0
- package/dist/core/storage/validation.js.map +1 -0
- package/dist/core/storage/world-storage.d.ts +114 -0
- package/dist/core/storage/world-storage.d.ts.map +1 -0
- package/dist/core/storage/world-storage.js +378 -0
- package/dist/core/storage/world-storage.js.map +1 -0
- package/dist/core/subscription.d.ts +43 -0
- package/dist/core/subscription.d.ts.map +1 -0
- package/dist/core/subscription.js +227 -0
- package/dist/core/subscription.js.map +1 -0
- package/dist/core/tool-utils.d.ts +80 -0
- package/dist/core/tool-utils.d.ts.map +1 -0
- package/dist/core/tool-utils.js +273 -0
- package/dist/core/tool-utils.js.map +1 -0
- package/dist/core/types.d.ts +595 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +158 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/utils.d.ts +138 -0
- package/dist/core/utils.d.ts.map +1 -0
- package/dist/core/utils.js +478 -0
- package/dist/core/utils.js.map +1 -0
- package/dist/core/world-class.d.ts +43 -0
- package/dist/core/world-class.d.ts.map +1 -0
- package/dist/core/world-class.js +90 -0
- package/dist/core/world-class.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/public/assets/agent-sprites-DJFgj-zP.png +0 -0
- package/dist/public/assets/border-KHK37r8y.svg +83 -0
- package/dist/public/assets/index-C9kPXL6G.css +1 -0
- package/dist/public/assets/index-DOQEHGWt.js +96 -0
- package/dist/public/index.html +21 -0
- package/dist/server/api.d.ts +2 -0
- package/dist/server/api.js +1124 -0
- package/dist/server/index.d.ts +29 -0
- package/dist/server/sse-handler.d.ts +62 -0
- package/dist/server/sse-handler.js +234 -0
- package/package.json +15 -3
- package/scripts/launch-electron.js +0 -58
|
@@ -0,0 +1,1769 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Server Registry and Tools Integration - Clean Implementation with Runtime AI SDK Patch
|
|
3
|
+
*
|
|
4
|
+
* Logger Categories: mcp.lifecycle, mcp.connection, mcp.tools, mcp.execution
|
|
5
|
+
* Purpose: MCP server management and tool execution
|
|
6
|
+
*
|
|
7
|
+
* Enable with:
|
|
8
|
+
* - LOG_MCP_LIFECYCLE=info - Server start/stop/ready events
|
|
9
|
+
* - LOG_MCP_CONNECTION=debug - Connection establishment details
|
|
10
|
+
* - LOG_MCP_TOOLS=debug - Tool discovery and caching
|
|
11
|
+
* - LOG_MCP_EXECUTION=debug - Tool execution and results
|
|
12
|
+
* - LOG_MCP=debug - Enable all MCP logs
|
|
13
|
+
*
|
|
14
|
+
* What you'll see:
|
|
15
|
+
* - Server lifecycle: start, stop, ready, shutdown
|
|
16
|
+
* - Connection: transport creation, connection attempts
|
|
17
|
+
* - Tools: discovery, caching, schema validation
|
|
18
|
+
* - Execution: tool calls, results, performance metrics
|
|
19
|
+
*
|
|
20
|
+
* Comprehensive MCP (Model Context Protocol) management system providing:
|
|
21
|
+
* - Server lifecycle management with reference counting and connection pooling
|
|
22
|
+
* - Configuration-based server identification and sharing across worlds
|
|
23
|
+
* - Flexible configuration: Supports both 'servers' and 'mcpServers' field names
|
|
24
|
+
* - AI-compatible tool conversion with schema validation
|
|
25
|
+
* - Transport support for stdio, SSE, and streamable HTTP connections
|
|
26
|
+
* - Health monitoring, error handling, and graceful shutdown
|
|
27
|
+
* - Thread-safe registry operations with world-server mapping
|
|
28
|
+
* - Consolidated logging under LOG_LLM_MCP for unified debugging
|
|
29
|
+
* - Enhanced debug logging for MCP communication data flows
|
|
30
|
+
*
|
|
31
|
+
* Key Features:
|
|
32
|
+
* - Azure OpenAI compatibility: Uses runtime AI SDK patch (core/ai-sdk-patch.ts) for schema corruption fix
|
|
33
|
+
* - Function names use underscores, clean schema structures for tool definitions
|
|
34
|
+
* - Smart server sharing: Multiple worlds can share the same server configuration
|
|
35
|
+
* - Automatic cleanup: Servers shut down when no longer referenced (30s delay)
|
|
36
|
+
* - Error resilience: Comprehensive error handling with fallback mechanisms
|
|
37
|
+
* - Schema validation: Creates well-formed, Azure-compatible schemas for all MCP tools
|
|
38
|
+
* - Performance tracking: Tool execution duration and result analysis
|
|
39
|
+
* - Sequence tracking: Tool call dependencies and execution relationships
|
|
40
|
+
* - Data flow debugging: Complete request/response payload logging
|
|
41
|
+
*
|
|
42
|
+
* Connection Resilience & Lifecycle Management (November 2025):
|
|
43
|
+
* - Automatic reconnection: Detects connection-level errors and attempts reconnection
|
|
44
|
+
* - Retry strategy: Up to 2 attempts for transient network failures
|
|
45
|
+
* - Connection error patterns: ECONNRESET, EPIPE, socket hang up, transport errors
|
|
46
|
+
* - Race condition protection: Prevents concurrent reconnection attempts via reconnecting flag
|
|
47
|
+
* - Client lifecycle management: ClientRef pattern for tracking active connections
|
|
48
|
+
* - Proper resource cleanup: Ensures clients are closed on cache eviction and shutdown
|
|
49
|
+
* - Memory leak prevention: Cache entries deleted even if disposal fails
|
|
50
|
+
* - MCP error response detection: Handles both isError and type: 'error' formats
|
|
51
|
+
*
|
|
52
|
+
* Reconnection Logic:
|
|
53
|
+
* - Triggered automatically on connection-level errors during tool execution
|
|
54
|
+
* - First attempt: Try to reconnect and retry the operation
|
|
55
|
+
* - Second attempt: Fail and propagate error if reconnection unsuccessful
|
|
56
|
+
* - Concurrent calls: Wait for in-progress reconnection instead of creating new ones
|
|
57
|
+
* - Cache refresh: Update cache timestamp after successful reconnection
|
|
58
|
+
* - Logging: Detailed tracking of reconnection attempts and outcomes
|
|
59
|
+
*
|
|
60
|
+
* MCP Communication Debug Logging (LOG_LLM_MCP=debug):
|
|
61
|
+
* - Server connection attempts with transport and configuration details
|
|
62
|
+
* - Tool list requests and responses with full payload data
|
|
63
|
+
* - Tool execution requests with complete argument structures
|
|
64
|
+
* - Tool execution responses with full result content and metadata
|
|
65
|
+
* - Request/response data size and structure analysis
|
|
66
|
+
* - Raw JSON payloads for deep debugging of MCP communication
|
|
67
|
+
* - Connection establishment and transport creation logging
|
|
68
|
+
* - Server registration configuration details
|
|
69
|
+
* - Reconnection attempts and retry logic execution
|
|
70
|
+
*
|
|
71
|
+
* MCP Tool Execution Logging (LOG_LLM_MCP=debug):
|
|
72
|
+
* - Tool execution performance metrics with millisecond precision
|
|
73
|
+
* - Tool result content analysis including size and type identification
|
|
74
|
+
* - Tool call sequence tracking with unique sequence IDs
|
|
75
|
+
* - Success/failure status with detailed error information
|
|
76
|
+
* - Parent-child tool call relationship tracking
|
|
77
|
+
* - Argument validation and presence checking
|
|
78
|
+
* - Result preview for debugging without exposing full content
|
|
79
|
+
* - Complete request/response payload logging for troubleshooting
|
|
80
|
+
* - Retry attempt tracking with attempt number and max attempts
|
|
81
|
+
*
|
|
82
|
+
* Schema Approach:
|
|
83
|
+
* - Uses simplified property types (string, number, boolean, array with string items)
|
|
84
|
+
* - Includes additionalProperties: false to prevent schema expansion
|
|
85
|
+
* - Maintains required fields but simplifies complex nested structures
|
|
86
|
+
* - Works with runtime AI SDK patch to prevent schema corruption in Azure OpenAI calls
|
|
87
|
+
*
|
|
88
|
+
* LLM Argument Type Correction:
|
|
89
|
+
* - Automatically fixes common LLM type errors in tool arguments
|
|
90
|
+
* - String to array conversion: "value" -> ["value"]
|
|
91
|
+
* - String to number conversion: "5" -> 5
|
|
92
|
+
* - Empty/invalid enum omission: "" -> (omitted, uses schema default)
|
|
93
|
+
* - Case-insensitive enum matching: "RELEVANCE" -> "relevance"
|
|
94
|
+
* - Null/undefined omission for optional params: null -> (omitted when not required)
|
|
95
|
+
* - Applied transparently during tool execution to prevent MCP validation errors
|
|
96
|
+
* - Logs all corrections for debugging and monitoring
|
|
97
|
+
* - Schema preservation: bulletproofSchema preserves enum, items, min/max, required for validation
|
|
98
|
+
*
|
|
99
|
+
* Architecture: Function-based design with module-level state management
|
|
100
|
+
* Consolidated from: mcp-server-registry.ts + mcp-tools.ts (August 2025)
|
|
101
|
+
* Runtime patch integration: Works with ai-sdk-patch.ts for Azure compatibility (August 2025)
|
|
102
|
+
* Enhanced debug logging: Complete MCP data flow visibility (August 2025)
|
|
103
|
+
* Scenario-based logging: Split into lifecycle, connection, tools, execution (October 2025)
|
|
104
|
+
* Lifecycle management: Connection resilience and automatic reconnection (November 2025)
|
|
105
|
+
* Explicit execution safety system: Replaced heuristic detection with structured metadata (November 2025)
|
|
106
|
+
* 2026-02-14: Added built-in `load_skill` tool registration for progressive skill instruction loading.
|
|
107
|
+
* 2026-02-19: Added built-in `create_agent` tool registration with approval-gated agent creation.
|
|
108
|
+
*/
|
|
109
|
+
import { createHash } from 'crypto';
|
|
110
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
111
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
112
|
+
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
|
113
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
114
|
+
import { getWorld } from './managers.js';
|
|
115
|
+
import { createCategoryLogger } from './logger.js';
|
|
116
|
+
import { createShellCmdToolDefinition } from './shell-cmd-tool.js';
|
|
117
|
+
import { createLoadSkillToolDefinition } from './load-skill-tool.js';
|
|
118
|
+
import { createCreateAgentToolDefinition } from './create-agent-tool.js';
|
|
119
|
+
import { createReadFileToolDefinition, createListFilesToolDefinition, createGrepToolDefinition, } from './file-tools.js';
|
|
120
|
+
import { wrapToolWithValidation } from './tool-utils.js';
|
|
121
|
+
// Scenario-based loggers for different MCP operations
|
|
122
|
+
const lifecycleLogger = createCategoryLogger('mcp.lifecycle');
|
|
123
|
+
const connectionLogger = createCategoryLogger('mcp.connection');
|
|
124
|
+
const toolsLogger = createCategoryLogger('mcp.tools');
|
|
125
|
+
const executionLogger = createCategoryLogger('mcp.execution');
|
|
126
|
+
// Legacy logger for backward compatibility and general debug logs
|
|
127
|
+
const logger = createCategoryLogger('llm.mcp');
|
|
128
|
+
// === TOOL UTILITY HELPERS ===
|
|
129
|
+
/**
|
|
130
|
+
* Sanitizes tool arguments by redacting sensitive information.
|
|
131
|
+
* Protects passwords, keys, tokens, and other sensitive data.
|
|
132
|
+
*
|
|
133
|
+
* @param args - Tool arguments object
|
|
134
|
+
* @returns Sanitized copy of arguments
|
|
135
|
+
*/
|
|
136
|
+
export function sanitizeArgs(args) {
|
|
137
|
+
if (!args || typeof args !== 'object') {
|
|
138
|
+
return args;
|
|
139
|
+
}
|
|
140
|
+
const sensitiveKeys = ['key', 'password', 'token', 'secret', 'apikey', 'api_key'];
|
|
141
|
+
const sanitized = { ...args };
|
|
142
|
+
for (const key in sanitized) {
|
|
143
|
+
if (sensitiveKeys.some(sk => key.toLowerCase().includes(sk))) {
|
|
144
|
+
sanitized[key] = '[REDACTED]';
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return sanitized;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* OLLAMA BUG FIX: Translate "$" parameter to proper parameter names
|
|
151
|
+
*
|
|
152
|
+
* Problem: Ollama (Llama 3.2) has a bug where it sends {"$": "value"}
|
|
153
|
+
* instead of proper parameter names like {"query": "value"}
|
|
154
|
+
*
|
|
155
|
+
* Solution: Detect single "$" argument and map it to the first required parameter
|
|
156
|
+
*
|
|
157
|
+
* Reference: https://github.com/ollama/ollama/issues/7860
|
|
158
|
+
*/
|
|
159
|
+
function translateOllamaArguments(args, toolSchema) {
|
|
160
|
+
// If args is not an object or doesn't have the "$" bug, return as-is
|
|
161
|
+
if (!args || typeof args !== 'object' || !args.hasOwnProperty('$')) {
|
|
162
|
+
return args;
|
|
163
|
+
}
|
|
164
|
+
// If there are multiple parameters, don't translate (ambiguous)
|
|
165
|
+
const argKeys = Object.keys(args);
|
|
166
|
+
if (argKeys.length !== 1 || argKeys[0] !== '$') {
|
|
167
|
+
return args;
|
|
168
|
+
}
|
|
169
|
+
// Get the schema's required parameters
|
|
170
|
+
const required = toolSchema?.required;
|
|
171
|
+
if (!Array.isArray(required) || required.length === 0) {
|
|
172
|
+
// No required parameters defined, try first property
|
|
173
|
+
const properties = toolSchema?.properties;
|
|
174
|
+
if (properties && typeof properties === 'object') {
|
|
175
|
+
const firstProp = Object.keys(properties)[0];
|
|
176
|
+
if (firstProp) {
|
|
177
|
+
return { [firstProp]: args['$'] };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return args;
|
|
181
|
+
}
|
|
182
|
+
// Map "$" to the first required parameter
|
|
183
|
+
const firstRequired = required[0];
|
|
184
|
+
return { [firstRequired]: args['$'] };
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Validate and correct tool argument types to match schema requirements
|
|
188
|
+
*
|
|
189
|
+
* Problem: LLMs often generate incorrect types for tool arguments:
|
|
190
|
+
* - Strings instead of arrays: "Cantonese" instead of ["Cantonese"]
|
|
191
|
+
* - Strings instead of numbers: "5" instead of 5
|
|
192
|
+
* - Invalid enum values or empty strings
|
|
193
|
+
*
|
|
194
|
+
* Solution: Automatically correct common type mismatches based on schema
|
|
195
|
+
*/
|
|
196
|
+
function validateAndCorrectToolArgs(args, toolSchema) {
|
|
197
|
+
if (!args || typeof args !== 'object' || !toolSchema?.properties) {
|
|
198
|
+
logger.debug(`Skipping type correction - invalid input`, {
|
|
199
|
+
hasArgs: !!args,
|
|
200
|
+
argsType: typeof args,
|
|
201
|
+
hasSchema: !!toolSchema,
|
|
202
|
+
hasProperties: !!toolSchema?.properties,
|
|
203
|
+
schemaKeys: toolSchema ? Object.keys(toolSchema) : []
|
|
204
|
+
});
|
|
205
|
+
return args;
|
|
206
|
+
}
|
|
207
|
+
const corrected = {};
|
|
208
|
+
const corrections = [];
|
|
209
|
+
logger.debug(`Starting type correction`, {
|
|
210
|
+
argKeys: Object.keys(args),
|
|
211
|
+
schemaProps: Object.keys(toolSchema.properties),
|
|
212
|
+
schemaPropsDetail: JSON.stringify(toolSchema.properties, null, 2)
|
|
213
|
+
});
|
|
214
|
+
const requiredParams = toolSchema.required || [];
|
|
215
|
+
for (const [key, value] of Object.entries(args)) {
|
|
216
|
+
const propSchema = toolSchema.properties[key];
|
|
217
|
+
if (!propSchema) {
|
|
218
|
+
// Property not in schema - pass through as-is
|
|
219
|
+
corrected[key] = value;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
// CRITICAL: Omit null/undefined values for optional parameters
|
|
223
|
+
// MCP servers often reject null for optional params, expecting them to be omitted
|
|
224
|
+
if ((value === null || value === undefined) && !requiredParams.includes(key)) {
|
|
225
|
+
corrections.push(`${key}: null/undefined omitted (optional parameter)`);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
// Type correction: string to array
|
|
229
|
+
if (propSchema.type === 'array' && typeof value === 'string' && value !== '') {
|
|
230
|
+
corrected[key] = [value];
|
|
231
|
+
corrections.push(`${key}: string -> array`);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
// Type correction: string to number
|
|
235
|
+
if (propSchema.type === 'number' && typeof value === 'string') {
|
|
236
|
+
const numValue = parseFloat(value);
|
|
237
|
+
if (!isNaN(numValue)) {
|
|
238
|
+
corrected[key] = numValue;
|
|
239
|
+
corrections.push(`${key}: "${value}" -> ${numValue}`);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// Type correction: invalid or empty enum value
|
|
244
|
+
if (propSchema.enum && Array.isArray(propSchema.enum)) {
|
|
245
|
+
if (value === '' || value === null || value === undefined) {
|
|
246
|
+
// Omit empty/null/undefined enum values - let schema defaults apply
|
|
247
|
+
corrections.push(`${key}: empty value omitted (will use default)`);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (!propSchema.enum.includes(value)) {
|
|
251
|
+
// Invalid enum value - try case-insensitive match first
|
|
252
|
+
const lowerValue = typeof value === 'string' ? value.toLowerCase() : value;
|
|
253
|
+
const match = propSchema.enum.find((e) => typeof e === 'string' && e.toLowerCase() === lowerValue);
|
|
254
|
+
if (match) {
|
|
255
|
+
corrected[key] = match;
|
|
256
|
+
corrections.push(`${key}: "${value}" -> "${match}" (case correction)`);
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
// No match - omit invalid value to use schema default
|
|
260
|
+
corrections.push(`${key}: invalid "${value}" omitted (expected: ${propSchema.enum.join('|')})`);
|
|
261
|
+
}
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// No correction needed - pass through
|
|
266
|
+
corrected[key] = value;
|
|
267
|
+
}
|
|
268
|
+
if (corrections.length > 0) {
|
|
269
|
+
logger.debug(`Tool argument type corrections applied`, {
|
|
270
|
+
corrections,
|
|
271
|
+
originalArgs: JSON.stringify(args),
|
|
272
|
+
correctedArgs: JSON.stringify(corrected)
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
return corrected;
|
|
276
|
+
}
|
|
277
|
+
// === UTILITY FUNCTIONS ===
|
|
278
|
+
// Azure OpenAI requires function names: ^[a-zA-Z0-9_\.-]+$
|
|
279
|
+
const sanitize = (s) => s.replace(/[^\w\-\.]/g, '_');
|
|
280
|
+
const nsName = (server, tool) => `${sanitize(server)}_${sanitize(tool)}`;
|
|
281
|
+
function isMCPErrorResponse(response) {
|
|
282
|
+
if (!response || typeof response !== 'object') {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
if ('isError' in response && response.isError) {
|
|
286
|
+
const err = response.error;
|
|
287
|
+
const message = typeof err === 'string'
|
|
288
|
+
? err
|
|
289
|
+
: err?.message ?? 'Unknown MCP tool error';
|
|
290
|
+
const code = typeof err === 'object' && err?.code ? ` (code: ${err.code})` : '';
|
|
291
|
+
return new Error(`MCP tool error${code}: ${message}`);
|
|
292
|
+
}
|
|
293
|
+
if ('type' in response && response.type === 'error') {
|
|
294
|
+
const err = response.error;
|
|
295
|
+
const message = typeof err === 'string'
|
|
296
|
+
? err
|
|
297
|
+
: err?.message ?? 'Unknown MCP tool error';
|
|
298
|
+
return new Error(`MCP tool error: ${message}`);
|
|
299
|
+
}
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
function isConnectionLevelError(error) {
|
|
303
|
+
if (!error || typeof error !== 'object') {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
307
|
+
const lower = message.toLowerCase();
|
|
308
|
+
const errorCode = error?.code ? String(error.code).toLowerCase() : '';
|
|
309
|
+
const CONNECTION_KEYWORDS = [
|
|
310
|
+
'connection closed',
|
|
311
|
+
'connection reset',
|
|
312
|
+
'socket hang up',
|
|
313
|
+
'broken pipe',
|
|
314
|
+
'transport error',
|
|
315
|
+
'cannot call write after a stream was destroyed',
|
|
316
|
+
'econnreset',
|
|
317
|
+
'econnrefused',
|
|
318
|
+
'network connection lost',
|
|
319
|
+
'read epipe'
|
|
320
|
+
];
|
|
321
|
+
return CONNECTION_KEYWORDS.some(keyword => lower.includes(keyword) || errorCode.includes(keyword));
|
|
322
|
+
}
|
|
323
|
+
async function safelyCloseClient(client, context) {
|
|
324
|
+
if (!client) {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
try {
|
|
328
|
+
await client.close();
|
|
329
|
+
logger.debug(`Closed MCP client`, {
|
|
330
|
+
serverName: context.serverName,
|
|
331
|
+
reason: context.reason
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
logger.warn(`Failed to close MCP client for ${context.serverName}`, {
|
|
336
|
+
reason: context.reason,
|
|
337
|
+
error: error instanceof Error ? error.message : error
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
async function disposeToolCacheEntry(entry, reason) {
|
|
342
|
+
await safelyCloseClient(entry.clientRef.current, {
|
|
343
|
+
serverName: entry.serverName,
|
|
344
|
+
reason
|
|
345
|
+
});
|
|
346
|
+
entry.clientRef.current = null;
|
|
347
|
+
}
|
|
348
|
+
async function disposeAllToolCacheEntries(reason) {
|
|
349
|
+
let disposed = 0;
|
|
350
|
+
for (const [key, entry] of Array.from(toolsCache.entries())) {
|
|
351
|
+
try {
|
|
352
|
+
await disposeToolCacheEntry(entry, reason);
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
logger.error(`Failed to dispose cache entry for ${entry.serverName}`, {
|
|
356
|
+
serverName: entry.serverName,
|
|
357
|
+
reason,
|
|
358
|
+
error: error instanceof Error ? error.message : error
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
finally {
|
|
362
|
+
// Always delete from cache to prevent memory leaks
|
|
363
|
+
toolsCache.delete(key);
|
|
364
|
+
disposed++;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return disposed;
|
|
368
|
+
}
|
|
369
|
+
// === TOOL CACHE KEY FUNCTIONS ===
|
|
370
|
+
/**
|
|
371
|
+
* Generate cache key for server-level tool caching
|
|
372
|
+
* Uses server name as the primary cache key
|
|
373
|
+
*/
|
|
374
|
+
function getToolCacheKey(serverName) {
|
|
375
|
+
return sanitize(serverName);
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Generate cache key for individual tool (if needed in future)
|
|
379
|
+
* Format: serverName:toolName
|
|
380
|
+
*/
|
|
381
|
+
function getIndividualToolKey(serverName, toolName) {
|
|
382
|
+
return `${sanitize(serverName)}:${sanitize(toolName)}`;
|
|
383
|
+
}
|
|
384
|
+
// === TOOL CACHE VALIDATION ===
|
|
385
|
+
/**
|
|
386
|
+
* Check if cached tools are still valid
|
|
387
|
+
* Validates against config changes and TTL expiration
|
|
388
|
+
*/
|
|
389
|
+
function isCacheValid(cached, currentConfig) {
|
|
390
|
+
// Check if server config changed
|
|
391
|
+
const currentHash = generateServerId(currentConfig);
|
|
392
|
+
if (cached.serverConfigHash !== currentHash) {
|
|
393
|
+
logger.debug(`Tools cache invalid: config changed for ${cached.serverName}`, {
|
|
394
|
+
serverName: cached.serverName,
|
|
395
|
+
cachedHash: cached.serverConfigHash.slice(0, 8),
|
|
396
|
+
currentHash: currentHash.slice(0, 8)
|
|
397
|
+
});
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
// Check TTL expiration
|
|
401
|
+
const ttl = cached.ttl || DEFAULT_TTL;
|
|
402
|
+
if (ttl > 0 && Date.now() - cached.cachedAt.getTime() > ttl) {
|
|
403
|
+
logger.debug(`Tools cache invalid: TTL expired for ${cached.serverName}`, {
|
|
404
|
+
serverName: cached.serverName,
|
|
405
|
+
cachedAt: cached.cachedAt.toISOString(),
|
|
406
|
+
ttl: ttl,
|
|
407
|
+
age: Date.now() - cached.cachedAt.getTime()
|
|
408
|
+
});
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Evict oldest cache entries when cache size exceeds limit
|
|
415
|
+
*/
|
|
416
|
+
async function evictOldestCacheEntries() {
|
|
417
|
+
if (toolsCache.size <= MAX_CACHE_ENTRIES)
|
|
418
|
+
return;
|
|
419
|
+
const entries = Array.from(toolsCache.entries())
|
|
420
|
+
.sort(([, a], [, b]) => a.cachedAt.getTime() - b.cachedAt.getTime());
|
|
421
|
+
const toEvict = entries.slice(0, entries.length - MAX_CACHE_ENTRIES);
|
|
422
|
+
for (const [key, entry] of toEvict) {
|
|
423
|
+
await disposeToolCacheEntry(entry, 'cache-eviction');
|
|
424
|
+
toolsCache.delete(key);
|
|
425
|
+
logger.debug(`Evicted old tools cache entry: ${entry.serverName}`, {
|
|
426
|
+
serverName: entry.serverName,
|
|
427
|
+
cachedAt: entry.cachedAt.toISOString(),
|
|
428
|
+
age: Date.now() - entry.cachedAt.getTime()
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
// === AZURE OPENAI COMPATIBILITY ===
|
|
433
|
+
/**
|
|
434
|
+
* Create simplified schema for Azure OpenAI compatibility
|
|
435
|
+
* Works with bulletproof schema normalization to ensure clean schema structure
|
|
436
|
+
*/
|
|
437
|
+
function createSimpleSchema(originalSchema) {
|
|
438
|
+
// Always return a fresh, clean object
|
|
439
|
+
const baseSchema = {
|
|
440
|
+
type: 'object',
|
|
441
|
+
properties: {},
|
|
442
|
+
additionalProperties: false
|
|
443
|
+
};
|
|
444
|
+
// For tools with no parameters or empty parameters, use minimal schema
|
|
445
|
+
if (!originalSchema ||
|
|
446
|
+
!originalSchema.properties ||
|
|
447
|
+
typeof originalSchema.properties !== 'object' ||
|
|
448
|
+
Object.keys(originalSchema.properties).length === 0) {
|
|
449
|
+
return baseSchema;
|
|
450
|
+
}
|
|
451
|
+
// For tools with parameters, create simplified property definitions
|
|
452
|
+
const simpleProperties = {};
|
|
453
|
+
for (const [propName, propDef] of Object.entries(originalSchema.properties)) {
|
|
454
|
+
const prop = propDef;
|
|
455
|
+
// Simplify property types for better compatibility
|
|
456
|
+
if (prop.type === 'string') {
|
|
457
|
+
simpleProperties[propName] = { type: 'string' };
|
|
458
|
+
}
|
|
459
|
+
else if (prop.type === 'number' || prop.type === 'integer') {
|
|
460
|
+
simpleProperties[propName] = { type: 'number' };
|
|
461
|
+
}
|
|
462
|
+
else if (prop.type === 'boolean') {
|
|
463
|
+
simpleProperties[propName] = { type: 'boolean' };
|
|
464
|
+
}
|
|
465
|
+
else if (prop.type === 'array') {
|
|
466
|
+
simpleProperties[propName] = {
|
|
467
|
+
type: 'array',
|
|
468
|
+
items: { type: 'string' } // Simplify array items to string
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
// Default everything else to string for schema simplicity
|
|
473
|
+
simpleProperties[propName] = { type: 'string' };
|
|
474
|
+
}
|
|
475
|
+
// Add description if available
|
|
476
|
+
if (prop.description && typeof prop.description === 'string') {
|
|
477
|
+
simpleProperties[propName].description = prop.description;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return {
|
|
481
|
+
...baseSchema,
|
|
482
|
+
properties: simpleProperties,
|
|
483
|
+
...(originalSchema.required && Array.isArray(originalSchema.required) ?
|
|
484
|
+
{ required: [...originalSchema.required] } : {})
|
|
485
|
+
};
|
|
486
|
+
} /**
|
|
487
|
+
* Validate and bulletproof JSON schema for Azure OpenAI compatibility
|
|
488
|
+
* Uses double normalization: bulletproof + simplification for maximum protection
|
|
489
|
+
*/
|
|
490
|
+
export function validateToolSchema(schema) {
|
|
491
|
+
return createSimpleSchema(schema);
|
|
492
|
+
}
|
|
493
|
+
// === CONFIGURATION PARSING ===
|
|
494
|
+
/**
|
|
495
|
+
* Convert MCP config JSON format to normalized server configs
|
|
496
|
+
* Supports both 'servers' and 'mcpServers' field names
|
|
497
|
+
*/
|
|
498
|
+
export function parseServersFromConfig(config) {
|
|
499
|
+
const servers = [];
|
|
500
|
+
// Support both 'servers' and 'mcpServers' fields
|
|
501
|
+
const serverDefs = config.servers || config.mcpServers;
|
|
502
|
+
if (!serverDefs) {
|
|
503
|
+
return servers;
|
|
504
|
+
}
|
|
505
|
+
for (const [name, serverDef] of Object.entries(serverDefs)) {
|
|
506
|
+
if ('command' in serverDef) {
|
|
507
|
+
// Stdio transport (default)
|
|
508
|
+
servers.push({
|
|
509
|
+
name,
|
|
510
|
+
transport: serverDef.transport || 'stdio',
|
|
511
|
+
command: serverDef.command,
|
|
512
|
+
args: serverDef.args,
|
|
513
|
+
env: serverDef.env
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
else if ('url' in serverDef) {
|
|
517
|
+
// HTTP/SSE transport - handle both 'transport' and legacy 'type' fields
|
|
518
|
+
const transportType = ('transport' in serverDef)
|
|
519
|
+
? serverDef.transport
|
|
520
|
+
: ('type' in serverDef)
|
|
521
|
+
? (serverDef.type === 'http' ? 'streamable-http' : serverDef.type)
|
|
522
|
+
: 'streamable-http';
|
|
523
|
+
servers.push({
|
|
524
|
+
name,
|
|
525
|
+
transport: transportType,
|
|
526
|
+
url: serverDef.url,
|
|
527
|
+
headers: serverDef.headers
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return servers;
|
|
532
|
+
}
|
|
533
|
+
// === CLIENT CONNECTION ===
|
|
534
|
+
/**
|
|
535
|
+
* Connect to an MCP server using the specified configuration
|
|
536
|
+
*/
|
|
537
|
+
export async function connectMCPServer(serverConfig) {
|
|
538
|
+
// Debug log: Connection details being used
|
|
539
|
+
logger.debug(`MCP server connection attempt`, {
|
|
540
|
+
serverName: serverConfig.name,
|
|
541
|
+
transport: serverConfig.transport,
|
|
542
|
+
connectionConfig: serverConfig.transport === 'stdio' ? {
|
|
543
|
+
command: serverConfig.command,
|
|
544
|
+
args: serverConfig.args,
|
|
545
|
+
env: serverConfig.env ? Object.keys(serverConfig.env) : []
|
|
546
|
+
} : {
|
|
547
|
+
url: serverConfig.url,
|
|
548
|
+
headers: serverConfig.headers ? Object.keys(serverConfig.headers) : []
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
// Handle traditional MCP SDK transports (stdio, sse, streamable-http)
|
|
552
|
+
const transportType = serverConfig.transport || 'stdio';
|
|
553
|
+
const transport = transportType === 'stdio'
|
|
554
|
+
? new StdioClientTransport({
|
|
555
|
+
command: serverConfig.command,
|
|
556
|
+
args: serverConfig.args ?? [],
|
|
557
|
+
env: serverConfig.env
|
|
558
|
+
})
|
|
559
|
+
: transportType === 'sse'
|
|
560
|
+
? new SSEClientTransport(new URL(serverConfig.url), {
|
|
561
|
+
requestInit: { headers: serverConfig.headers }
|
|
562
|
+
})
|
|
563
|
+
: new StreamableHTTPClientTransport(new URL(serverConfig.url), {
|
|
564
|
+
requestInit: { headers: serverConfig.headers }
|
|
565
|
+
});
|
|
566
|
+
const client = new Client({ name: 'my-app', version: '1.0.0' }, { capabilities: {} });
|
|
567
|
+
logger.debug(`MCP server transport created, initiating connection`, {
|
|
568
|
+
serverName: serverConfig.name,
|
|
569
|
+
transport: serverConfig.transport
|
|
570
|
+
});
|
|
571
|
+
await client.connect(transport);
|
|
572
|
+
logger.debug(`MCP server connection established successfully`, {
|
|
573
|
+
serverName: serverConfig.name,
|
|
574
|
+
transport: serverConfig.transport
|
|
575
|
+
});
|
|
576
|
+
return client;
|
|
577
|
+
}
|
|
578
|
+
// === TOOL CONVERSION ===
|
|
579
|
+
/**
|
|
580
|
+
* Bulletproof schema normalization to prevent AI SDK corruption
|
|
581
|
+
* This is our surgical fix - normalize schemas right before they go to AI SDK
|
|
582
|
+
*/
|
|
583
|
+
function bulletproofSchema(schema) {
|
|
584
|
+
if (!schema || typeof schema !== 'object') {
|
|
585
|
+
return {
|
|
586
|
+
type: 'object',
|
|
587
|
+
properties: {},
|
|
588
|
+
additionalProperties: false
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
// Create a completely fresh object to avoid any corruption
|
|
592
|
+
const normalized = {
|
|
593
|
+
type: 'object',
|
|
594
|
+
properties: {},
|
|
595
|
+
additionalProperties: false
|
|
596
|
+
};
|
|
597
|
+
// Copy properties safely - preserve critical schema information for validation
|
|
598
|
+
if (schema.properties && typeof schema.properties === 'object') {
|
|
599
|
+
for (const [key, value] of Object.entries(schema.properties)) {
|
|
600
|
+
const prop = value;
|
|
601
|
+
normalized.properties[key] = {
|
|
602
|
+
type: prop?.type || 'string',
|
|
603
|
+
...(prop?.description && { description: prop.description }),
|
|
604
|
+
// Preserve enum values for validation
|
|
605
|
+
...(prop?.enum && Array.isArray(prop.enum) && { enum: prop.enum }),
|
|
606
|
+
// Preserve array item schema
|
|
607
|
+
...(prop?.items && { items: prop.items }),
|
|
608
|
+
// Preserve numeric constraints
|
|
609
|
+
...(prop?.minimum !== undefined && { minimum: prop.minimum }),
|
|
610
|
+
...(prop?.maximum !== undefined && { maximum: prop.maximum })
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
// Copy required array safely
|
|
615
|
+
if (schema.required && Array.isArray(schema.required)) {
|
|
616
|
+
normalized.required = [...schema.required];
|
|
617
|
+
}
|
|
618
|
+
return normalized;
|
|
619
|
+
}
|
|
620
|
+
// === TOOL CACHE OPERATIONS ===
|
|
621
|
+
/**
|
|
622
|
+
* Fetch tools from MCP server using ephemeral connection and cache results
|
|
623
|
+
* Handles connection lifecycle and error recovery
|
|
624
|
+
*/
|
|
625
|
+
async function fetchAndCacheTools(serverConfig) {
|
|
626
|
+
const startTime = performance.now();
|
|
627
|
+
const cacheKey = getToolCacheKey(serverConfig.name);
|
|
628
|
+
logger.debug(`Fetching and caching tools for server: ${serverConfig.name}`, {
|
|
629
|
+
serverName: serverConfig.name,
|
|
630
|
+
cacheKey,
|
|
631
|
+
transport: serverConfig.transport
|
|
632
|
+
});
|
|
633
|
+
const clientRef = { current: null, reconnecting: null };
|
|
634
|
+
let cacheEntry = null;
|
|
635
|
+
try {
|
|
636
|
+
// Create ephemeral connection
|
|
637
|
+
clientRef.current = await connectMCPServer(serverConfig);
|
|
638
|
+
const reconnectClient = async (reason) => {
|
|
639
|
+
// Prevent concurrent reconnection attempts
|
|
640
|
+
if (clientRef.reconnecting) {
|
|
641
|
+
logger.debug(`Reconnection already in progress, waiting...`, {
|
|
642
|
+
serverName: serverConfig.name,
|
|
643
|
+
reason
|
|
644
|
+
});
|
|
645
|
+
await clientRef.reconnecting;
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
clientRef.reconnecting = (async () => {
|
|
649
|
+
logger.warn(`Reconnecting MCP client after failure`, {
|
|
650
|
+
serverName: serverConfig.name,
|
|
651
|
+
reason
|
|
652
|
+
});
|
|
653
|
+
const previousClient = clientRef.current;
|
|
654
|
+
clientRef.current = null;
|
|
655
|
+
await safelyCloseClient(previousClient, {
|
|
656
|
+
serverName: serverConfig.name,
|
|
657
|
+
reason: `${reason}-reconnect`
|
|
658
|
+
});
|
|
659
|
+
try {
|
|
660
|
+
const newClient = await connectMCPServer(serverConfig);
|
|
661
|
+
clientRef.current = newClient;
|
|
662
|
+
if (cacheEntry) {
|
|
663
|
+
cacheEntry.cachedAt = new Date();
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
catch (reconnectError) {
|
|
667
|
+
logger.error(`Failed to reconnect MCP client`, {
|
|
668
|
+
serverName: serverConfig.name,
|
|
669
|
+
reason,
|
|
670
|
+
error: reconnectError instanceof Error ? reconnectError.message : reconnectError
|
|
671
|
+
});
|
|
672
|
+
throw reconnectError;
|
|
673
|
+
}
|
|
674
|
+
})();
|
|
675
|
+
try {
|
|
676
|
+
await clientRef.reconnecting;
|
|
677
|
+
}
|
|
678
|
+
finally {
|
|
679
|
+
clientRef.reconnecting = null;
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
// Fetch and convert tools
|
|
683
|
+
const tools = await mcpToolsToAiTools(clientRef, serverConfig, reconnectClient);
|
|
684
|
+
// Cache the results
|
|
685
|
+
cacheEntry = {
|
|
686
|
+
tools,
|
|
687
|
+
cachedAt: new Date(),
|
|
688
|
+
serverConfigHash: generateServerId(serverConfig),
|
|
689
|
+
serverName: serverConfig.name,
|
|
690
|
+
ttl: DEFAULT_TTL,
|
|
691
|
+
clientRef,
|
|
692
|
+
reconnectClient,
|
|
693
|
+
serverConfig
|
|
694
|
+
};
|
|
695
|
+
const existingEntry = toolsCache.get(cacheKey);
|
|
696
|
+
if (existingEntry) {
|
|
697
|
+
await disposeToolCacheEntry(existingEntry, 'cache-refresh');
|
|
698
|
+
toolsCache.delete(cacheKey);
|
|
699
|
+
}
|
|
700
|
+
toolsCache.set(cacheKey, cacheEntry);
|
|
701
|
+
// Evict old entries if needed
|
|
702
|
+
await evictOldestCacheEntries();
|
|
703
|
+
const duration = performance.now() - startTime;
|
|
704
|
+
const toolCount = Object.keys(tools).length;
|
|
705
|
+
logger.debug(`Successfully cached tools for server: ${serverConfig.name}`, {
|
|
706
|
+
serverName: serverConfig.name,
|
|
707
|
+
toolCount,
|
|
708
|
+
duration: Math.round(duration * 100) / 100,
|
|
709
|
+
cacheSize: toolsCache.size
|
|
710
|
+
});
|
|
711
|
+
return tools;
|
|
712
|
+
}
|
|
713
|
+
catch (error) {
|
|
714
|
+
const duration = performance.now() - startTime;
|
|
715
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
716
|
+
await safelyCloseClient(clientRef.current, {
|
|
717
|
+
serverName: serverConfig.name,
|
|
718
|
+
reason: 'fetch-failed'
|
|
719
|
+
});
|
|
720
|
+
clientRef.current = null;
|
|
721
|
+
logger.warn(`Failed to fetch and cache tools for server: ${serverConfig.name}`, {
|
|
722
|
+
serverName: serverConfig.name,
|
|
723
|
+
error: errorMessage,
|
|
724
|
+
duration: Math.round(duration * 100) / 100
|
|
725
|
+
});
|
|
726
|
+
// Return empty tools object on failure - don't break entire tool fetch
|
|
727
|
+
return {};
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Convert MCP tools to AI-compatible tool format with bulletproof schema protection
|
|
732
|
+
*/
|
|
733
|
+
export async function mcpToolsToAiTools(clientRef, serverConfig, reconnectClient) {
|
|
734
|
+
const serverName = serverConfig.name;
|
|
735
|
+
const transport = serverConfig.transport || 'stdio';
|
|
736
|
+
const ensureClient = () => {
|
|
737
|
+
if (!clientRef.current) {
|
|
738
|
+
throw new Error(`MCP client not connected for server: ${serverName}`);
|
|
739
|
+
}
|
|
740
|
+
return clientRef.current;
|
|
741
|
+
};
|
|
742
|
+
logger.debug(`MCP tools list request starting`, {
|
|
743
|
+
serverName,
|
|
744
|
+
operation: 'listTools',
|
|
745
|
+
transport
|
|
746
|
+
});
|
|
747
|
+
let toolsResponse;
|
|
748
|
+
// Use MCP SDK client with timeout
|
|
749
|
+
const listToolsPromise = ensureClient().listTools();
|
|
750
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
751
|
+
setTimeout(() => reject(new Error('Timeout waiting for tools list')), 5000);
|
|
752
|
+
});
|
|
753
|
+
toolsResponse = await Promise.race([listToolsPromise, timeoutPromise]);
|
|
754
|
+
const { tools } = toolsResponse;
|
|
755
|
+
// Debug log: Tools data received from MCP server
|
|
756
|
+
logger.debug(`MCP server tools list response`, {
|
|
757
|
+
serverName,
|
|
758
|
+
operation: 'listTools',
|
|
759
|
+
toolsCount: tools.length,
|
|
760
|
+
toolNames: tools.map((t) => t.name),
|
|
761
|
+
toolsPayload: JSON.stringify(tools, null, 2)
|
|
762
|
+
});
|
|
763
|
+
const aiTools = {};
|
|
764
|
+
for (const t of tools) {
|
|
765
|
+
const key = nsName(serverName, t.name);
|
|
766
|
+
// Debug: Log original tool definition from MCP server
|
|
767
|
+
logger.debug(`MCP original tool definition from server: ${serverName}`, {
|
|
768
|
+
toolName: t.name,
|
|
769
|
+
originalDescription: t.description,
|
|
770
|
+
inputSchema: JSON.stringify(t.inputSchema, null, 2)
|
|
771
|
+
});
|
|
772
|
+
// Apply bulletproof schema normalization - this is our surgical fix
|
|
773
|
+
const bulletproofedSchema = bulletproofSchema(t.inputSchema);
|
|
774
|
+
// Validate and simplify further for Azure compatibility
|
|
775
|
+
const finalSchema = validateToolSchema(bulletproofedSchema);
|
|
776
|
+
// Enhance tool description with explicit usage guidance for execute_command
|
|
777
|
+
let enhancedDescription = t.description ?? '';
|
|
778
|
+
if (t.name === 'execute_command') {
|
|
779
|
+
const originalDesc = t.description ?? '';
|
|
780
|
+
enhancedDescription = 'Execute a user-requested shell command only when the user explicitly asks to run one. Do not use for greetings, general Q&A, or normal conversation. Contract: command must be a single executable token and arguments must be passed as separate argv tokens (no mini scripts). Keep execution inside the trusted working directory/scope configured by runtime guardrails. Reject out-of-scope path requests, shell control syntax (`&&`, `||`, pipes, redirects, substitution, backgrounding), and inline eval/script modes such as `sh -c`, `node -e`, `python -c`, or `powershell -Command`. Because commands run through an OS shell, do not execute untrusted command text.';
|
|
781
|
+
logger.debug(`Enhanced tool description for execute_command`, {
|
|
782
|
+
toolName: t.name,
|
|
783
|
+
originalDescription: originalDesc,
|
|
784
|
+
enhancedDescription: enhancedDescription,
|
|
785
|
+
descriptionChanged: originalDesc !== enhancedDescription
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
aiTools[key] = {
|
|
789
|
+
description: enhancedDescription,
|
|
790
|
+
parameters: finalSchema,
|
|
791
|
+
// Tool metadata
|
|
792
|
+
location: 'server',
|
|
793
|
+
execute: async (args, sequenceId, parentToolCall, context) => {
|
|
794
|
+
const startTime = performance.now();
|
|
795
|
+
const executionId = `${serverName}-${t.name}-${Date.now()}`;
|
|
796
|
+
const chatId = context?.chatId ?? context?.world?.currentChatId ?? null;
|
|
797
|
+
const worldId = context?.worldId ?? context?.world?.id ?? null;
|
|
798
|
+
const agentId = context?.agentId ?? null;
|
|
799
|
+
logger.debug(`MCP tool execution starting via AI conversion`, {
|
|
800
|
+
executionId,
|
|
801
|
+
serverName,
|
|
802
|
+
toolName: t.name,
|
|
803
|
+
toolKey: key,
|
|
804
|
+
sequenceId,
|
|
805
|
+
parentToolCall,
|
|
806
|
+
argsPresent: !!args,
|
|
807
|
+
argsKeys: args ? Object.keys(args) : [],
|
|
808
|
+
transport,
|
|
809
|
+
worldId,
|
|
810
|
+
chatId,
|
|
811
|
+
agentId
|
|
812
|
+
});
|
|
813
|
+
// NOTE: Tool safety checks are handled in tool-utils.ts wrapToolWithValidation().
|
|
814
|
+
// This ensures consistent execution guardrails for all tools (MCP and built-in).
|
|
815
|
+
// Debug log: Request data being sent to MCP server
|
|
816
|
+
// OLLAMA BUG FIX: Translate "$" arguments to proper parameter names
|
|
817
|
+
let translatedArgs = translateOllamaArguments(args ?? {}, t.inputSchema);
|
|
818
|
+
// TYPE CORRECTION: Validate and fix argument types to match schema
|
|
819
|
+
translatedArgs = validateAndCorrectToolArgs(translatedArgs, t.inputSchema);
|
|
820
|
+
const requestPayload = { name: t.name, arguments: translatedArgs };
|
|
821
|
+
logger.debug(`MCP server request payload`, {
|
|
822
|
+
executionId,
|
|
823
|
+
serverName,
|
|
824
|
+
toolName: t.name,
|
|
825
|
+
requestPayload: JSON.stringify(requestPayload, null, 2)
|
|
826
|
+
});
|
|
827
|
+
const maxAttempts = 2;
|
|
828
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
829
|
+
try {
|
|
830
|
+
const res = await ensureClient().callTool(requestPayload);
|
|
831
|
+
const mcpError = isMCPErrorResponse(res);
|
|
832
|
+
if (mcpError) {
|
|
833
|
+
logger.error(`MCP tool execution returned error payload`, {
|
|
834
|
+
executionId,
|
|
835
|
+
serverName,
|
|
836
|
+
toolName: t.name,
|
|
837
|
+
toolKey: key,
|
|
838
|
+
error: mcpError.message,
|
|
839
|
+
transport
|
|
840
|
+
});
|
|
841
|
+
throw mcpError;
|
|
842
|
+
}
|
|
843
|
+
// Debug log: Raw response data received from MCP server
|
|
844
|
+
logger.debug(`MCP server response payload`, {
|
|
845
|
+
executionId,
|
|
846
|
+
serverName,
|
|
847
|
+
toolName: t.name,
|
|
848
|
+
responsePayload: JSON.stringify(res, null, 2),
|
|
849
|
+
responseType: typeof res,
|
|
850
|
+
hasContent: !!(res?.content),
|
|
851
|
+
contentLength: Array.isArray(res?.content) ? res.content.length : 0
|
|
852
|
+
});
|
|
853
|
+
const duration = performance.now() - startTime;
|
|
854
|
+
// Handle result content - prefer text > json > fallback
|
|
855
|
+
let processedResult;
|
|
856
|
+
let resultType = 'unknown';
|
|
857
|
+
if (res?.content && Array.isArray(res.content)) {
|
|
858
|
+
const textPart = res.content.find((p) => p?.type === 'text');
|
|
859
|
+
if (textPart?.text) {
|
|
860
|
+
processedResult = textPart.text;
|
|
861
|
+
resultType = 'text';
|
|
862
|
+
}
|
|
863
|
+
else {
|
|
864
|
+
const jsonPart = res.content.find((p) => p?.type === 'json');
|
|
865
|
+
if (jsonPart?.json) {
|
|
866
|
+
processedResult = jsonPart.json;
|
|
867
|
+
resultType = 'json';
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
if (!processedResult) {
|
|
872
|
+
processedResult = JSON.stringify(res);
|
|
873
|
+
resultType = 'serialized';
|
|
874
|
+
}
|
|
875
|
+
const resultSize = typeof processedResult === 'string' ? processedResult.length : JSON.stringify(processedResult).length;
|
|
876
|
+
logger.debug(`MCP tool execution completed via AI conversion`, {
|
|
877
|
+
executionId,
|
|
878
|
+
serverName,
|
|
879
|
+
toolName: t.name,
|
|
880
|
+
toolKey: key,
|
|
881
|
+
sequenceId,
|
|
882
|
+
parentToolCall,
|
|
883
|
+
status: 'success',
|
|
884
|
+
duration: Math.round(duration * 100) / 100,
|
|
885
|
+
resultType,
|
|
886
|
+
resultSize,
|
|
887
|
+
resultPreview: typeof processedResult === 'string'
|
|
888
|
+
? processedResult.slice(0, 200) + (resultSize > 200 ? '...' : '')
|
|
889
|
+
: JSON.stringify(processedResult).slice(0, 200) + '...',
|
|
890
|
+
transport
|
|
891
|
+
});
|
|
892
|
+
return processedResult;
|
|
893
|
+
}
|
|
894
|
+
catch (error) {
|
|
895
|
+
const duration = performance.now() - startTime;
|
|
896
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
897
|
+
const isLastAttempt = attempt === maxAttempts - 1;
|
|
898
|
+
const shouldRetry = !isLastAttempt && isConnectionLevelError(error);
|
|
899
|
+
if (shouldRetry) {
|
|
900
|
+
logger.warn(`MCP tool execution detected transport issue - attempting reconnect`, {
|
|
901
|
+
executionId,
|
|
902
|
+
serverName,
|
|
903
|
+
toolName: t.name,
|
|
904
|
+
toolKey: key,
|
|
905
|
+
status: 'retrying',
|
|
906
|
+
attempt: attempt + 1,
|
|
907
|
+
maxAttempts,
|
|
908
|
+
duration: Math.round(duration * 100) / 100,
|
|
909
|
+
error: errorMessage,
|
|
910
|
+
transport
|
|
911
|
+
});
|
|
912
|
+
try {
|
|
913
|
+
await reconnectClient('call-tool-failure');
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
catch (reconnectError) {
|
|
917
|
+
logger.error(`MCP tool execution reconnect failed`, {
|
|
918
|
+
executionId,
|
|
919
|
+
serverName,
|
|
920
|
+
toolName: t.name,
|
|
921
|
+
toolKey: key,
|
|
922
|
+
error: reconnectError instanceof Error ? reconnectError.message : reconnectError,
|
|
923
|
+
transport
|
|
924
|
+
});
|
|
925
|
+
throw reconnectError;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
logger.error(`MCP tool execution failed via AI conversion: ${errorMessage}`, {
|
|
929
|
+
executionId,
|
|
930
|
+
serverName,
|
|
931
|
+
toolName: t.name,
|
|
932
|
+
toolKey: key,
|
|
933
|
+
sequenceId,
|
|
934
|
+
parentToolCall,
|
|
935
|
+
status: 'error',
|
|
936
|
+
duration: Math.round(duration * 100) / 100,
|
|
937
|
+
error: errorMessage,
|
|
938
|
+
errorStack: error instanceof Error ? error.stack : undefined,
|
|
939
|
+
transport
|
|
940
|
+
});
|
|
941
|
+
throw error;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
},
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
return aiTools;
|
|
948
|
+
}
|
|
949
|
+
// === SERVER REGISTRY STATE ===
|
|
950
|
+
// Module-level server registry state
|
|
951
|
+
const serverRegistry = new Map();
|
|
952
|
+
const worldServerMapping = new Map(); // worldId -> Set of serverIds
|
|
953
|
+
// Module-level tool cache state
|
|
954
|
+
const toolsCache = new Map(); // serverName -> cached tools
|
|
955
|
+
const DEFAULT_TTL = 60 * 60 * 1000; // 1 hour default TTL
|
|
956
|
+
const MAX_CACHE_ENTRIES = 100; // Maximum cached servers
|
|
957
|
+
let isInitialized = false;
|
|
958
|
+
let shutdownInProgress = false;
|
|
959
|
+
// === CORE REGISTRY FUNCTIONS ===
|
|
960
|
+
/**
|
|
961
|
+
* Generate unique server ID based on configuration hash for sharing
|
|
962
|
+
*/
|
|
963
|
+
function generateServerId(config) {
|
|
964
|
+
const configString = JSON.stringify({
|
|
965
|
+
name: config.name,
|
|
966
|
+
transport: config.transport,
|
|
967
|
+
...(config.transport === 'stdio' ? {
|
|
968
|
+
command: config.command,
|
|
969
|
+
args: config.args,
|
|
970
|
+
env: config.env
|
|
971
|
+
} : {
|
|
972
|
+
url: config.url,
|
|
973
|
+
headers: config.headers
|
|
974
|
+
})
|
|
975
|
+
}, null, 0);
|
|
976
|
+
return createHash('sha256').update(configString).digest('hex');
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* Initialize MCP registry - called on Express app startup
|
|
980
|
+
*/
|
|
981
|
+
export function initializeMCPRegistry() {
|
|
982
|
+
if (isInitialized) {
|
|
983
|
+
logger.warn('MCP registry already initialized');
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
isInitialized = true;
|
|
987
|
+
shutdownInProgress = false;
|
|
988
|
+
logger.info('MCP registry initialized');
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* Register and start an MCP server with reference counting
|
|
992
|
+
* Returns the server ID for tracking. Reuses existing servers when possible.
|
|
993
|
+
*/
|
|
994
|
+
export async function registerMCPServer(config, worldId) {
|
|
995
|
+
if (shutdownInProgress) {
|
|
996
|
+
throw new Error('MCP registry is shutting down');
|
|
997
|
+
}
|
|
998
|
+
const serverId = generateServerId(config);
|
|
999
|
+
let serverInstance = serverRegistry.get(serverId);
|
|
1000
|
+
if (serverInstance) {
|
|
1001
|
+
// Reuse existing server
|
|
1002
|
+
serverInstance.referenceCount++;
|
|
1003
|
+
serverInstance.associatedWorlds.add(worldId);
|
|
1004
|
+
const worldServers = worldServerMapping.get(worldId) || new Set();
|
|
1005
|
+
worldServers.add(serverId);
|
|
1006
|
+
worldServerMapping.set(worldId, worldServers);
|
|
1007
|
+
logger.debug(`Reusing existing MCP server: ${config.name}`, {
|
|
1008
|
+
serverId: serverId.slice(0, 8),
|
|
1009
|
+
referenceCount: serverInstance.referenceCount,
|
|
1010
|
+
status: serverInstance.status
|
|
1011
|
+
});
|
|
1012
|
+
return serverId;
|
|
1013
|
+
}
|
|
1014
|
+
// Create new server instance
|
|
1015
|
+
serverInstance = {
|
|
1016
|
+
id: serverId,
|
|
1017
|
+
config,
|
|
1018
|
+
client: null,
|
|
1019
|
+
status: 'starting',
|
|
1020
|
+
referenceCount: 1,
|
|
1021
|
+
startedAt: new Date(),
|
|
1022
|
+
lastHealthCheck: new Date(),
|
|
1023
|
+
associatedWorlds: new Set([worldId])
|
|
1024
|
+
};
|
|
1025
|
+
serverRegistry.set(serverId, serverInstance);
|
|
1026
|
+
const worldServers = worldServerMapping.get(worldId) || new Set();
|
|
1027
|
+
worldServers.add(serverId);
|
|
1028
|
+
worldServerMapping.set(worldId, worldServers);
|
|
1029
|
+
logger.info(`Starting MCP server: ${config.name}`, {
|
|
1030
|
+
serverId: serverId.slice(0, 8),
|
|
1031
|
+
transport: config.transport,
|
|
1032
|
+
worldId
|
|
1033
|
+
});
|
|
1034
|
+
// Debug log: Full server configuration being used
|
|
1035
|
+
logger.debug(`MCP server registration configuration`, {
|
|
1036
|
+
serverId: serverId.slice(0, 8),
|
|
1037
|
+
serverName: config.name,
|
|
1038
|
+
worldId,
|
|
1039
|
+
fullConfig: JSON.stringify(config, null, 2)
|
|
1040
|
+
});
|
|
1041
|
+
// Start server and wait for ready state
|
|
1042
|
+
try {
|
|
1043
|
+
await startServerAsync(serverInstance);
|
|
1044
|
+
logger.debug(`MCP server ready: ${config.name}`, {
|
|
1045
|
+
serverId: serverId.slice(0, 8),
|
|
1046
|
+
status: serverInstance.status
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
catch (error) {
|
|
1050
|
+
logger.error(`Failed to start MCP server: ${config.name}`, {
|
|
1051
|
+
serverId: serverId.slice(0, 8),
|
|
1052
|
+
error: error instanceof Error ? error.message : error
|
|
1053
|
+
});
|
|
1054
|
+
serverInstance.status = 'error';
|
|
1055
|
+
serverInstance.error = error instanceof Error ? error : new Error(String(error));
|
|
1056
|
+
throw error;
|
|
1057
|
+
}
|
|
1058
|
+
return serverId;
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Unregister MCP server for a world - decreases reference count
|
|
1062
|
+
* Schedules shutdown when no more references exist
|
|
1063
|
+
*/
|
|
1064
|
+
export async function unregisterMCPServer(serverId, worldId) {
|
|
1065
|
+
const serverInstance = serverRegistry.get(serverId);
|
|
1066
|
+
if (!serverInstance)
|
|
1067
|
+
return false;
|
|
1068
|
+
serverInstance.associatedWorlds.delete(worldId);
|
|
1069
|
+
serverInstance.referenceCount = Math.max(0, serverInstance.referenceCount - 1);
|
|
1070
|
+
const worldServers = worldServerMapping.get(worldId);
|
|
1071
|
+
if (worldServers) {
|
|
1072
|
+
worldServers.delete(serverId);
|
|
1073
|
+
if (worldServers.size === 0)
|
|
1074
|
+
worldServerMapping.delete(worldId);
|
|
1075
|
+
}
|
|
1076
|
+
logger.debug(`Unregistered MCP server for world: ${worldId}`, {
|
|
1077
|
+
serverId: serverId.slice(0, 8),
|
|
1078
|
+
referenceCount: serverInstance.referenceCount
|
|
1079
|
+
});
|
|
1080
|
+
// Schedule shutdown after 30s if no more references
|
|
1081
|
+
if (serverInstance.referenceCount === 0) {
|
|
1082
|
+
logger.info(`Scheduling MCP server shutdown: ${serverInstance.config.name}`, {
|
|
1083
|
+
serverId: serverId.slice(0, 8)
|
|
1084
|
+
});
|
|
1085
|
+
setTimeout(async () => {
|
|
1086
|
+
if (serverInstance.referenceCount === 0) {
|
|
1087
|
+
await stopServer(serverInstance);
|
|
1088
|
+
serverRegistry.delete(serverId);
|
|
1089
|
+
}
|
|
1090
|
+
}, 30000);
|
|
1091
|
+
}
|
|
1092
|
+
return true;
|
|
1093
|
+
}
|
|
1094
|
+
// === REGISTRY ACCESS FUNCTIONS ===
|
|
1095
|
+
/** Get MCP server instance by ID */
|
|
1096
|
+
export function getMCPServer(serverId) {
|
|
1097
|
+
return serverRegistry.get(serverId) || null;
|
|
1098
|
+
}
|
|
1099
|
+
/** List all MCP servers */
|
|
1100
|
+
export function listMCPServers() {
|
|
1101
|
+
return Array.from(serverRegistry.values());
|
|
1102
|
+
}
|
|
1103
|
+
/** Get server IDs for a specific world */
|
|
1104
|
+
export function getMCPServersForWorld(worldId) {
|
|
1105
|
+
const worldServers = worldServerMapping.get(worldId);
|
|
1106
|
+
return worldServers ? Array.from(worldServers) : [];
|
|
1107
|
+
}
|
|
1108
|
+
// === LIFECYCLE MANAGEMENT ===
|
|
1109
|
+
/** Shutdown all MCP servers - called on Express app shutdown */
|
|
1110
|
+
export async function shutdownAllMCPServers() {
|
|
1111
|
+
if (shutdownInProgress) {
|
|
1112
|
+
logger.warn('Shutdown already in progress');
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
shutdownInProgress = true;
|
|
1116
|
+
logger.info(`Shutting down ${serverRegistry.size} MCP servers`);
|
|
1117
|
+
const shutdownPromises = Array.from(serverRegistry.values()).map(async (serverInstance) => {
|
|
1118
|
+
try {
|
|
1119
|
+
await stopServer(serverInstance);
|
|
1120
|
+
}
|
|
1121
|
+
catch (error) {
|
|
1122
|
+
logger.error(`Error shutting down MCP server: ${serverInstance.config.name}`, {
|
|
1123
|
+
serverId: serverInstance.id.slice(0, 8),
|
|
1124
|
+
error: error instanceof Error ? error.message : error
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
});
|
|
1128
|
+
await Promise.allSettled(shutdownPromises);
|
|
1129
|
+
serverRegistry.clear();
|
|
1130
|
+
worldServerMapping.clear();
|
|
1131
|
+
// Clear tools cache during shutdown
|
|
1132
|
+
const cacheEntriesCleared = await disposeAllToolCacheEntries('shutdown');
|
|
1133
|
+
isInitialized = false;
|
|
1134
|
+
shutdownInProgress = false;
|
|
1135
|
+
logger.info('All MCP servers shut down', {
|
|
1136
|
+
cacheEntriesCleared
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
// === INTERNAL HELPER FUNCTIONS ===
|
|
1140
|
+
/** Start server asynchronously */
|
|
1141
|
+
async function startServerAsync(serverInstance) {
|
|
1142
|
+
try {
|
|
1143
|
+
const client = await connectMCPServer(serverInstance.config);
|
|
1144
|
+
serverInstance.client = client;
|
|
1145
|
+
serverInstance.status = 'running';
|
|
1146
|
+
serverInstance.lastHealthCheck = new Date();
|
|
1147
|
+
logger.info(`MCP server started successfully: ${serverInstance.config.name}`, {
|
|
1148
|
+
serverId: serverInstance.id.slice(0, 8),
|
|
1149
|
+
referenceCount: serverInstance.referenceCount,
|
|
1150
|
+
transport: serverInstance.config.transport
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
catch (error) {
|
|
1154
|
+
serverInstance.status = 'error';
|
|
1155
|
+
serverInstance.error = error instanceof Error ? error : new Error(String(error));
|
|
1156
|
+
throw error;
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
/** Stop server and cleanup resources */
|
|
1160
|
+
async function stopServer(serverInstance) {
|
|
1161
|
+
if (serverInstance.status === 'stopping' || serverInstance.status === 'error') {
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
serverInstance.status = 'stopping';
|
|
1165
|
+
if (serverInstance.client) {
|
|
1166
|
+
try {
|
|
1167
|
+
await safelyCloseClient(serverInstance.client, {
|
|
1168
|
+
serverName: serverInstance.config.name,
|
|
1169
|
+
reason: 'stop-server'
|
|
1170
|
+
});
|
|
1171
|
+
serverInstance.client = null;
|
|
1172
|
+
logger.info(`MCP server stopped: ${serverInstance.config.name}`, {
|
|
1173
|
+
serverId: serverInstance.id.slice(0, 8)
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
catch (error) {
|
|
1177
|
+
logger.error(`Error stopping MCP server: ${serverInstance.config.name}`, {
|
|
1178
|
+
serverId: serverInstance.id.slice(0, 8),
|
|
1179
|
+
error: error instanceof Error ? error.message : error
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
// === MONITORING AND UTILITIES ===
|
|
1185
|
+
/** Health check for MCP servers - can be called periodically */
|
|
1186
|
+
export function performHealthCheck() {
|
|
1187
|
+
const now = new Date();
|
|
1188
|
+
for (const serverInstance of serverRegistry.values()) {
|
|
1189
|
+
if (serverInstance.status === 'running' && serverInstance.client) {
|
|
1190
|
+
serverInstance.lastHealthCheck = now;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
/** Get registry statistics */
|
|
1195
|
+
export function getMCPRegistryStats() {
|
|
1196
|
+
const servers = Array.from(serverRegistry.values());
|
|
1197
|
+
return {
|
|
1198
|
+
totalServers: servers.length,
|
|
1199
|
+
runningServers: servers.filter(s => s.status === 'running').length,
|
|
1200
|
+
errorServers: servers.filter(s => s.status === 'error').length,
|
|
1201
|
+
totalWorlds: worldServerMapping.size
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
// === CONFIGURATION VALIDATION ===
|
|
1205
|
+
/** Validate MCP configuration format and content */
|
|
1206
|
+
export function validateMCPConfig(config) {
|
|
1207
|
+
try {
|
|
1208
|
+
// Must have either 'servers' or 'mcpServers' field
|
|
1209
|
+
if (!config?.servers && !config?.mcpServers)
|
|
1210
|
+
return false;
|
|
1211
|
+
const serverDefs = config.servers || config.mcpServers;
|
|
1212
|
+
if (typeof serverDefs !== 'object')
|
|
1213
|
+
return false;
|
|
1214
|
+
for (const [serverName, server] of Object.entries(serverDefs)) {
|
|
1215
|
+
if (!serverName || !server || typeof server !== 'object')
|
|
1216
|
+
return false;
|
|
1217
|
+
const serverConfig = server;
|
|
1218
|
+
const transport = serverConfig.transport ||
|
|
1219
|
+
(serverConfig.type === 'http' ? 'streamable-http' : serverConfig.type) ||
|
|
1220
|
+
'stdio';
|
|
1221
|
+
if (!['stdio', 'sse', 'streamable-http', 'http'].includes(transport))
|
|
1222
|
+
return false;
|
|
1223
|
+
if (transport === 'stdio') {
|
|
1224
|
+
if (!serverConfig.command || typeof serverConfig.command !== 'string')
|
|
1225
|
+
return false;
|
|
1226
|
+
}
|
|
1227
|
+
else {
|
|
1228
|
+
if (!serverConfig.url || typeof serverConfig.url !== 'string')
|
|
1229
|
+
return false;
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
return true;
|
|
1233
|
+
}
|
|
1234
|
+
catch (error) {
|
|
1235
|
+
logger.error('Error validating MCP config', { error: error instanceof Error ? error.message : error });
|
|
1236
|
+
return false;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
/** Parse MCP configuration string safely */
|
|
1240
|
+
export function parseMCPConfig(configString) {
|
|
1241
|
+
try {
|
|
1242
|
+
if (!configString?.trim())
|
|
1243
|
+
return null;
|
|
1244
|
+
const config = JSON.parse(configString);
|
|
1245
|
+
return validateMCPConfig(config) ? config : null;
|
|
1246
|
+
}
|
|
1247
|
+
catch (error) {
|
|
1248
|
+
logger.error('Failed to parse MCP config', {
|
|
1249
|
+
error: error instanceof Error ? error.message : error,
|
|
1250
|
+
configString: configString.slice(0, 100) + (configString.length > 100 ? '...' : '')
|
|
1251
|
+
});
|
|
1252
|
+
return null;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
// === HIGH-LEVEL WORLD INTEGRATION ===
|
|
1256
|
+
/** Start MCP servers for a world based on its configuration */
|
|
1257
|
+
export async function startMCPServersForWorld(worldId, mcpConfig) {
|
|
1258
|
+
if (!mcpConfig) {
|
|
1259
|
+
logger.debug(`No MCP config for world: ${worldId}`);
|
|
1260
|
+
return [];
|
|
1261
|
+
}
|
|
1262
|
+
const config = parseMCPConfig(mcpConfig);
|
|
1263
|
+
if (!config) {
|
|
1264
|
+
logger.error(`Invalid MCP config for world: ${worldId}`);
|
|
1265
|
+
return [];
|
|
1266
|
+
}
|
|
1267
|
+
const serverConfigs = parseServersFromConfig(config);
|
|
1268
|
+
const serverIds = [];
|
|
1269
|
+
const startupPromises = [];
|
|
1270
|
+
for (const serverConfig of serverConfigs) {
|
|
1271
|
+
try {
|
|
1272
|
+
const serverIdPromise = registerMCPServer(serverConfig, worldId);
|
|
1273
|
+
startupPromises.push(serverIdPromise.then(serverId => {
|
|
1274
|
+
serverIds.push(serverId);
|
|
1275
|
+
}).catch(error => {
|
|
1276
|
+
logger.error(`Failed to start MCP server: ${serverConfig.name}`, {
|
|
1277
|
+
worldId,
|
|
1278
|
+
error: error instanceof Error ? error.message : error
|
|
1279
|
+
});
|
|
1280
|
+
}));
|
|
1281
|
+
}
|
|
1282
|
+
catch (error) {
|
|
1283
|
+
logger.error(`Error registering MCP server: ${serverConfig.name}`, {
|
|
1284
|
+
worldId,
|
|
1285
|
+
error: error instanceof Error ? error.message : error
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
await Promise.allSettled(startupPromises);
|
|
1290
|
+
logger.info(`Started ${serverIds.length}/${serverConfigs.length} MCP servers for world: ${worldId}`, {
|
|
1291
|
+
serverIds: serverIds.map(id => id.slice(0, 8))
|
|
1292
|
+
});
|
|
1293
|
+
return serverIds;
|
|
1294
|
+
}
|
|
1295
|
+
/** Stop MCP servers for a world - decreases reference counts */
|
|
1296
|
+
export async function stopMCPServersForWorld(worldId) {
|
|
1297
|
+
const worldServers = worldServerMapping.get(worldId);
|
|
1298
|
+
if (!worldServers?.size) {
|
|
1299
|
+
logger.debug(`No MCP servers to stop for world: ${worldId}`);
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
logger.info(`Stopping MCP servers for world: ${worldId}`, {
|
|
1303
|
+
serverCount: worldServers.size,
|
|
1304
|
+
serverIds: Array.from(worldServers).map(id => id.slice(0, 8))
|
|
1305
|
+
});
|
|
1306
|
+
const stopPromises = Array.from(worldServers).map(async (serverId) => {
|
|
1307
|
+
try {
|
|
1308
|
+
await unregisterMCPServer(serverId, worldId);
|
|
1309
|
+
}
|
|
1310
|
+
catch (error) {
|
|
1311
|
+
logger.error(`Error stopping MCP server: ${serverId.slice(0, 8)}`, {
|
|
1312
|
+
worldId,
|
|
1313
|
+
error: error instanceof Error ? error.message : error
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
});
|
|
1317
|
+
await Promise.allSettled(stopPromises);
|
|
1318
|
+
}
|
|
1319
|
+
/** Update MCP servers for a world when configuration changes */
|
|
1320
|
+
export async function updateMCPServersForWorld(worldId, newMcpConfig) {
|
|
1321
|
+
logger.info(`Updating MCP servers for world: ${worldId}`);
|
|
1322
|
+
await stopMCPServersForWorld(worldId);
|
|
1323
|
+
return await startMCPServersForWorld(worldId, newMcpConfig);
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Get built-in tools that are always available to all worlds
|
|
1327
|
+
* These tools don't require MCP server configuration
|
|
1328
|
+
* Built-in tools:
|
|
1329
|
+
* - shell_cmd: Execute shell commands
|
|
1330
|
+
* - load_skill: Load full SKILL.md instructions by registry skill_id
|
|
1331
|
+
* - create_agent: Create a new agent after explicit user approval
|
|
1332
|
+
* - read_file: Read file contents with pagination controls
|
|
1333
|
+
* - list_files: List directory entries
|
|
1334
|
+
* - grep: Recursive text search across files
|
|
1335
|
+
*
|
|
1336
|
+
* @returns Record of built-in tool definitions
|
|
1337
|
+
*/
|
|
1338
|
+
function getBuiltInTools() {
|
|
1339
|
+
const shellCmdTool = createShellCmdToolDefinition();
|
|
1340
|
+
const loadSkillTool = createLoadSkillToolDefinition();
|
|
1341
|
+
const createAgentTool = createCreateAgentToolDefinition();
|
|
1342
|
+
const readFileTool = createReadFileToolDefinition();
|
|
1343
|
+
const listFilesTool = createListFilesToolDefinition();
|
|
1344
|
+
const grepTool = createGrepToolDefinition();
|
|
1345
|
+
return {
|
|
1346
|
+
'shell_cmd': wrapToolWithValidation(shellCmdTool, 'shell_cmd'),
|
|
1347
|
+
'load_skill': wrapToolWithValidation(loadSkillTool, 'load_skill'),
|
|
1348
|
+
'create_agent': wrapToolWithValidation(createAgentTool, 'create_agent'),
|
|
1349
|
+
'read_file': wrapToolWithValidation(readFileTool, 'read_file'),
|
|
1350
|
+
'list_files': wrapToolWithValidation(listFilesTool, 'list_files'),
|
|
1351
|
+
'grep': wrapToolWithValidation(grepTool, 'grep'),
|
|
1352
|
+
'grep_search': wrapToolWithValidation(grepTool, 'grep_search'),
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
/**
|
|
1356
|
+
* Get MCP tools available for a world with on-demand server startup
|
|
1357
|
+
*/
|
|
1358
|
+
/**
|
|
1359
|
+
* Get MCP tools available for a world with registry-level caching
|
|
1360
|
+
* Uses cache-first strategy to avoid repeated tool fetching during ephemeral connections
|
|
1361
|
+
* Includes built-in tools (`shell_cmd`, `load_skill`) in addition to MCP server tools
|
|
1362
|
+
*/
|
|
1363
|
+
export async function getMCPToolsForWorld(worldId) {
|
|
1364
|
+
const startTime = performance.now();
|
|
1365
|
+
const world = await getWorld(worldId);
|
|
1366
|
+
// Start with built-in tools
|
|
1367
|
+
const allTools = getBuiltInTools();
|
|
1368
|
+
if (!world?.mcpConfig) {
|
|
1369
|
+
logger.debug(`No MCP config for world: ${worldId}, returning built-in tools only`, {
|
|
1370
|
+
worldId,
|
|
1371
|
+
builtInToolCount: Object.keys(allTools).length
|
|
1372
|
+
});
|
|
1373
|
+
return allTools;
|
|
1374
|
+
}
|
|
1375
|
+
const config = parseMCPConfig(world.mcpConfig);
|
|
1376
|
+
if (!config) {
|
|
1377
|
+
logger.error(`Invalid MCP config for world: ${worldId}, returning built-in tools only`, {
|
|
1378
|
+
worldId,
|
|
1379
|
+
builtInToolCount: Object.keys(allTools).length
|
|
1380
|
+
});
|
|
1381
|
+
return allTools;
|
|
1382
|
+
}
|
|
1383
|
+
const serverConfigs = parseServersFromConfig(config);
|
|
1384
|
+
const serverPromises = [];
|
|
1385
|
+
let cacheHits = 0;
|
|
1386
|
+
let cacheMisses = 0;
|
|
1387
|
+
for (const serverConfig of serverConfigs) {
|
|
1388
|
+
const serverPromise = (async () => {
|
|
1389
|
+
try {
|
|
1390
|
+
const cacheKey = getToolCacheKey(serverConfig.name);
|
|
1391
|
+
const cached = toolsCache.get(cacheKey);
|
|
1392
|
+
const cacheIsValid = cached ? isCacheValid(cached, serverConfig) : false;
|
|
1393
|
+
// Check cache first
|
|
1394
|
+
if (cached && cacheIsValid) {
|
|
1395
|
+
// Cache hit
|
|
1396
|
+
cacheHits++;
|
|
1397
|
+
Object.assign(allTools, cached.tools);
|
|
1398
|
+
logger.debug(`Tools cache hit for server: ${serverConfig.name}`, {
|
|
1399
|
+
serverName: serverConfig.name,
|
|
1400
|
+
toolCount: Object.keys(cached.tools).length,
|
|
1401
|
+
age: Date.now() - cached.cachedAt.getTime()
|
|
1402
|
+
});
|
|
1403
|
+
return;
|
|
1404
|
+
}
|
|
1405
|
+
if (cached && !cacheIsValid) {
|
|
1406
|
+
await disposeToolCacheEntry(cached, 'stale-cache-entry');
|
|
1407
|
+
toolsCache.delete(cacheKey);
|
|
1408
|
+
}
|
|
1409
|
+
// Cache miss - fetch and cache tools
|
|
1410
|
+
cacheMisses++;
|
|
1411
|
+
if (cached) {
|
|
1412
|
+
logger.debug(`Tools cache miss (invalid) for server: ${serverConfig.name}`, {
|
|
1413
|
+
serverName: serverConfig.name,
|
|
1414
|
+
reason: cacheIsValid ? 'unknown' : 'invalid'
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1417
|
+
else {
|
|
1418
|
+
logger.debug(`Tools cache miss (not found) for server: ${serverConfig.name}`, {
|
|
1419
|
+
serverName: serverConfig.name
|
|
1420
|
+
});
|
|
1421
|
+
}
|
|
1422
|
+
const serverTools = await fetchAndCacheTools(serverConfig);
|
|
1423
|
+
Object.assign(allTools, serverTools);
|
|
1424
|
+
}
|
|
1425
|
+
catch (error) {
|
|
1426
|
+
logger.error(`Failed to get tools from MCP server: ${serverConfig.name}`, {
|
|
1427
|
+
worldId,
|
|
1428
|
+
error: error instanceof Error ? error.message : error
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
})();
|
|
1432
|
+
serverPromises.push(serverPromise);
|
|
1433
|
+
}
|
|
1434
|
+
await Promise.allSettled(serverPromises);
|
|
1435
|
+
const totalTools = Object.keys(allTools).length;
|
|
1436
|
+
const builtInToolCount = Object.keys(getBuiltInTools()).length;
|
|
1437
|
+
const mcpToolCount = totalTools - builtInToolCount;
|
|
1438
|
+
const duration = performance.now() - startTime;
|
|
1439
|
+
logger.info(`Retrieved ${totalTools} total tools for world: ${worldId} (${builtInToolCount} built-in, ${mcpToolCount} from MCP)`, {
|
|
1440
|
+
worldId,
|
|
1441
|
+
totalTools,
|
|
1442
|
+
builtInToolCount,
|
|
1443
|
+
mcpToolCount,
|
|
1444
|
+
cacheHits,
|
|
1445
|
+
cacheMisses,
|
|
1446
|
+
cacheHitRate: cacheHits + cacheMisses > 0 ? Math.round((cacheHits / (cacheHits + cacheMisses)) * 100) : 0,
|
|
1447
|
+
duration: Math.round(duration * 100) / 100,
|
|
1448
|
+
cacheSize: toolsCache.size
|
|
1449
|
+
});
|
|
1450
|
+
return allTools;
|
|
1451
|
+
}
|
|
1452
|
+
/**
|
|
1453
|
+
* Execute MCP tool by server ID and tool name
|
|
1454
|
+
*
|
|
1455
|
+
* @param serverId - Server ID from registry
|
|
1456
|
+
* @param toolName - Name of the tool to execute
|
|
1457
|
+
* @param args - Tool arguments (will be validated if schema provided)
|
|
1458
|
+
* @param sequenceId - Optional sequence ID for tracking
|
|
1459
|
+
* @param parentToolCall - Optional parent tool call ID
|
|
1460
|
+
* @param toolSchema - Optional tool input schema for parameter validation.
|
|
1461
|
+
* If provided, applies validateAndCorrectToolArgs to fix
|
|
1462
|
+
* common LLM parameter mistakes (string→array, string→number,
|
|
1463
|
+
* invalid enums, etc.)
|
|
1464
|
+
*
|
|
1465
|
+
* @example
|
|
1466
|
+
* // With validation (recommended for external callers)
|
|
1467
|
+
* const schema = { properties: { limit: { type: 'number' } }, required: [] };
|
|
1468
|
+
* const result = await executeMCPTool(serverId, 'searchAgents', args, seq, parent, schema);
|
|
1469
|
+
*
|
|
1470
|
+
* // Without validation (legacy behavior)
|
|
1471
|
+
* const result = await executeMCPTool(serverId, 'searchAgents', args);
|
|
1472
|
+
*/
|
|
1473
|
+
export async function executeMCPTool(serverId, toolName, args, sequenceId, parentToolCall, toolSchema) {
|
|
1474
|
+
const serverInstance = serverRegistry.get(serverId);
|
|
1475
|
+
if (!serverInstance) {
|
|
1476
|
+
throw new Error(`MCP server not found: ${serverId.slice(0, 8)}`);
|
|
1477
|
+
}
|
|
1478
|
+
if (serverInstance.status !== 'running' || !serverInstance.client) {
|
|
1479
|
+
throw new Error(`MCP server not available: ${serverInstance.config.name} (status: ${serverInstance.status})`);
|
|
1480
|
+
}
|
|
1481
|
+
const startTime = performance.now();
|
|
1482
|
+
const executionId = `${serverId.slice(0, 8)}-${toolName}-${Date.now()}`;
|
|
1483
|
+
logger.debug(`MCP tool execution starting`, {
|
|
1484
|
+
executionId,
|
|
1485
|
+
serverId: serverId.slice(0, 8),
|
|
1486
|
+
toolName,
|
|
1487
|
+
serverName: serverInstance.config.name,
|
|
1488
|
+
sequenceId,
|
|
1489
|
+
parentToolCall,
|
|
1490
|
+
argsPresent: !!args,
|
|
1491
|
+
argsKeys: args ? Object.keys(args) : [],
|
|
1492
|
+
hasSchema: !!toolSchema
|
|
1493
|
+
});
|
|
1494
|
+
// OLLAMA BUG FIX: Translate "$" arguments to proper parameter names
|
|
1495
|
+
let validatedArgs = translateOllamaArguments(args || {}, toolSchema);
|
|
1496
|
+
// ENHANCEMENT: Apply parameter validation if schema provided
|
|
1497
|
+
// This ensures executeMCPTool has same validation as mcpToolsToAiTools wrapper
|
|
1498
|
+
if (toolSchema) {
|
|
1499
|
+
validatedArgs = validateAndCorrectToolArgs(validatedArgs, toolSchema);
|
|
1500
|
+
logger.debug(`Parameter validation applied in executeMCPTool`, {
|
|
1501
|
+
executionId,
|
|
1502
|
+
serverId: serverId.slice(0, 8),
|
|
1503
|
+
toolName,
|
|
1504
|
+
hasSchema: true,
|
|
1505
|
+
originalArgs: args,
|
|
1506
|
+
validatedArgs
|
|
1507
|
+
});
|
|
1508
|
+
}
|
|
1509
|
+
const requestPayload = { name: toolName, arguments: validatedArgs };
|
|
1510
|
+
logger.debug(`MCP server direct request payload`, {
|
|
1511
|
+
executionId,
|
|
1512
|
+
serverId: serverId.slice(0, 8),
|
|
1513
|
+
toolName,
|
|
1514
|
+
serverName: serverInstance.config.name,
|
|
1515
|
+
requestPayload: JSON.stringify(requestPayload, null, 2)
|
|
1516
|
+
});
|
|
1517
|
+
const maxAttempts = 2;
|
|
1518
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1519
|
+
if (!serverInstance.client) {
|
|
1520
|
+
throw new Error(`MCP server not available: ${serverInstance.config.name} (client disconnected)`);
|
|
1521
|
+
}
|
|
1522
|
+
try {
|
|
1523
|
+
const result = await serverInstance.client.callTool(requestPayload);
|
|
1524
|
+
const mcpError = isMCPErrorResponse(result);
|
|
1525
|
+
if (mcpError) {
|
|
1526
|
+
logger.error(`MCP tool execution returned error payload`, {
|
|
1527
|
+
executionId,
|
|
1528
|
+
serverId: serverId.slice(0, 8),
|
|
1529
|
+
toolName,
|
|
1530
|
+
serverName: serverInstance.config.name,
|
|
1531
|
+
error: mcpError.message
|
|
1532
|
+
});
|
|
1533
|
+
throw mcpError;
|
|
1534
|
+
}
|
|
1535
|
+
// Debug log: Raw response data received from MCP server
|
|
1536
|
+
logger.debug(`MCP server direct response payload`, {
|
|
1537
|
+
executionId,
|
|
1538
|
+
serverId: serverId.slice(0, 8),
|
|
1539
|
+
toolName,
|
|
1540
|
+
serverName: serverInstance.config.name,
|
|
1541
|
+
responsePayload: JSON.stringify(result, null, 2),
|
|
1542
|
+
responseType: typeof result,
|
|
1543
|
+
hasContent: !!(result?.content),
|
|
1544
|
+
contentLength: Array.isArray(result?.content) ? result.content.length : 0
|
|
1545
|
+
});
|
|
1546
|
+
const duration = performance.now() - startTime;
|
|
1547
|
+
const hasContent = result?.content && Array.isArray(result.content) && result.content.length > 0;
|
|
1548
|
+
const contentTypes = hasContent ? result.content.map((c) => c?.type).filter(Boolean) : [];
|
|
1549
|
+
const resultSize = hasContent ? JSON.stringify(result).length : 0;
|
|
1550
|
+
logger.debug(`MCP tool execution completed`, {
|
|
1551
|
+
executionId,
|
|
1552
|
+
serverId: serverId.slice(0, 8),
|
|
1553
|
+
toolName,
|
|
1554
|
+
serverName: serverInstance.config.name,
|
|
1555
|
+
sequenceId,
|
|
1556
|
+
parentToolCall,
|
|
1557
|
+
status: 'success',
|
|
1558
|
+
duration: Math.round(duration * 100) / 100, // Round to 2 decimal places
|
|
1559
|
+
hasContent,
|
|
1560
|
+
contentTypes,
|
|
1561
|
+
resultSize,
|
|
1562
|
+
resultPreview: hasContent ? JSON.stringify(result).slice(0, 200) + (resultSize > 200 ? '...' : '') : null
|
|
1563
|
+
});
|
|
1564
|
+
return result;
|
|
1565
|
+
}
|
|
1566
|
+
catch (error) {
|
|
1567
|
+
const duration = performance.now() - startTime;
|
|
1568
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1569
|
+
const isLastAttempt = attempt === maxAttempts - 1;
|
|
1570
|
+
const shouldRetry = !isLastAttempt && isConnectionLevelError(error);
|
|
1571
|
+
if (shouldRetry) {
|
|
1572
|
+
logger.warn(`MCP tool execution detected transport issue - attempting direct reconnect`, {
|
|
1573
|
+
executionId,
|
|
1574
|
+
serverId: serverId.slice(0, 8),
|
|
1575
|
+
toolName,
|
|
1576
|
+
serverName: serverInstance.config.name,
|
|
1577
|
+
status: 'retrying',
|
|
1578
|
+
attempt: attempt + 1,
|
|
1579
|
+
maxAttempts,
|
|
1580
|
+
duration: Math.round(duration * 100) / 100,
|
|
1581
|
+
error: errorMessage
|
|
1582
|
+
});
|
|
1583
|
+
await safelyCloseClient(serverInstance.client, {
|
|
1584
|
+
serverName: serverInstance.config.name,
|
|
1585
|
+
reason: 'direct-call-reconnect'
|
|
1586
|
+
});
|
|
1587
|
+
serverInstance.client = null;
|
|
1588
|
+
try {
|
|
1589
|
+
serverInstance.client = await connectMCPServer(serverInstance.config);
|
|
1590
|
+
serverInstance.status = 'running';
|
|
1591
|
+
serverInstance.lastHealthCheck = new Date();
|
|
1592
|
+
continue;
|
|
1593
|
+
}
|
|
1594
|
+
catch (reconnectError) {
|
|
1595
|
+
const reconnectMessage = reconnectError instanceof Error ? reconnectError.message : String(reconnectError);
|
|
1596
|
+
logger.error(`MCP direct tool execution reconnect failed`, {
|
|
1597
|
+
executionId,
|
|
1598
|
+
serverId: serverId.slice(0, 8),
|
|
1599
|
+
toolName,
|
|
1600
|
+
serverName: serverInstance.config.name,
|
|
1601
|
+
error: reconnectMessage
|
|
1602
|
+
});
|
|
1603
|
+
serverInstance.status = 'error';
|
|
1604
|
+
serverInstance.error = reconnectError instanceof Error ? reconnectError : new Error(reconnectMessage);
|
|
1605
|
+
throw reconnectError;
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
logger.error(`MCP tool execution failed: ${errorMessage}`, {
|
|
1609
|
+
executionId,
|
|
1610
|
+
serverId: serverId.slice(0, 8),
|
|
1611
|
+
toolName,
|
|
1612
|
+
serverName: serverInstance.config.name,
|
|
1613
|
+
sequenceId,
|
|
1614
|
+
parentToolCall,
|
|
1615
|
+
status: 'error',
|
|
1616
|
+
duration: Math.round(duration * 100) / 100,
|
|
1617
|
+
error: errorMessage,
|
|
1618
|
+
errorStack: error instanceof Error ? error.stack : undefined
|
|
1619
|
+
});
|
|
1620
|
+
throw error;
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
/**
|
|
1625
|
+
* Restart MCP server by ID
|
|
1626
|
+
*/
|
|
1627
|
+
export async function restartMCPServer(serverId) {
|
|
1628
|
+
const serverInstance = serverRegistry.get(serverId);
|
|
1629
|
+
if (!serverInstance)
|
|
1630
|
+
return false;
|
|
1631
|
+
logger.info(`Restarting MCP server: ${serverInstance.config.name}`, {
|
|
1632
|
+
serverId: serverId.slice(0, 8)
|
|
1633
|
+
});
|
|
1634
|
+
try {
|
|
1635
|
+
await stopServer(serverInstance);
|
|
1636
|
+
await startServerAsync(serverInstance);
|
|
1637
|
+
return true;
|
|
1638
|
+
}
|
|
1639
|
+
catch (error) {
|
|
1640
|
+
logger.error(`Failed to restart MCP server: ${serverInstance.config.name}`, {
|
|
1641
|
+
serverId: serverId.slice(0, 8),
|
|
1642
|
+
error: error instanceof Error ? error.message : error
|
|
1643
|
+
});
|
|
1644
|
+
serverInstance.status = 'error';
|
|
1645
|
+
serverInstance.error = error instanceof Error ? error : new Error(String(error));
|
|
1646
|
+
return false;
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
// === TOOL CACHE MANAGEMENT ===
|
|
1650
|
+
/**
|
|
1651
|
+
* Clear cached tools for specific server or all servers
|
|
1652
|
+
* Useful for forcing cache refresh when server tools change
|
|
1653
|
+
*/
|
|
1654
|
+
export async function clearToolsCache(serverName) {
|
|
1655
|
+
if (serverName) {
|
|
1656
|
+
const cacheKey = getToolCacheKey(serverName);
|
|
1657
|
+
const entry = toolsCache.get(cacheKey);
|
|
1658
|
+
let deleted = false;
|
|
1659
|
+
if (entry) {
|
|
1660
|
+
await disposeToolCacheEntry(entry, 'manual-clear');
|
|
1661
|
+
deleted = toolsCache.delete(cacheKey);
|
|
1662
|
+
}
|
|
1663
|
+
logger.info(`Cleared tools cache for server: ${serverName}`, {
|
|
1664
|
+
serverName,
|
|
1665
|
+
found: deleted,
|
|
1666
|
+
remainingEntries: toolsCache.size
|
|
1667
|
+
});
|
|
1668
|
+
}
|
|
1669
|
+
else {
|
|
1670
|
+
const entriesCleared = await disposeAllToolCacheEntries('manual-clear-all');
|
|
1671
|
+
logger.info(`Cleared all tools cache entries`, {
|
|
1672
|
+
entriesCleared
|
|
1673
|
+
});
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
/**
|
|
1677
|
+
* Get tools cache statistics for monitoring and debugging
|
|
1678
|
+
*/
|
|
1679
|
+
export function getToolsCacheStats() {
|
|
1680
|
+
const entries = Array.from(toolsCache.values());
|
|
1681
|
+
if (entries.length === 0) {
|
|
1682
|
+
return {
|
|
1683
|
+
totalEntries: 0,
|
|
1684
|
+
totalTools: 0,
|
|
1685
|
+
cacheSize: 0,
|
|
1686
|
+
memoryUsage: { approximate: '0 B' }
|
|
1687
|
+
};
|
|
1688
|
+
}
|
|
1689
|
+
const sortedByDate = entries.sort((a, b) => a.cachedAt.getTime() - b.cachedAt.getTime());
|
|
1690
|
+
const totalTools = entries.reduce((sum, entry) => sum + Object.keys(entry.tools).length, 0);
|
|
1691
|
+
// Rough memory usage estimate (ignore non-serializable client references)
|
|
1692
|
+
const approximateSize = JSON.stringify(entries.map(entry => ({
|
|
1693
|
+
serverName: entry.serverName,
|
|
1694
|
+
cachedAt: entry.cachedAt.toISOString(),
|
|
1695
|
+
toolCount: Object.keys(entry.tools).length,
|
|
1696
|
+
transport: entry.serverConfig.transport
|
|
1697
|
+
}))).length;
|
|
1698
|
+
const formatSize = (bytes) => {
|
|
1699
|
+
if (bytes < 1024)
|
|
1700
|
+
return `${bytes} B`;
|
|
1701
|
+
if (bytes < 1024 * 1024)
|
|
1702
|
+
return `${Math.round(bytes / 1024)} KB`;
|
|
1703
|
+
return `${Math.round(bytes / (1024 * 1024) * 100) / 100} MB`;
|
|
1704
|
+
};
|
|
1705
|
+
return {
|
|
1706
|
+
totalEntries: entries.length,
|
|
1707
|
+
totalTools,
|
|
1708
|
+
cacheSize: toolsCache.size,
|
|
1709
|
+
oldestEntry: sortedByDate.length > 0 ? {
|
|
1710
|
+
serverName: sortedByDate[0].serverName,
|
|
1711
|
+
cachedAt: sortedByDate[0].cachedAt
|
|
1712
|
+
} : undefined,
|
|
1713
|
+
newestEntry: sortedByDate.length > 0 ? {
|
|
1714
|
+
serverName: sortedByDate[sortedByDate.length - 1].serverName,
|
|
1715
|
+
cachedAt: sortedByDate[sortedByDate.length - 1].cachedAt
|
|
1716
|
+
} : undefined,
|
|
1717
|
+
memoryUsage: { approximate: formatSize(approximateSize) }
|
|
1718
|
+
};
|
|
1719
|
+
}
|
|
1720
|
+
/**
|
|
1721
|
+
* Refresh cached tools for a specific server (force cache miss on next access)
|
|
1722
|
+
*/
|
|
1723
|
+
export async function refreshServerToolsCache(serverName) {
|
|
1724
|
+
const cacheKey = getToolCacheKey(serverName);
|
|
1725
|
+
const entry = toolsCache.get(cacheKey);
|
|
1726
|
+
if (entry) {
|
|
1727
|
+
await disposeToolCacheEntry(entry, 'manual-refresh');
|
|
1728
|
+
toolsCache.delete(cacheKey);
|
|
1729
|
+
logger.info(`Marked tools cache for refresh: ${serverName}`, {
|
|
1730
|
+
serverName,
|
|
1731
|
+
remainingEntries: toolsCache.size
|
|
1732
|
+
});
|
|
1733
|
+
return true;
|
|
1734
|
+
}
|
|
1735
|
+
logger.debug(`Tools cache not found for server: ${serverName}`, {
|
|
1736
|
+
serverName
|
|
1737
|
+
});
|
|
1738
|
+
return false;
|
|
1739
|
+
}
|
|
1740
|
+
/** Get MCP system health status */
|
|
1741
|
+
export function getMCPSystemHealth() {
|
|
1742
|
+
const servers = Array.from(serverRegistry.values());
|
|
1743
|
+
const healthyServers = servers.filter(s => s.status === 'running');
|
|
1744
|
+
const unhealthyServers = servers.filter(s => s.status === 'error');
|
|
1745
|
+
const errors = unhealthyServers.map(s => s.error?.message || 'Unknown error');
|
|
1746
|
+
let status;
|
|
1747
|
+
if (servers.length === 0) {
|
|
1748
|
+
status = 'healthy'; // No servers is healthy
|
|
1749
|
+
}
|
|
1750
|
+
else if (unhealthyServers.length === 0) {
|
|
1751
|
+
status = 'healthy';
|
|
1752
|
+
}
|
|
1753
|
+
else if (healthyServers.length > unhealthyServers.length) {
|
|
1754
|
+
status = 'degraded';
|
|
1755
|
+
}
|
|
1756
|
+
else {
|
|
1757
|
+
status = 'unhealthy';
|
|
1758
|
+
}
|
|
1759
|
+
return {
|
|
1760
|
+
status,
|
|
1761
|
+
details: {
|
|
1762
|
+
totalServers: servers.length,
|
|
1763
|
+
healthyServers: healthyServers.length,
|
|
1764
|
+
unhealthyServers: unhealthyServers.length,
|
|
1765
|
+
errors
|
|
1766
|
+
}
|
|
1767
|
+
};
|
|
1768
|
+
}
|
|
1769
|
+
//# sourceMappingURL=mcp-server-registry.js.map
|