@1mcp/agent 0.2.0 → 0.7.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 +68 -11
- package/build/capabilities/capabilityManager.js +17 -9
- package/build/clients/clientManager.js +64 -10
- package/build/clients/clientManager.test.js +172 -0
- package/build/config/configManager.js +1 -1
- package/build/config/configManager.test.js +92 -0
- package/build/config/transportConfig.js +17 -38
- package/build/constants.js +2 -1
- package/build/handlers/notificationHandlers.js +10 -10
- package/build/handlers/requestHandlers.js +106 -63
- package/build/index.js +30 -2
- package/build/logger/logger.js +12 -25
- package/build/logger/mcpTransport.js +1 -1
- package/build/server/expressServer.js +21 -6
- package/build/serverManager.js +21 -14
- package/build/serverManager.test.js +158 -0
- package/build/services/configReloadService.js +10 -8
- package/build/test-utils/fixtures/commonFixtures.js +44 -0
- package/build/test-utils/helpers/testHelpers.js +23 -0
- package/build/test-utils/mocks/commonMocks.js +65 -0
- package/build/types.js +31 -0
- package/build/utils/clientFiltering.js +101 -0
- package/build/utils/clientFiltering.test.js +124 -0
- package/build/utils/errorHandling.js +66 -43
- package/build/utils/errorTypes.js +47 -0
- package/package.json +41 -4
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ npx -y @1mcp/agent --config ~/Library/Application\ Support/Claude/claude_desktop
|
|
|
28
28
|
"mcpServers": {
|
|
29
29
|
"1mcp": {
|
|
30
30
|
"type": "http",
|
|
31
|
-
"url": "http://localhost:3050"
|
|
31
|
+
"url": "http://localhost:3050/sse"
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
}
|
|
@@ -41,24 +41,81 @@ npx -y @1mcp/agent --config ~/Library/Application\ Support/Claude/claude_desktop
|
|
|
41
41
|
You can run the server directly using `npx`:
|
|
42
42
|
|
|
43
43
|
```bash
|
|
44
|
-
#
|
|
44
|
+
# Basic usage (starts server with SSE transport)
|
|
45
45
|
npx -y @1mcp/agent
|
|
46
46
|
|
|
47
|
-
#
|
|
48
|
-
npx -y @1mcp/agent --
|
|
47
|
+
# Use existing Claude Desktop config
|
|
48
|
+
npx -y @1mcp/agent --config ~/Library/Application\ Support/Claude/claude_desktop_config.json
|
|
49
49
|
|
|
50
|
-
#
|
|
51
|
-
npx -y @1mcp/agent --
|
|
50
|
+
# Use stdio transport instead of SSE
|
|
51
|
+
npx -y @1mcp/agent --transport stdio
|
|
52
52
|
|
|
53
|
-
# Show
|
|
53
|
+
# Show all available options
|
|
54
54
|
npx -y @1mcp/agent --help
|
|
55
55
|
```
|
|
56
56
|
|
|
57
57
|
Available options:
|
|
58
|
-
- `--transport, -t`:
|
|
59
|
-
- `--config, -c`:
|
|
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)
|
|
60
63
|
- `--help, -h`: Show help
|
|
61
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
|
+
|
|
62
119
|
## Configuration
|
|
63
120
|
|
|
64
121
|
### Global Configuration
|
|
@@ -172,10 +229,10 @@ pnpm dev
|
|
|
172
229
|
|
|
173
230
|
### Debugging
|
|
174
231
|
|
|
175
|
-
|
|
232
|
+
Using the [MCP Inspector](https://github.com/modelcontextprotocol/inspector), which is available as a package script:
|
|
176
233
|
|
|
177
234
|
```bash
|
|
178
|
-
pnpm
|
|
235
|
+
pnpm inspector
|
|
179
236
|
```
|
|
180
237
|
|
|
181
238
|
The Inspector will provide a URL to access debugging tools in your browser.
|
|
@@ -5,16 +5,17 @@ import { registerRequestHandlers } from '../handlers/requestHandlers.js';
|
|
|
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 setupCapabilities(clients,
|
|
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,
|
|
15
|
+
setupClientToServerNotifications(clients, serverInfo);
|
|
16
|
+
setupServerToClientNotifications(clients, serverInfo);
|
|
16
17
|
// Register request handlers based on capabilities
|
|
17
|
-
registerRequestHandlers(clients,
|
|
18
|
+
registerRequestHandlers(clients, serverInfo);
|
|
18
19
|
return capabilities;
|
|
19
20
|
}
|
|
20
21
|
/**
|
|
@@ -23,12 +24,19 @@ export async function setupCapabilities(clients, server) {
|
|
|
23
24
|
* @returns The combined server capabilities
|
|
24
25
|
*/
|
|
25
26
|
function collectCapabilities(clients) {
|
|
26
|
-
|
|
27
|
-
for (const [name,
|
|
27
|
+
const capabilities = {};
|
|
28
|
+
for (const [name, clientInfo] of Object.entries(clients)) {
|
|
28
29
|
try {
|
|
29
|
-
const
|
|
30
|
-
logger.debug(`Capabilities from ${name}: ${JSON.stringify(
|
|
31
|
-
|
|
30
|
+
const serverCapabilities = clientInfo.client.getServerCapabilities() || {};
|
|
31
|
+
logger.debug(`Capabilities from ${name}: ${JSON.stringify(serverCapabilities)}`);
|
|
32
|
+
// Store capabilities per client
|
|
33
|
+
clientInfo.capabilities = serverCapabilities;
|
|
34
|
+
// Aggregate capabilities
|
|
35
|
+
capabilities.resources = { ...capabilities.resources, ...serverCapabilities.resources };
|
|
36
|
+
capabilities.tools = { ...capabilities.tools, ...serverCapabilities.tools };
|
|
37
|
+
capabilities.prompts = { ...capabilities.prompts, ...serverCapabilities.prompts };
|
|
38
|
+
capabilities.experimental = { ...capabilities.experimental, ...serverCapabilities.experimental };
|
|
39
|
+
capabilities.logging = { ...capabilities.logging, ...serverCapabilities.logging };
|
|
32
40
|
}
|
|
33
41
|
catch (error) {
|
|
34
42
|
logger.error(`Failed to get capabilities from ${name}: ${error}`);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import createClient from '../client.js';
|
|
2
2
|
import logger from '../logger/logger.js';
|
|
3
|
-
import { CONNECTION_RETRY, MCP_SERVER_NAME } from '../constants.js';
|
|
4
|
-
import { ClientConnectionError, ClientNotFoundError,
|
|
3
|
+
import { CONNECTION_RETRY, MCP_SERVER_NAME, ERROR_CODES } from '../constants.js';
|
|
4
|
+
import { ClientConnectionError, ClientNotFoundError, MCPError } from '../utils/errorTypes.js';
|
|
5
|
+
import { ClientStatus } from '../types.js';
|
|
5
6
|
/**
|
|
6
7
|
* Creates client instances for all transports with retry logic
|
|
7
8
|
* @param transports Record of transport instances
|
|
@@ -15,15 +16,27 @@ export async function createClients(transports) {
|
|
|
15
16
|
const client = await createClient();
|
|
16
17
|
// Connect with retry logic
|
|
17
18
|
await connectWithRetry(client, transport, name);
|
|
18
|
-
clients[name] =
|
|
19
|
+
clients[name] = {
|
|
20
|
+
name,
|
|
21
|
+
transport,
|
|
22
|
+
client,
|
|
23
|
+
status: ClientStatus.Connected,
|
|
24
|
+
lastConnected: new Date(),
|
|
25
|
+
};
|
|
19
26
|
logger.info(`Client created for ${name}`);
|
|
20
27
|
}
|
|
21
28
|
catch (error) {
|
|
22
29
|
logger.error(`Failed to create client for ${name}: ${error}`);
|
|
23
|
-
|
|
30
|
+
clients[name] = {
|
|
31
|
+
name,
|
|
32
|
+
transport,
|
|
33
|
+
client: await createClient(),
|
|
34
|
+
status: ClientStatus.Error,
|
|
35
|
+
lastError: error instanceof Error ? error : new Error(String(error)),
|
|
36
|
+
};
|
|
24
37
|
}
|
|
25
38
|
}
|
|
26
|
-
return clients;
|
|
39
|
+
return Object.freeze(clients);
|
|
27
40
|
}
|
|
28
41
|
/**
|
|
29
42
|
* Connects a client to its transport with retry logic
|
|
@@ -71,13 +84,54 @@ export function getClient(clients, clientName) {
|
|
|
71
84
|
return client;
|
|
72
85
|
}
|
|
73
86
|
/**
|
|
74
|
-
* Executes
|
|
87
|
+
* Executes an operation with error handling and retry logic
|
|
88
|
+
* @param operation The operation to execute
|
|
89
|
+
* @param context The execution context (client info or server info)
|
|
90
|
+
* @param options Operation options including timeout and retry settings
|
|
91
|
+
* @returns The result of the operation
|
|
92
|
+
*/
|
|
93
|
+
export async function executeOperation(operation, contextName, options = {}) {
|
|
94
|
+
const { retryCount = 0, retryDelay = 1000 } = options;
|
|
95
|
+
let lastError;
|
|
96
|
+
for (let i = 0; i <= retryCount; i++) {
|
|
97
|
+
try {
|
|
98
|
+
return await operation();
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
102
|
+
if (i < retryCount) {
|
|
103
|
+
logger.info(`Retrying operation ${operation.name} on ${contextName} after ${retryDelay}ms`);
|
|
104
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// If we get here, we've exhausted all retries
|
|
109
|
+
logger.error(`Operation failed on ${contextName} after ${retryCount + 1} attempts: ${lastError}`);
|
|
110
|
+
if (lastError instanceof MCPError) {
|
|
111
|
+
throw lastError;
|
|
112
|
+
}
|
|
113
|
+
const mcpError = new MCPError(`Error executing operation on ${contextName}`, ERROR_CODES.INTERNAL_SERVER_ERROR, {
|
|
114
|
+
originalError: lastError,
|
|
115
|
+
});
|
|
116
|
+
throw mcpError;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Executes a client operation with error handling and retry logic
|
|
75
120
|
* @param clients Record of client instances
|
|
76
121
|
* @param clientName The name of the client to use
|
|
77
122
|
* @param operation The operation to execute
|
|
78
|
-
* @
|
|
123
|
+
* @param options Operation options including timeout and retry settings
|
|
124
|
+
*/
|
|
125
|
+
export async function executeClientOperation(clients, clientName, operation, options = {}) {
|
|
126
|
+
const clientInfo = getClient(clients, clientName);
|
|
127
|
+
return executeOperation(() => operation(clientInfo), `client ${clientName}`, options);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Executes a server operation with error handling and retry logic
|
|
131
|
+
* @param server The server to execute the operation on
|
|
132
|
+
* @param operation The operation to execute
|
|
133
|
+
* @param options Operation options including timeout and retry settings
|
|
79
134
|
*/
|
|
80
|
-
export async function
|
|
81
|
-
|
|
82
|
-
return withErrorHandling(async () => operation(client), `Error executing operation on client ${clientName}`)();
|
|
135
|
+
export async function executeServerOperation(server, operation, options = {}) {
|
|
136
|
+
return executeOperation(() => operation(server), 'server', options);
|
|
83
137
|
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { createClients, getClient, executeOperation, executeClientOperation, executeServerOperation, } from './clientManager.js';
|
|
2
|
+
import createClientFn from '../client.js';
|
|
3
|
+
import logger from '../logger/logger.js';
|
|
4
|
+
import { ClientStatus } from '../types.js';
|
|
5
|
+
import { ClientConnectionError, ClientNotFoundError, MCPError } from '../utils/errorTypes.js';
|
|
6
|
+
import { MCP_SERVER_NAME, CONNECTION_RETRY } from '../constants.js';
|
|
7
|
+
// Mock dependencies
|
|
8
|
+
jest.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
|
|
9
|
+
Client: jest.fn(),
|
|
10
|
+
}));
|
|
11
|
+
jest.mock('../client.js', () => ({
|
|
12
|
+
__esModule: true,
|
|
13
|
+
default: jest.fn(),
|
|
14
|
+
}));
|
|
15
|
+
jest.mock('../logger/logger.js', () => ({
|
|
16
|
+
__esModule: true,
|
|
17
|
+
default: {
|
|
18
|
+
info: jest.fn(),
|
|
19
|
+
error: jest.fn(),
|
|
20
|
+
},
|
|
21
|
+
}));
|
|
22
|
+
describe('clientManager', () => {
|
|
23
|
+
let mockTransport;
|
|
24
|
+
let mockClient;
|
|
25
|
+
let mockTransports;
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
jest.clearAllMocks();
|
|
28
|
+
jest.useFakeTimers();
|
|
29
|
+
mockTransport = {
|
|
30
|
+
name: 'test-transport',
|
|
31
|
+
start: jest.fn(),
|
|
32
|
+
send: jest.fn(),
|
|
33
|
+
close: jest.fn(),
|
|
34
|
+
};
|
|
35
|
+
mockClient = {
|
|
36
|
+
connect: jest.fn(),
|
|
37
|
+
getServerVersion: jest.fn(),
|
|
38
|
+
};
|
|
39
|
+
mockTransports = {
|
|
40
|
+
'test-client': mockTransport,
|
|
41
|
+
};
|
|
42
|
+
createClientFn.mockResolvedValue(mockClient);
|
|
43
|
+
});
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
jest.useRealTimers();
|
|
46
|
+
});
|
|
47
|
+
describe('createClients', () => {
|
|
48
|
+
it('should create clients successfully', async () => {
|
|
49
|
+
mockClient.connect.mockResolvedValue(undefined);
|
|
50
|
+
mockClient.getServerVersion.mockResolvedValue({ name: 'test-server', version: '1.0.0' });
|
|
51
|
+
const clientsPromise = createClients(mockTransports);
|
|
52
|
+
await jest.runAllTimersAsync();
|
|
53
|
+
const clients = await clientsPromise;
|
|
54
|
+
expect(clients['test-client']).toBeDefined();
|
|
55
|
+
expect(clients['test-client'].status).toBe(ClientStatus.Connected);
|
|
56
|
+
expect(clients['test-client'].transport).toBe(mockTransport);
|
|
57
|
+
expect(logger.info).toHaveBeenCalledWith('Client created for test-client');
|
|
58
|
+
});
|
|
59
|
+
it('should handle client connection failure after retries', async () => {
|
|
60
|
+
const error = new Error('Connection failed');
|
|
61
|
+
mockClient.connect.mockRejectedValue(error);
|
|
62
|
+
const clientsPromise = createClients(mockTransports);
|
|
63
|
+
// Run through all retry attempts
|
|
64
|
+
for (let i = 0; i < CONNECTION_RETRY.MAX_ATTEMPTS; i++) {
|
|
65
|
+
await jest.advanceTimersByTimeAsync(CONNECTION_RETRY.INITIAL_DELAY_MS * Math.pow(2, i));
|
|
66
|
+
}
|
|
67
|
+
const clients = await clientsPromise;
|
|
68
|
+
expect(clients['test-client'].status).toBe(ClientStatus.Error);
|
|
69
|
+
expect(clients['test-client'].lastError).toBeInstanceOf(ClientConnectionError);
|
|
70
|
+
expect(clients['test-client'].lastError?.message).toContain('Connection failed');
|
|
71
|
+
expect(mockClient.connect).toHaveBeenCalledTimes(CONNECTION_RETRY.MAX_ATTEMPTS);
|
|
72
|
+
});
|
|
73
|
+
it('should prevent circular dependency with MCP server', async () => {
|
|
74
|
+
mockClient.connect.mockResolvedValue(undefined);
|
|
75
|
+
mockClient.getServerVersion.mockResolvedValue({ name: MCP_SERVER_NAME, version: '1.0.0' });
|
|
76
|
+
const clientsPromise = createClients(mockTransports);
|
|
77
|
+
await jest.runAllTimersAsync();
|
|
78
|
+
const clients = await clientsPromise;
|
|
79
|
+
expect(clients['test-client'].status).toBe(ClientStatus.Error);
|
|
80
|
+
expect(clients['test-client'].lastError).toBeInstanceOf(ClientConnectionError);
|
|
81
|
+
expect(clients['test-client'].lastError?.message).toContain('circular dependency');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
describe('getClient', () => {
|
|
85
|
+
let clients;
|
|
86
|
+
beforeEach(async () => {
|
|
87
|
+
clients = await createClients(mockTransports);
|
|
88
|
+
});
|
|
89
|
+
it('should return client info for existing client', () => {
|
|
90
|
+
const clientInfo = getClient(clients, 'test-client');
|
|
91
|
+
expect(clientInfo).toBeDefined();
|
|
92
|
+
expect(clientInfo.name).toBe('test-client');
|
|
93
|
+
});
|
|
94
|
+
it('should throw ClientNotFoundError for non-existent client', () => {
|
|
95
|
+
expect(() => getClient(clients, 'non-existent')).toThrow(ClientNotFoundError);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
describe('executeOperation', () => {
|
|
99
|
+
it('should execute operation successfully', async () => {
|
|
100
|
+
const operation = jest.fn().mockResolvedValue('result');
|
|
101
|
+
const result = await executeOperation(operation, 'test-context');
|
|
102
|
+
expect(result).toBe('result');
|
|
103
|
+
expect(operation).toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
it('should retry failed operations', async () => {
|
|
106
|
+
const error = new Error('Operation failed');
|
|
107
|
+
const operation = jest.fn().mockRejectedValueOnce(error).mockResolvedValueOnce('result');
|
|
108
|
+
const operationPromise = executeOperation(operation, 'test-context', { retryCount: 1, retryDelay: 1000 });
|
|
109
|
+
// Advance timer by retry delay
|
|
110
|
+
await jest.advanceTimersByTimeAsync(1000);
|
|
111
|
+
const result = await operationPromise;
|
|
112
|
+
expect(result).toBe('result');
|
|
113
|
+
expect(operation).toHaveBeenCalledTimes(2);
|
|
114
|
+
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Retrying operation'));
|
|
115
|
+
});
|
|
116
|
+
it('should throw error after max retries', async () => {
|
|
117
|
+
const error = new Error('Operation failed');
|
|
118
|
+
const operation = jest.fn().mockRejectedValue(error);
|
|
119
|
+
const operationPromise = executeOperation(operation, 'test-context', { retryCount: 2, retryDelay: 1000 });
|
|
120
|
+
// Create rejection assertion before advancing timers
|
|
121
|
+
const rejection = expect(operationPromise).rejects.toMatchObject({
|
|
122
|
+
message: 'Error executing operation on test-context',
|
|
123
|
+
data: { originalError: error },
|
|
124
|
+
});
|
|
125
|
+
// Advance timer for each retry
|
|
126
|
+
for (let i = 0; i < 2; i++) {
|
|
127
|
+
await jest.advanceTimersByTimeAsync(1000);
|
|
128
|
+
}
|
|
129
|
+
// Wait for the rejection assertion
|
|
130
|
+
await rejection;
|
|
131
|
+
expect(operation).toHaveBeenCalledTimes(3); // Initial try + 2 retries
|
|
132
|
+
expect(logger.error).toHaveBeenCalled();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
describe('executeClientOperation', () => {
|
|
136
|
+
let clients;
|
|
137
|
+
beforeEach(async () => {
|
|
138
|
+
clients = await createClients(mockTransports);
|
|
139
|
+
});
|
|
140
|
+
it('should execute client operation successfully', async () => {
|
|
141
|
+
const operation = jest.fn().mockResolvedValue('result');
|
|
142
|
+
const result = await executeClientOperation(clients, 'test-client', operation);
|
|
143
|
+
expect(result).toBe('result');
|
|
144
|
+
expect(operation).toHaveBeenCalledWith(clients['test-client']);
|
|
145
|
+
});
|
|
146
|
+
it('should throw error for non-existent client', async () => {
|
|
147
|
+
const operation = jest.fn();
|
|
148
|
+
await expect(executeClientOperation(clients, 'non-existent', operation)).rejects.toThrow(ClientNotFoundError);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
describe('executeServerOperation', () => {
|
|
152
|
+
let mockServer;
|
|
153
|
+
beforeEach(() => {
|
|
154
|
+
mockServer = {
|
|
155
|
+
server: {
|
|
156
|
+
request: jest.fn().mockResolvedValue('result'),
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
});
|
|
160
|
+
it('should execute server operation successfully', async () => {
|
|
161
|
+
const operation = jest.fn().mockResolvedValue('result');
|
|
162
|
+
const result = await executeServerOperation(mockServer, operation);
|
|
163
|
+
expect(result).toBe('result');
|
|
164
|
+
expect(operation).toHaveBeenCalledWith(mockServer);
|
|
165
|
+
});
|
|
166
|
+
it('should handle server operation failure', async () => {
|
|
167
|
+
const error = new Error('Server operation failed');
|
|
168
|
+
const operation = jest.fn().mockRejectedValue(error);
|
|
169
|
+
await expect(executeServerOperation(mockServer, operation)).rejects.toThrow(MCPError);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { ConfigManager, ConfigChangeEvent } from './configManager.js';
|
|
4
|
+
import { DEFAULT_CONFIG } from '../constants.js';
|
|
5
|
+
import { setupCommonJestMocks } from '../test-utils/mocks/commonMocks.js';
|
|
6
|
+
import { testConfig } from '../test-utils/fixtures/commonFixtures.js';
|
|
7
|
+
import { clearAllMocks, createEventEmitterSpy } from '../test-utils/helpers/testHelpers.js';
|
|
8
|
+
// Mock modules
|
|
9
|
+
jest.mock('fs', () => ({
|
|
10
|
+
existsSync: jest.fn(),
|
|
11
|
+
mkdirSync: jest.fn(),
|
|
12
|
+
writeFileSync: jest.fn(),
|
|
13
|
+
readFileSync: jest.fn(),
|
|
14
|
+
watch: jest.fn(() => ({ close: jest.fn() })),
|
|
15
|
+
}));
|
|
16
|
+
// Setup common mocks
|
|
17
|
+
setupCommonJestMocks();
|
|
18
|
+
// Mock constants
|
|
19
|
+
jest.mock('../constants.js', () => ({
|
|
20
|
+
__esModule: true,
|
|
21
|
+
DEFAULT_CONFIG: { mcpServers: {} },
|
|
22
|
+
getGlobalConfigPath: jest.fn(),
|
|
23
|
+
getGlobalConfigDir: jest.fn().mockReturnValue('/test'),
|
|
24
|
+
}));
|
|
25
|
+
describe('ConfigManager', () => {
|
|
26
|
+
const testConfigPath = '/test/config.json';
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
// Reset all mocks and timers
|
|
29
|
+
clearAllMocks();
|
|
30
|
+
// Reset singleton instance
|
|
31
|
+
ConfigManager.instance = undefined;
|
|
32
|
+
// Default mock implementations
|
|
33
|
+
fs.existsSync.mockReturnValue(true);
|
|
34
|
+
fs.readFileSync.mockReturnValue(JSON.stringify({ mcpServers: {} }));
|
|
35
|
+
});
|
|
36
|
+
describe('getInstance', () => {
|
|
37
|
+
it('should create singleton instance', () => {
|
|
38
|
+
const instance1 = ConfigManager.getInstance(testConfigPath);
|
|
39
|
+
const instance2 = ConfigManager.getInstance(testConfigPath);
|
|
40
|
+
expect(instance1).toBe(instance2);
|
|
41
|
+
});
|
|
42
|
+
it('should use provided config path', () => {
|
|
43
|
+
const instance = ConfigManager.getInstance(testConfigPath);
|
|
44
|
+
expect(instance.configFilePath).toBe(testConfigPath);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
describe('ensureConfigExists', () => {
|
|
48
|
+
it('should create config directory and file if they do not exist', () => {
|
|
49
|
+
fs.existsSync
|
|
50
|
+
.mockReturnValueOnce(false) // Directory doesn't exist
|
|
51
|
+
.mockReturnValueOnce(false); // File doesn't exist
|
|
52
|
+
ConfigManager.getInstance(testConfigPath);
|
|
53
|
+
expect(fs.mkdirSync).toHaveBeenCalledWith(path.dirname(testConfigPath), { recursive: true });
|
|
54
|
+
expect(fs.writeFileSync).toHaveBeenCalledWith(testConfigPath, JSON.stringify(DEFAULT_CONFIG, null, 2));
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
describe('config watching', () => {
|
|
58
|
+
it('should start and stop watching config file', () => {
|
|
59
|
+
const instance = ConfigManager.getInstance(testConfigPath);
|
|
60
|
+
instance.startWatching();
|
|
61
|
+
expect(fs.watch).toHaveBeenCalledWith(testConfigPath, expect.any(Function));
|
|
62
|
+
instance.stopWatching();
|
|
63
|
+
expect(instance['configWatcher']).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
it('should reload config on file change', () => {
|
|
66
|
+
const instance = ConfigManager.getInstance(testConfigPath);
|
|
67
|
+
const mockWatcher = { close: jest.fn() };
|
|
68
|
+
let watchCallback = () => { };
|
|
69
|
+
fs.watch.mockImplementation((path, callback) => {
|
|
70
|
+
watchCallback = callback;
|
|
71
|
+
return mockWatcher;
|
|
72
|
+
});
|
|
73
|
+
instance.startWatching();
|
|
74
|
+
// Setup spy for event emission
|
|
75
|
+
const emitSpy = createEventEmitterSpy(instance);
|
|
76
|
+
// Mock new config data
|
|
77
|
+
fs.readFileSync.mockReturnValueOnce(JSON.stringify(testConfig));
|
|
78
|
+
// Simulate file change
|
|
79
|
+
watchCallback('change', path.basename(testConfigPath));
|
|
80
|
+
expect(emitSpy).toHaveBeenCalledWith(ConfigChangeEvent.TRANSPORT_CONFIG_CHANGED, testConfig.mcpServers);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
describe('getTransportConfig', () => {
|
|
84
|
+
it('should return copy of transport config', () => {
|
|
85
|
+
fs.readFileSync.mockReturnValueOnce(JSON.stringify(testConfig));
|
|
86
|
+
const instance = ConfigManager.getInstance(testConfigPath);
|
|
87
|
+
const config = instance.getTransportConfig();
|
|
88
|
+
expect(config).toEqual(testConfig.mcpServers);
|
|
89
|
+
expect(config).not.toBe(instance['transportConfig']); // Should be a copy
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -1,74 +1,53 @@
|
|
|
1
1
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
2
2
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
|
3
|
-
import {
|
|
3
|
+
import { ZodError } from 'zod';
|
|
4
4
|
import logger from '../logger/logger.js';
|
|
5
|
-
|
|
6
|
-
* Zod schema for transport configuration
|
|
7
|
-
*/
|
|
8
|
-
export const transportConfigSchema = z.object({
|
|
9
|
-
type: z.enum(['stdio', 'sse', 'http']).optional(),
|
|
10
|
-
disabled: z.boolean().optional(),
|
|
11
|
-
// SSEServerParameters fields
|
|
12
|
-
url: z.string().url().optional(),
|
|
13
|
-
headers: z.record(z.string()).optional(),
|
|
14
|
-
// StdioServerParameters fields
|
|
15
|
-
command: z.string().optional(),
|
|
16
|
-
args: z.array(z.string()).optional(),
|
|
17
|
-
stderr: z.union([z.string(), z.number()]).optional(),
|
|
18
|
-
cwd: z.string().optional(),
|
|
19
|
-
env: z.record(z.string()).optional(),
|
|
20
|
-
});
|
|
5
|
+
import { transportConfigSchema } from '../types.js';
|
|
21
6
|
/**
|
|
22
7
|
* Creates transport instances from configuration
|
|
23
8
|
* @returns Record of transport instances
|
|
24
9
|
*/
|
|
25
|
-
export function createTransports(
|
|
10
|
+
export function createTransports(config) {
|
|
26
11
|
const transports = {};
|
|
27
|
-
for (const [name,
|
|
28
|
-
if (
|
|
12
|
+
for (const [name, params] of Object.entries(config)) {
|
|
13
|
+
if (params.disabled) {
|
|
29
14
|
logger.debug(`Skipping disabled transport: ${name}`);
|
|
30
15
|
continue;
|
|
31
16
|
}
|
|
32
17
|
try {
|
|
33
|
-
|
|
34
|
-
const validatedTransport = transportConfigSchema.parse(transport);
|
|
35
|
-
// Merge environment variables
|
|
36
|
-
validatedTransport.env = {
|
|
37
|
-
...Object.fromEntries(Object.entries(process.env)
|
|
38
|
-
.filter(([_, v]) => v !== undefined)
|
|
39
|
-
.map(([k, v]) => [k, String(v)])),
|
|
40
|
-
...validatedTransport.env,
|
|
41
|
-
};
|
|
42
|
-
// Create transport based on type
|
|
18
|
+
const validatedTransport = transportConfigSchema.parse(params);
|
|
43
19
|
if (validatedTransport.type === 'sse' || validatedTransport.type === 'http') {
|
|
44
20
|
if (!validatedTransport.url) {
|
|
45
21
|
throw new Error(`URL is required for SSE transport: ${name}`);
|
|
46
22
|
}
|
|
47
|
-
|
|
23
|
+
const transport = new SSEClientTransport(new URL(validatedTransport.url), {
|
|
48
24
|
requestInit: {
|
|
49
25
|
headers: validatedTransport.headers,
|
|
50
26
|
},
|
|
51
27
|
});
|
|
28
|
+
transport.timeout = validatedTransport.timeout;
|
|
29
|
+
transport.tags = validatedTransport.tags;
|
|
30
|
+
transports[name] = transport;
|
|
52
31
|
}
|
|
53
32
|
else {
|
|
54
|
-
// For stdio transport, ensure required fields are present
|
|
55
33
|
if (!validatedTransport.command) {
|
|
56
34
|
throw new Error(`Command is required for stdio transport: ${name}`);
|
|
57
35
|
}
|
|
58
|
-
|
|
36
|
+
const transport = new StdioClientTransport(validatedTransport);
|
|
37
|
+
transport.timeout = validatedTransport.timeout;
|
|
38
|
+
transport.tags = validatedTransport.tags;
|
|
39
|
+
transports[name] = transport;
|
|
59
40
|
}
|
|
60
|
-
logger.debug(`Created
|
|
41
|
+
logger.debug(`Created transport: ${name}`);
|
|
61
42
|
}
|
|
62
43
|
catch (error) {
|
|
63
44
|
if (error instanceof ZodError) {
|
|
64
45
|
logger.error(`Invalid transport configuration for ${name}:`, error.errors);
|
|
65
46
|
}
|
|
66
|
-
else if (error instanceof Error) {
|
|
67
|
-
logger.error(`Failed to create transport for ${name}: ${error.message}`);
|
|
68
|
-
}
|
|
69
47
|
else {
|
|
70
|
-
logger.error(`
|
|
48
|
+
logger.error(`Error creating transport ${name}:`, error);
|
|
71
49
|
}
|
|
50
|
+
throw error;
|
|
72
51
|
}
|
|
73
52
|
}
|
|
74
53
|
return transports;
|
package/build/constants.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
// Server configuration
|
|
5
5
|
export const PORT = 3050;
|
|
6
|
+
export const HOST = '127.0.0.1';
|
|
6
7
|
// API endpoints
|
|
7
8
|
export const SSE_ENDPOINT = '/sse';
|
|
8
9
|
export const MESSAGES_ENDPOINT = '/messages';
|
|
@@ -22,7 +23,7 @@ export const ERROR_CODES = {
|
|
|
22
23
|
// MCP constants
|
|
23
24
|
export const MCP_CONFIG_FILE = 'mcp.json';
|
|
24
25
|
export const MCP_SERVER_NAME = '1mcp';
|
|
25
|
-
export const MCP_SERVER_VERSION = '0.
|
|
26
|
+
export const MCP_SERVER_VERSION = '0.7.0';
|
|
26
27
|
export const MCP_URI_SEPARATOR = '_1mcp_';
|
|
27
28
|
// Global config paths
|
|
28
29
|
export const CONFIG_DIR_NAME = '1mcp';
|