@1mcp/agent 0.1.0 → 0.4.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 +176 -53
- package/build/capabilities/capabilityManager.js +8 -10
- package/build/client.js +4 -3
- package/build/clients/clientManager.js +47 -12
- package/build/config/configManager.js +31 -9
- package/build/config/configManager.test.js +106 -0
- package/build/config/transportConfig.js +38 -23
- package/build/constants.js +32 -1
- package/build/handlers/notificationHandlers.js +11 -11
- package/build/handlers/requestHandlers.js +81 -52
- package/build/index.js +64 -98
- package/build/{logger.js → logger/logger.js} +7 -20
- package/build/{mcpTransport.js → logger/mcpTransport.js} +33 -5
- package/build/middleware/loggingMiddleware.js +114 -0
- package/build/server/expressServer.js +109 -0
- package/build/server.js +9 -42
- package/build/serverManager.js +78 -0
- package/build/serverManager.test.js +158 -0
- package/build/services/configReloadService.js +21 -26
- package/build/types.js +31 -0
- package/build/utils/clientFiltering.js +29 -0
- package/build/utils/errorHandling.js +67 -44
- package/build/utils/errorTypes.js +47 -0
- package/package.json +26 -4
package/README.md
CHANGED
|
@@ -4,67 +4,207 @@ A unified Model Context Protocol server implementation that aggregates multiple
|
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
|
7
|
-
1MCP (One MCP) is designed to simplify the way you work with AI
|
|
7
|
+
1MCP (One MCP) is designed to simplify the way you work with AI assistants. Instead of configuring multiple MCP servers for different clients (Claude Desktop, Cherry Studio, Cursor, Roo Code, Claude, etc.), 1MCP provides a single, unified server that:
|
|
8
8
|
|
|
9
9
|
- Aggregates multiple MCP servers into one unified interface
|
|
10
10
|
- Reduces system resource usage by eliminating redundant server instances
|
|
11
|
-
- Simplifies configuration management across different AI
|
|
11
|
+
- Simplifies configuration management across different AI assistants
|
|
12
12
|
- Provides a standardized way for AI models to interact with external tools and resources
|
|
13
|
+
- Supports dynamic configuration reloading without server restart
|
|
14
|
+
- Handles graceful shutdown and resource cleanup
|
|
13
15
|
|
|
14
|
-
|
|
16
|
+
## Quick Start
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
- Winston-based logging system with MCP protocol integration
|
|
18
|
-
- Support for MCP capabilities and notifications
|
|
19
|
-
- Smart request routing and capability aggregation
|
|
18
|
+
To enable Cursor to use existing MCP servers already configured in Claude Desktop, follow these steps:
|
|
20
19
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
- Express.js for HTTP handling
|
|
26
|
-
- Server-Sent Events (SSE) for real-time communication
|
|
27
|
-
- MCP SDK for protocol implementation
|
|
28
|
-
|
|
29
|
-
### Server Configuration
|
|
30
|
-
|
|
31
|
-
The server runs on port 3050 by default and exposes the following endpoints:
|
|
32
|
-
|
|
33
|
-
- `/sse` - SSE endpoint for establishing connections
|
|
34
|
-
- `/messages` - Endpoint for receiving messages from clients
|
|
35
|
-
|
|
36
|
-
## MCP Logging Implementation
|
|
37
|
-
|
|
38
|
-
This project includes a Winston transport for the Model Context Protocol (MCP) logging protocol. The implementation allows logs from the agent to be sent to MCP clients using the standard MCP logging notification format.
|
|
20
|
+
1. Run the 1MCP server with the Claude Desktop config file:
|
|
21
|
+
```bash
|
|
22
|
+
npx -y @1mcp/agent --config ~/Library/Application\ Support/Claude/claude_desktop_config.json
|
|
23
|
+
```
|
|
39
24
|
|
|
40
|
-
|
|
25
|
+
2. Add the 1MCP server to your Cursor config file (`~/.cursor/mcp.json`):
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"mcpServers": {
|
|
29
|
+
"1mcp": {
|
|
30
|
+
"type": "http",
|
|
31
|
+
"url": "http://localhost:3050/sse"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
41
36
|
|
|
42
|
-
|
|
43
|
-
- Automatic mapping of Winston log levels to MCP log levels
|
|
44
|
-
- Support for structured logging data
|
|
45
|
-
- Configurable logger name
|
|
46
|
-
- Connection-aware logging (only sends logs when clients are connected)
|
|
47
|
-
- Sensitive data sanitization
|
|
37
|
+
3. Enjoy it!
|
|
48
38
|
|
|
49
39
|
## Usage
|
|
50
40
|
|
|
51
41
|
You can run the server directly using `npx`:
|
|
52
42
|
|
|
53
43
|
```bash
|
|
54
|
-
#
|
|
44
|
+
# Basic usage (starts server with SSE transport)
|
|
55
45
|
npx -y @1mcp/agent
|
|
56
46
|
|
|
57
|
-
#
|
|
47
|
+
# Use existing Claude Desktop config
|
|
48
|
+
npx -y @1mcp/agent --config ~/Library/Application\ Support/Claude/claude_desktop_config.json
|
|
49
|
+
|
|
50
|
+
# Use stdio transport instead of SSE
|
|
58
51
|
npx -y @1mcp/agent --transport stdio
|
|
59
52
|
|
|
60
|
-
# Show
|
|
53
|
+
# Show all available options
|
|
61
54
|
npx -y @1mcp/agent --help
|
|
62
55
|
```
|
|
63
56
|
|
|
64
57
|
Available options:
|
|
65
|
-
- `--transport, -t`:
|
|
58
|
+
- `--transport, -t`: Choose transport type ("stdio" or "sse", default: "sse")
|
|
59
|
+
- `--config, -c`: Use a specific config file
|
|
60
|
+
- `--port, -P`: Change SSE port (default: 3050)
|
|
61
|
+
- `--host, -H`: Change SSE host (default: localhost)
|
|
62
|
+
- `--tags, -g`: Filter servers by tags (see Tags section below)
|
|
66
63
|
- `--help, -h`: Show help
|
|
67
64
|
|
|
65
|
+
### Understanding Tags
|
|
66
|
+
|
|
67
|
+
Tags help you control which MCP servers are available to different clients. Think of tags as labels that describe what each server can do.
|
|
68
|
+
|
|
69
|
+
#### How to Use Tags
|
|
70
|
+
|
|
71
|
+
1. **In your server config**: Add tags to each server to describe its capabilities
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"mcpServers": {
|
|
75
|
+
"web-server": {
|
|
76
|
+
"command": "uvx",
|
|
77
|
+
"args": ["mcp-server-fetch"],
|
|
78
|
+
"tags": ["network", "web"],
|
|
79
|
+
"disabled": false
|
|
80
|
+
},
|
|
81
|
+
"file-server": {
|
|
82
|
+
"command": "npx",
|
|
83
|
+
"args": ["-y", "@modelcontextprotocol/server-filesystem", "~/Downloads"],
|
|
84
|
+
"tags": ["filesystem"],
|
|
85
|
+
"disabled": false
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
2. **When starting 1MCP in stdio mode**: You can filter servers by tags
|
|
92
|
+
```bash
|
|
93
|
+
# Only start servers with the "network" tag
|
|
94
|
+
npx -y @1mcp/agent --transport stdio --tags "network"
|
|
95
|
+
|
|
96
|
+
# Start servers with either "network" or "filesystem" tags
|
|
97
|
+
npx -y @1mcp/agent --transport stdio --tags "network,filesystem"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
3. **When using SSE transport**: Clients can request servers with specific tags
|
|
101
|
+
```json
|
|
102
|
+
{
|
|
103
|
+
"mcpServers": {
|
|
104
|
+
"1mcp": {
|
|
105
|
+
"type": "http",
|
|
106
|
+
"url": "http://localhost:3050/sse?tags=network" // Only connect to network-capable servers
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Example tags:
|
|
113
|
+
- `network`: For servers that make web requests
|
|
114
|
+
- `filesystem`: For servers that handle file operations
|
|
115
|
+
- `memory`: For servers that provide memory/storage
|
|
116
|
+
- `shell`: For servers that run shell commands
|
|
117
|
+
- `db`: For servers that handle database operations
|
|
118
|
+
|
|
119
|
+
## Configuration
|
|
120
|
+
|
|
121
|
+
### Global Configuration
|
|
122
|
+
|
|
123
|
+
The server automatically manages configuration in a global location:
|
|
124
|
+
|
|
125
|
+
- macOS/Linux: `~/.config/1mcp/mcp.json`
|
|
126
|
+
- Windows: `%APPDATA%/1mcp/mcp.json`
|
|
127
|
+
|
|
128
|
+
### Configuration File Format
|
|
129
|
+
|
|
130
|
+
```json
|
|
131
|
+
{
|
|
132
|
+
"mcpServers": {
|
|
133
|
+
"mcp-server-fetch": {
|
|
134
|
+
"command": "uvx",
|
|
135
|
+
"args": [
|
|
136
|
+
"mcp-server-fetch"
|
|
137
|
+
],
|
|
138
|
+
"disabled": false
|
|
139
|
+
},
|
|
140
|
+
"server-memory": {
|
|
141
|
+
"command": "npx",
|
|
142
|
+
"args": [
|
|
143
|
+
"-y",
|
|
144
|
+
"@modelcontextprotocol/server-memory"
|
|
145
|
+
],
|
|
146
|
+
"disabled": false
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## How It Works
|
|
153
|
+
|
|
154
|
+
### System Architecture
|
|
155
|
+
|
|
156
|
+
```mermaid
|
|
157
|
+
graph TB
|
|
158
|
+
subgraph "AI Assistants"
|
|
159
|
+
A1[Claude Desktop]
|
|
160
|
+
A2[Cursor]
|
|
161
|
+
A3[Cherry Studio]
|
|
162
|
+
A4[Roo Code]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
subgraph "1MCP Server"
|
|
166
|
+
MCP[1MCP Agent]
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
subgraph "MCP Servers"
|
|
170
|
+
S1[Server 1]
|
|
171
|
+
S2[Server 2]
|
|
172
|
+
S3[Server 3]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
A1 -->|sse| MCP
|
|
176
|
+
A2 -->|sse| MCP
|
|
177
|
+
A3 -->|sse| MCP
|
|
178
|
+
A4 -->|sse| MCP
|
|
179
|
+
|
|
180
|
+
MCP --> |sse| S1
|
|
181
|
+
MCP --> |stdio| S2
|
|
182
|
+
MCP --> |stdio| S3
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Request Flow
|
|
186
|
+
|
|
187
|
+
```mermaid
|
|
188
|
+
sequenceDiagram
|
|
189
|
+
participant Client as AI Assistant
|
|
190
|
+
participant 1MCP as 1MCP Server
|
|
191
|
+
participant MCP as MCP Servers
|
|
192
|
+
|
|
193
|
+
Client->>1MCP: Send MCP Request
|
|
194
|
+
activate 1MCP
|
|
195
|
+
|
|
196
|
+
1MCP->>1MCP: Validate Request
|
|
197
|
+
1MCP->>1MCP: Load Config
|
|
198
|
+
1MCP->>MCP: Forward Request
|
|
199
|
+
activate MCP
|
|
200
|
+
|
|
201
|
+
MCP-->>1MCP: Response
|
|
202
|
+
deactivate MCP
|
|
203
|
+
|
|
204
|
+
1MCP-->>Client: Forward Response
|
|
205
|
+
deactivate 1MCP
|
|
206
|
+
```
|
|
207
|
+
|
|
68
208
|
## Development
|
|
69
209
|
|
|
70
210
|
Install dependencies:
|
|
@@ -87,23 +227,6 @@ Run the server:
|
|
|
87
227
|
pnpm dev
|
|
88
228
|
```
|
|
89
229
|
|
|
90
|
-
## Installation
|
|
91
|
-
|
|
92
|
-
To use with Claude Desktop, add the server config:
|
|
93
|
-
|
|
94
|
-
On MacOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
95
|
-
On Windows: `%APPDATA%/Claude/claude_desktop_config.json`
|
|
96
|
-
|
|
97
|
-
```json
|
|
98
|
-
{
|
|
99
|
-
"mcpServers": {
|
|
100
|
-
"agent": {
|
|
101
|
-
"command": "/path/to/agent/build/index.js"
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
```
|
|
106
|
-
|
|
107
230
|
### Debugging
|
|
108
231
|
|
|
109
232
|
Since MCP servers communicate over stdio, debugging can be challenging. We recommend using the [MCP Inspector](https://github.com/modelcontextprotocol/inspector), which is available as a package script:
|
|
@@ -1,23 +1,21 @@
|
|
|
1
|
-
import logger from '../logger.js';
|
|
1
|
+
import logger from '../logger/logger.js';
|
|
2
2
|
import { setupClientToServerNotifications, setupServerToClientNotifications, } from '../handlers/notificationHandlers.js';
|
|
3
3
|
import { registerRequestHandlers } from '../handlers/requestHandlers.js';
|
|
4
4
|
/**
|
|
5
5
|
* Collects capabilities from all clients and registers them with the server
|
|
6
6
|
* @param clients Record of client instances
|
|
7
7
|
* @param server The MCP server instance
|
|
8
|
+
* @param tags Array of tags to filter clients by
|
|
8
9
|
* @returns The combined server capabilities
|
|
9
10
|
*/
|
|
10
|
-
export async function
|
|
11
|
+
export async function setupCapabilities(clients, serverInfo) {
|
|
11
12
|
// Collect capabilities from all clients
|
|
12
13
|
const capabilities = collectCapabilities(clients);
|
|
13
14
|
// Set up notification handlers
|
|
14
|
-
setupClientToServerNotifications(clients,
|
|
15
|
-
setupServerToClientNotifications(clients,
|
|
16
|
-
// Register capabilities with the server
|
|
17
|
-
logger.info(`Registering capabilities: ${JSON.stringify(capabilities)}`);
|
|
18
|
-
server.registerCapabilities(capabilities);
|
|
15
|
+
setupClientToServerNotifications(clients, serverInfo);
|
|
16
|
+
setupServerToClientNotifications(clients, serverInfo);
|
|
19
17
|
// Register request handlers based on capabilities
|
|
20
|
-
registerRequestHandlers(clients,
|
|
18
|
+
registerRequestHandlers(clients, serverInfo, capabilities);
|
|
21
19
|
return capabilities;
|
|
22
20
|
}
|
|
23
21
|
/**
|
|
@@ -27,9 +25,9 @@ export async function collectAndRegisterCapabilities(clients, server) {
|
|
|
27
25
|
*/
|
|
28
26
|
function collectCapabilities(clients) {
|
|
29
27
|
let capabilities = {};
|
|
30
|
-
for (const [name,
|
|
28
|
+
for (const [name, clientInfo] of Object.entries(clients)) {
|
|
31
29
|
try {
|
|
32
|
-
const clientCapabilities = client.getServerCapabilities() || {};
|
|
30
|
+
const clientCapabilities = clientInfo.client.getServerCapabilities() || {};
|
|
33
31
|
logger.debug(`Capabilities from ${name}: ${JSON.stringify(clientCapabilities)}`);
|
|
34
32
|
capabilities = { ...capabilities, ...clientCapabilities };
|
|
35
33
|
}
|
package/build/client.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
2
|
-
|
|
2
|
+
import { MCP_SERVER_NAME, MCP_SERVER_VERSION } from './constants.js';
|
|
3
|
+
export default async function createClient() {
|
|
3
4
|
const client = new Client({
|
|
4
|
-
name:
|
|
5
|
-
version:
|
|
5
|
+
name: MCP_SERVER_NAME,
|
|
6
|
+
version: MCP_SERVER_VERSION,
|
|
6
7
|
}, {
|
|
7
8
|
capabilities: {
|
|
8
9
|
experimental: {},
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import createClient from '../client.js';
|
|
2
|
-
import logger from '../logger.js';
|
|
3
|
-
import { CONNECTION_RETRY } from '../constants.js';
|
|
4
|
-
import {
|
|
2
|
+
import logger from '../logger/logger.js';
|
|
3
|
+
import { CONNECTION_RETRY, MCP_SERVER_NAME } from '../constants.js';
|
|
4
|
+
import { withErrorHandling } from '../utils/errorHandling.js';
|
|
5
|
+
import { ClientConnectionError, ClientNotFoundError } from '../utils/errorTypes.js';
|
|
6
|
+
import { ClientStatus } from '../types.js';
|
|
5
7
|
/**
|
|
6
8
|
* Creates client instances for all transports with retry logic
|
|
7
9
|
* @param transports Record of transport instances
|
|
@@ -12,18 +14,30 @@ export async function createClients(transports) {
|
|
|
12
14
|
for (const [name, transport] of Object.entries(transports)) {
|
|
13
15
|
logger.info(`Creating client for ${name}`);
|
|
14
16
|
try {
|
|
15
|
-
const client = await createClient(
|
|
17
|
+
const client = await createClient();
|
|
16
18
|
// Connect with retry logic
|
|
17
19
|
await connectWithRetry(client, transport, name);
|
|
18
|
-
clients[name] =
|
|
20
|
+
clients[name] = {
|
|
21
|
+
name,
|
|
22
|
+
transport,
|
|
23
|
+
client,
|
|
24
|
+
status: ClientStatus.Connected,
|
|
25
|
+
lastConnected: new Date(),
|
|
26
|
+
};
|
|
19
27
|
logger.info(`Client created for ${name}`);
|
|
20
28
|
}
|
|
21
29
|
catch (error) {
|
|
22
30
|
logger.error(`Failed to create client for ${name}: ${error}`);
|
|
23
|
-
|
|
31
|
+
clients[name] = {
|
|
32
|
+
name,
|
|
33
|
+
transport,
|
|
34
|
+
client: await createClient(),
|
|
35
|
+
status: ClientStatus.Error,
|
|
36
|
+
lastError: error instanceof Error ? error : new Error(String(error)),
|
|
37
|
+
};
|
|
24
38
|
}
|
|
25
39
|
}
|
|
26
|
-
return clients;
|
|
40
|
+
return Object.freeze(clients);
|
|
27
41
|
}
|
|
28
42
|
/**
|
|
29
43
|
* Connects a client to its transport with retry logic
|
|
@@ -36,7 +50,11 @@ async function connectWithRetry(client, transport, name) {
|
|
|
36
50
|
for (let i = 0; i < CONNECTION_RETRY.MAX_ATTEMPTS; i++) {
|
|
37
51
|
try {
|
|
38
52
|
await client.connect(transport);
|
|
39
|
-
|
|
53
|
+
const sv = await client.getServerVersion();
|
|
54
|
+
if (sv?.name === MCP_SERVER_NAME) {
|
|
55
|
+
throw new ClientConnectionError(name, new Error('Aborted to prevent circular dependency'));
|
|
56
|
+
}
|
|
57
|
+
logger.info(`Successfully connected to ${name} with server ${sv?.name} version ${sv?.version}`);
|
|
40
58
|
return;
|
|
41
59
|
}
|
|
42
60
|
catch (error) {
|
|
@@ -67,13 +85,30 @@ export function getClient(clients, clientName) {
|
|
|
67
85
|
return client;
|
|
68
86
|
}
|
|
69
87
|
/**
|
|
70
|
-
* Executes a client operation with error handling
|
|
88
|
+
* Executes a client operation with error handling and timeout
|
|
71
89
|
* @param clients Record of client instances
|
|
72
90
|
* @param clientName The name of the client to use
|
|
73
91
|
* @param operation The operation to execute
|
|
92
|
+
* @param options Operation options including timeout and retry settings
|
|
74
93
|
* @returns The result of the operation
|
|
75
94
|
*/
|
|
76
|
-
export async function executeClientOperation(clients, clientName, operation) {
|
|
77
|
-
const
|
|
78
|
-
|
|
95
|
+
export async function executeClientOperation(clients, clientName, operation, options = {}) {
|
|
96
|
+
const clientInfo = getClient(clients, clientName);
|
|
97
|
+
const { retryCount = 0, retryDelay = 1000 } = options;
|
|
98
|
+
return withErrorHandling(async () => {
|
|
99
|
+
let lastError;
|
|
100
|
+
for (let i = 0; i <= retryCount; i++) {
|
|
101
|
+
try {
|
|
102
|
+
return await operation(clientInfo);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
106
|
+
if (i < retryCount) {
|
|
107
|
+
logger.info(`Retrying operation ${operation.name} on client ${clientName} after ${retryDelay}ms`);
|
|
108
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
throw lastError;
|
|
113
|
+
}, `Error executing operation on client ${clientName}`)();
|
|
79
114
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { EventEmitter } from 'events';
|
|
4
|
-
import {
|
|
5
|
-
import logger from '../logger.js';
|
|
4
|
+
import { getGlobalConfigPath, getGlobalConfigDir, DEFAULT_CONFIG } from '../constants.js';
|
|
5
|
+
import logger from '../logger/logger.js';
|
|
6
6
|
/**
|
|
7
7
|
* Configuration change event types
|
|
8
8
|
*/
|
|
@@ -20,22 +20,44 @@ export class ConfigManager extends EventEmitter {
|
|
|
20
20
|
configFilePath;
|
|
21
21
|
/**
|
|
22
22
|
* Private constructor to enforce singleton pattern
|
|
23
|
+
* @param configFilePath - Optional path to the config file. If not provided, uses global config path
|
|
23
24
|
*/
|
|
24
|
-
constructor() {
|
|
25
|
+
constructor(configFilePath) {
|
|
25
26
|
super();
|
|
26
|
-
this.configFilePath =
|
|
27
|
+
this.configFilePath = configFilePath || getGlobalConfigPath();
|
|
28
|
+
this.ensureConfigExists();
|
|
27
29
|
this.loadConfig();
|
|
28
30
|
}
|
|
29
31
|
/**
|
|
30
|
-
* Get the singleton instance of
|
|
31
|
-
* @
|
|
32
|
+
* Get the singleton instance of ConfigManager
|
|
33
|
+
* @param configFilePath - Optional path to the config file
|
|
32
34
|
*/
|
|
33
|
-
static getInstance() {
|
|
35
|
+
static getInstance(configFilePath) {
|
|
34
36
|
if (!ConfigManager.instance) {
|
|
35
|
-
ConfigManager.instance = new ConfigManager();
|
|
37
|
+
ConfigManager.instance = new ConfigManager(configFilePath);
|
|
36
38
|
}
|
|
37
39
|
return ConfigManager.instance;
|
|
38
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Ensure the config directory and file exist
|
|
43
|
+
*/
|
|
44
|
+
ensureConfigExists() {
|
|
45
|
+
try {
|
|
46
|
+
const configDir = getGlobalConfigDir();
|
|
47
|
+
if (!fs.existsSync(configDir)) {
|
|
48
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
49
|
+
logger.info(`Created config directory: ${configDir}`);
|
|
50
|
+
}
|
|
51
|
+
if (!fs.existsSync(this.configFilePath)) {
|
|
52
|
+
fs.writeFileSync(this.configFilePath, JSON.stringify(DEFAULT_CONFIG, null, 2));
|
|
53
|
+
logger.info(`Created default config file: ${this.configFilePath}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
logger.error(`Failed to ensure config exists: ${error}`);
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
39
61
|
/**
|
|
40
62
|
* Load the configuration from the config file
|
|
41
63
|
*/
|
|
@@ -105,4 +127,4 @@ export class ConfigManager extends EventEmitter {
|
|
|
105
127
|
return { ...this.transportConfig };
|
|
106
128
|
}
|
|
107
129
|
}
|
|
108
|
-
export default ConfigManager
|
|
130
|
+
export default ConfigManager;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
// Mock modules before importing ConfigManager
|
|
4
|
+
jest.mock('fs', () => ({
|
|
5
|
+
existsSync: jest.fn(),
|
|
6
|
+
mkdirSync: jest.fn(),
|
|
7
|
+
writeFileSync: jest.fn(),
|
|
8
|
+
readFileSync: jest.fn(),
|
|
9
|
+
watch: jest.fn(() => ({ close: jest.fn() })),
|
|
10
|
+
}));
|
|
11
|
+
const mockLogger = {
|
|
12
|
+
error: jest.fn(),
|
|
13
|
+
warn: jest.fn(),
|
|
14
|
+
info: jest.fn(),
|
|
15
|
+
debug: jest.fn(),
|
|
16
|
+
level: 'info',
|
|
17
|
+
transports: [],
|
|
18
|
+
};
|
|
19
|
+
jest.doMock('../logger/logger.js', () => ({
|
|
20
|
+
__esModule: true,
|
|
21
|
+
default: mockLogger,
|
|
22
|
+
}));
|
|
23
|
+
// Mock constants
|
|
24
|
+
jest.doMock('../constants.js', () => ({
|
|
25
|
+
__esModule: true,
|
|
26
|
+
DEFAULT_CONFIG: { mcpServers: {} },
|
|
27
|
+
getGlobalConfigPath: jest.fn(),
|
|
28
|
+
getGlobalConfigDir: jest.fn().mockReturnValue('/test'),
|
|
29
|
+
}));
|
|
30
|
+
// Import after mocks are set up
|
|
31
|
+
import { ConfigManager, ConfigChangeEvent } from './configManager.js';
|
|
32
|
+
import { DEFAULT_CONFIG } from '../constants.js';
|
|
33
|
+
describe('ConfigManager', () => {
|
|
34
|
+
const testConfigPath = '/test/config.json';
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
// Reset all mocks
|
|
37
|
+
jest.clearAllMocks();
|
|
38
|
+
// Reset singleton instance
|
|
39
|
+
ConfigManager.instance = undefined;
|
|
40
|
+
// Default mock implementations
|
|
41
|
+
fs.existsSync.mockReturnValue(true);
|
|
42
|
+
fs.readFileSync.mockReturnValue(JSON.stringify({ mcpServers: {} }));
|
|
43
|
+
});
|
|
44
|
+
describe('getInstance', () => {
|
|
45
|
+
it('should create singleton instance', () => {
|
|
46
|
+
const instance1 = ConfigManager.getInstance(testConfigPath);
|
|
47
|
+
const instance2 = ConfigManager.getInstance(testConfigPath);
|
|
48
|
+
expect(instance1).toBe(instance2);
|
|
49
|
+
});
|
|
50
|
+
it('should use provided config path', () => {
|
|
51
|
+
const instance = ConfigManager.getInstance(testConfigPath);
|
|
52
|
+
expect(instance.configFilePath).toBe(testConfigPath);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
describe('ensureConfigExists', () => {
|
|
56
|
+
it('should create config directory and file if they do not exist', () => {
|
|
57
|
+
fs.existsSync
|
|
58
|
+
.mockReturnValueOnce(false) // Directory doesn't exist
|
|
59
|
+
.mockReturnValueOnce(false); // File doesn't exist
|
|
60
|
+
ConfigManager.getInstance(testConfigPath);
|
|
61
|
+
expect(fs.mkdirSync).toHaveBeenCalledWith(path.dirname(testConfigPath), { recursive: true });
|
|
62
|
+
expect(fs.writeFileSync).toHaveBeenCalledWith(testConfigPath, JSON.stringify(DEFAULT_CONFIG, null, 2));
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
describe('config watching', () => {
|
|
66
|
+
it('should start and stop watching config file', () => {
|
|
67
|
+
const instance = ConfigManager.getInstance(testConfigPath);
|
|
68
|
+
instance.startWatching();
|
|
69
|
+
expect(fs.watch).toHaveBeenCalledWith(testConfigPath, expect.any(Function));
|
|
70
|
+
instance.stopWatching();
|
|
71
|
+
expect(instance['configWatcher']).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
it('should reload config on file change', () => {
|
|
74
|
+
const instance = ConfigManager.getInstance(testConfigPath);
|
|
75
|
+
const mockWatcher = { close: jest.fn() };
|
|
76
|
+
let watchCallback = () => { };
|
|
77
|
+
fs.watch.mockImplementation((path, callback) => {
|
|
78
|
+
watchCallback = callback;
|
|
79
|
+
return mockWatcher;
|
|
80
|
+
});
|
|
81
|
+
instance.startWatching();
|
|
82
|
+
// Setup spy for event emission
|
|
83
|
+
const emitSpy = jest.spyOn(instance, 'emit');
|
|
84
|
+
// Mock new config data
|
|
85
|
+
const newConfig = { mcpServers: { test: { url: 'test-url' } } };
|
|
86
|
+
fs.readFileSync.mockReturnValueOnce(JSON.stringify(newConfig));
|
|
87
|
+
// Simulate file change
|
|
88
|
+
watchCallback('change', path.basename(testConfigPath));
|
|
89
|
+
expect(emitSpy).toHaveBeenCalledWith(ConfigChangeEvent.TRANSPORT_CONFIG_CHANGED, newConfig.mcpServers);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
describe('getTransportConfig', () => {
|
|
93
|
+
it('should return copy of transport config', () => {
|
|
94
|
+
const testConfig = {
|
|
95
|
+
mcpServers: {
|
|
96
|
+
test: { url: 'test-url' },
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
fs.readFileSync.mockReturnValueOnce(JSON.stringify(testConfig));
|
|
100
|
+
const instance = ConfigManager.getInstance(testConfigPath);
|
|
101
|
+
const config = instance.getTransportConfig();
|
|
102
|
+
expect(config).toEqual(testConfig.mcpServers);
|
|
103
|
+
expect(config).not.toBe(instance['transportConfig']); // Should be a copy
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -1,38 +1,53 @@
|
|
|
1
1
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
* @returns Record of transport configurations
|
|
7
|
-
*/
|
|
8
|
-
export function loadTransportConfig() {
|
|
9
|
-
return configManager.getTransportConfig();
|
|
10
|
-
}
|
|
2
|
+
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
|
3
|
+
import { ZodError } from 'zod';
|
|
4
|
+
import logger from '../logger/logger.js';
|
|
5
|
+
import { transportConfigSchema } from '../types.js';
|
|
11
6
|
/**
|
|
12
7
|
* Creates transport instances from configuration
|
|
13
8
|
* @returns Record of transport instances
|
|
14
9
|
*/
|
|
15
|
-
export function createTransports() {
|
|
10
|
+
export function createTransports(config) {
|
|
16
11
|
const transports = {};
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
if (transport.disabled) {
|
|
12
|
+
for (const [name, params] of Object.entries(config)) {
|
|
13
|
+
if (params.disabled) {
|
|
20
14
|
logger.debug(`Skipping disabled transport: ${name}`);
|
|
21
15
|
continue;
|
|
22
16
|
}
|
|
23
17
|
try {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
18
|
+
const validatedTransport = transportConfigSchema.parse(params);
|
|
19
|
+
if (validatedTransport.type === 'sse' || validatedTransport.type === 'http') {
|
|
20
|
+
if (!validatedTransport.url) {
|
|
21
|
+
throw new Error(`URL is required for SSE transport: ${name}`);
|
|
22
|
+
}
|
|
23
|
+
const transport = new SSEClientTransport(new URL(validatedTransport.url), {
|
|
24
|
+
requestInit: {
|
|
25
|
+
headers: validatedTransport.headers,
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
transport.timeout = validatedTransport.timeout;
|
|
29
|
+
transport.tags = validatedTransport.tags;
|
|
30
|
+
transports[name] = transport;
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
if (!validatedTransport.command) {
|
|
34
|
+
throw new Error(`Command is required for stdio transport: ${name}`);
|
|
35
|
+
}
|
|
36
|
+
const transport = new StdioClientTransport(validatedTransport);
|
|
37
|
+
transport.timeout = validatedTransport.timeout;
|
|
38
|
+
transport.tags = validatedTransport.tags;
|
|
39
|
+
transports[name] = transport;
|
|
40
|
+
}
|
|
41
|
+
logger.debug(`Created transport: ${name}`);
|
|
33
42
|
}
|
|
34
43
|
catch (error) {
|
|
35
|
-
|
|
44
|
+
if (error instanceof ZodError) {
|
|
45
|
+
logger.error(`Invalid transport configuration for ${name}:`, error.errors);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
logger.error(`Error creating transport ${name}:`, error);
|
|
49
|
+
}
|
|
50
|
+
throw error;
|
|
36
51
|
}
|
|
37
52
|
}
|
|
38
53
|
return transports;
|