@natomalabs/natoma-mcp-gateway 1.0.1 → 1.0.2
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 +133 -16
- package/build/base.js +54 -0
- package/build/cli.js +64 -0
- package/build/ent-gateway.js +407 -0
- package/build/gateway.js +52 -156
- package/build/nms-gateway.js +131 -0
- package/build/setup.js +186 -0
- package/package.json +13 -10
package/README.md
CHANGED
|
@@ -1,28 +1,145 @@
|
|
|
1
|
-
# Natoma
|
|
1
|
+
# Natoma MCP Gateway
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A robust, production-ready gateway that bridges stdio-based MCP clients (like Claude Desktop) with HTTP/SSE-based MCP servers on the Natoma platform.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Overview
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
The Natoma MCP Gateway acts as a translation layer that enables seamless communication between different MCP (Model Context Protocol) implementations. It supports both standard and enterprise deployment modes, with automatic reconnection, health monitoring, and robust error handling.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
-
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **Dual Gateway Modes**: NMS (standard) and Enterprise gateways for different deployment scenarios
|
|
12
|
+
- **Protocol Translation**: Converts stdio JSON-RPC messages to HTTP/SSE format and vice versa
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install @natomalabs/natoma-mcp-gateway
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
### Environment Variables
|
|
23
|
+
|
|
24
|
+
Required environment variables:
|
|
25
|
+
|
|
26
|
+
- `NATOMA_MCP_SERVER_INSTALLATION_ID`: Your MCP server installation ID/slug
|
|
27
|
+
- `NATOMA_MCP_API_KEY`: Your Natoma API key for authentication
|
|
28
|
+
|
|
29
|
+
### Command Line Usage
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# Standard NMS Gateway mode
|
|
33
|
+
npx @natomalabs/natoma-mcp-gateway
|
|
34
|
+
|
|
35
|
+
# Enterprise Gateway mode
|
|
36
|
+
npx @natomalabs/natoma-mcp-gateway --enterprise
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Integration with Claude Desktop
|
|
40
|
+
|
|
41
|
+
Add to your Claude Desktop MCP configuration:
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"mcpServers": {
|
|
46
|
+
"your-server-name": {
|
|
47
|
+
"command": "npx",
|
|
48
|
+
"args": [
|
|
49
|
+
"@natomalabs/natoma-mcp-gateway"
|
|
50
|
+
],
|
|
51
|
+
"env": {
|
|
52
|
+
"NATOMA_MCP_SERVER_INSTALLATION_ID": "your-installation-id",
|
|
53
|
+
"NATOMA_MCP_API_KEY": "your-api-key"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
For enterprise deployments, add `--enterprise` to the args array.
|
|
61
|
+
|
|
62
|
+
## Architecture
|
|
63
|
+
|
|
64
|
+
### Gateway Modes
|
|
65
|
+
|
|
66
|
+
#### NMS Gateway (Standard)
|
|
67
|
+
|
|
68
|
+
- Uses EventSource for Server-Sent Events
|
|
69
|
+
- Optimized for standard MCP deployments
|
|
70
|
+
- Faster reconnection times (1 second)
|
|
71
|
+
- HTTP-based message delivery
|
|
72
|
+
|
|
73
|
+
#### Enterprise Gateway
|
|
74
|
+
|
|
75
|
+
- Uses fetch API with enhanced features
|
|
76
|
+
- Support for both JSON and SSE responses
|
|
77
|
+
- Health check monitoring (every 5 minutes)
|
|
78
|
+
- Extended timeouts (60 seconds)
|
|
79
|
+
- Enhanced error recovery
|
|
80
|
+
|
|
81
|
+
### Protocol Flow
|
|
82
|
+
|
|
83
|
+
1. **Initialization**: Gateway connects to Natoma MCP Platform
|
|
84
|
+
2. **Session Establishment**: Secure session created with session ID tracking
|
|
85
|
+
3. **Message Processing**: stdio JSON-RPC messages translated to HTTP/SSE
|
|
86
|
+
4. **Response Handling**: Server responses converted back to stdio format
|
|
87
|
+
5. **Error Recovery**: Automatic reconnection on connection failures
|
|
88
|
+
|
|
89
|
+
## Configuration
|
|
90
|
+
|
|
91
|
+
The gateway accepts several configuration options through the `MCPConfig` interface:
|
|
92
|
+
|
|
93
|
+
- `slug`: Server installation ID (required)
|
|
94
|
+
- `apiKey`: Authentication API key (required)
|
|
95
|
+
- `maxReconnectAttempts`: Maximum reconnection attempts (default: 5)
|
|
96
|
+
- `reconnectDelay`: Delay between reconnection attempts (default: 1000ms for NMS, 2000ms for Enterprise)
|
|
97
|
+
- `timeout`: Request timeout (default: 30000ms for NMS, 60000ms for Enterprise)
|
|
12
98
|
|
|
13
99
|
## What is MCP?
|
|
14
100
|
|
|
15
|
-
|
|
101
|
+
The Model Context Protocol (MCP) is an open standard for connecting AI assistants to external systems and data sources. Learn more at [modelcontextprotocol.io](https://modelcontextprotocol.io/introduction).
|
|
102
|
+
|
|
103
|
+
## Requirements
|
|
104
|
+
|
|
105
|
+
- Node.js 18 or higher
|
|
106
|
+
- Valid Natoma API credentials
|
|
107
|
+
- Network access to api.natoma.app
|
|
108
|
+
|
|
109
|
+
## Development
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
# Clone the repository
|
|
113
|
+
git clone <repository-url>
|
|
114
|
+
cd mcp-gateway
|
|
115
|
+
|
|
116
|
+
# Install dependencies
|
|
117
|
+
npm install
|
|
118
|
+
|
|
119
|
+
# Build the project
|
|
120
|
+
npm run build
|
|
121
|
+
|
|
122
|
+
# Run in development mode
|
|
123
|
+
npm run dev
|
|
124
|
+
|
|
125
|
+
# Run enterprise mode in development
|
|
126
|
+
npm run dev:enterprise
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Error Handling
|
|
130
|
+
|
|
131
|
+
The gateway includes comprehensive error handling:
|
|
16
132
|
|
|
17
|
-
|
|
133
|
+
- **Connection Errors**: Automatic reconnection with exponential backoff
|
|
134
|
+
- **Authentication Errors**: Clear error messages for invalid API keys
|
|
135
|
+
- **Timeout Handling**: Configurable timeouts with proper cleanup
|
|
136
|
+
- **Protocol Errors**: Validation and error reporting for malformed messages
|
|
137
|
+
- **Resource Management**: Proper cleanup of connections and timeouts
|
|
18
138
|
|
|
19
|
-
|
|
139
|
+
## License
|
|
20
140
|
|
|
21
|
-
|
|
22
|
-
- Maintains persistent SSE connections with Natoma MCP Platform
|
|
23
|
-
- Handles bi-directional protocol conversion
|
|
24
|
-
- Ensures reliable message delivery and error recovery
|
|
141
|
+
MIT
|
|
25
142
|
|
|
26
|
-
|
|
143
|
+
## Support
|
|
27
144
|
|
|
28
|
-
|
|
145
|
+
For issues and support, please contact the Natoma team or file an issue in the repository.
|
package/build/base.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// base.ts
|
|
2
|
+
export const NATOMA_NMS_SERVER_URL = "https://api.natoma.app/api/mcp";
|
|
3
|
+
export const NATOMA_ENTERPRISE_SERVER_URL = "https://api.natoma.app/api/mcp";
|
|
4
|
+
export const MCP_SESSION_ID_HEADER = "Mcp-Session-Id";
|
|
5
|
+
// Handle EPIPE errors gracefully
|
|
6
|
+
process.stdout.on("error", (err) => {
|
|
7
|
+
if (err.code === "EPIPE") {
|
|
8
|
+
process.exit(0);
|
|
9
|
+
}
|
|
10
|
+
});
|
|
11
|
+
process.stderr.on("error", (err) => {
|
|
12
|
+
if (err.code === "EPIPE") {
|
|
13
|
+
process.exit(0);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
// Base abstract gateway class
|
|
17
|
+
export class BaseMCPGateway {
|
|
18
|
+
isReady = false;
|
|
19
|
+
messageQueue = [];
|
|
20
|
+
reconnectAttempts = 0;
|
|
21
|
+
baseUrl;
|
|
22
|
+
maxReconnectAttempts;
|
|
23
|
+
reconnectDelay;
|
|
24
|
+
apiKey;
|
|
25
|
+
constructor(config) {
|
|
26
|
+
// Set base URL based on gateway type
|
|
27
|
+
this.baseUrl = config?.isEnterprise
|
|
28
|
+
? NATOMA_ENTERPRISE_SERVER_URL
|
|
29
|
+
: NATOMA_NMS_SERVER_URL;
|
|
30
|
+
this.maxReconnectAttempts = 3;
|
|
31
|
+
this.reconnectDelay = 1000;
|
|
32
|
+
this.apiKey = config?.apiKey;
|
|
33
|
+
// Validate that API key is provided
|
|
34
|
+
if (!this.apiKey) {
|
|
35
|
+
throw new Error("API key is required for MCP Gateway");
|
|
36
|
+
}
|
|
37
|
+
// Debug logging
|
|
38
|
+
console.error(`[BaseMCPGateway] Gateway Type: ${config?.isEnterprise ? "Enterprise" : "NMS"}`);
|
|
39
|
+
console.error(`[BaseMCPGateway] Base URL set to: ${this.baseUrl}`);
|
|
40
|
+
console.error(`[BaseMCPGateway] API Key: ${this.apiKey ? "PROVIDED" : "NOT PROVIDED"}`);
|
|
41
|
+
console.error(`[BaseMCPGateway] Max reconnect attempts: ${this.maxReconnectAttempts}`);
|
|
42
|
+
}
|
|
43
|
+
get ready() {
|
|
44
|
+
return this.isReady;
|
|
45
|
+
}
|
|
46
|
+
async processQueuedMessages() {
|
|
47
|
+
while (this.messageQueue.length > 0) {
|
|
48
|
+
const message = this.messageQueue.shift();
|
|
49
|
+
if (message) {
|
|
50
|
+
await this.processMessage(message);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
package/build/cli.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import yargs from 'yargs';
|
|
2
|
+
import { hideBin } from 'yargs/helpers';
|
|
3
|
+
import { setupCommand } from './setup.js';
|
|
4
|
+
import { MCPGateway } from './gateway.js';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
const startCommand = {
|
|
7
|
+
command: 'start',
|
|
8
|
+
describe: 'Start the Natoma MCP Gateway',
|
|
9
|
+
builder: (yargs) => {
|
|
10
|
+
return yargs
|
|
11
|
+
.option('url', {
|
|
12
|
+
type: 'string',
|
|
13
|
+
describe: 'The Natoma MCP URL to connect to',
|
|
14
|
+
})
|
|
15
|
+
.option('apiKey', {
|
|
16
|
+
type: 'string',
|
|
17
|
+
describe: 'API key for authentication',
|
|
18
|
+
demandOption: true,
|
|
19
|
+
})
|
|
20
|
+
.option('slug', {
|
|
21
|
+
type: 'string',
|
|
22
|
+
describe: 'Installation ID/slug for the connection',
|
|
23
|
+
});
|
|
24
|
+
},
|
|
25
|
+
handler: async (argv) => {
|
|
26
|
+
try {
|
|
27
|
+
const { url, apiKey, slug } = argv;
|
|
28
|
+
console.log(chalk.cyan('🚀 Starting Natoma MCP Gateway...'));
|
|
29
|
+
if (url) {
|
|
30
|
+
console.log(chalk.cyan(` URL: ${url}`));
|
|
31
|
+
}
|
|
32
|
+
if (slug) {
|
|
33
|
+
console.log(chalk.cyan(` Installation ID: ${slug}`));
|
|
34
|
+
}
|
|
35
|
+
const gateway = new MCPGateway({
|
|
36
|
+
apiKey: apiKey || process.env.NATOMA_MCP_API_KEY,
|
|
37
|
+
slug: slug || process.env.NATOMA_MCP_SERVER_INSTALLATION_ID,
|
|
38
|
+
});
|
|
39
|
+
await gateway.connect();
|
|
40
|
+
console.log(chalk.green('✅ Connected to Natoma MCP Server'));
|
|
41
|
+
process.stdin.on("data", (data) => gateway.processMessage(data));
|
|
42
|
+
// Handle cleanup
|
|
43
|
+
const cleanup = () => {
|
|
44
|
+
console.log(chalk.yellow("Shutting down..."));
|
|
45
|
+
gateway.cleanup();
|
|
46
|
+
process.exit(0);
|
|
47
|
+
};
|
|
48
|
+
process.on("SIGINT", cleanup);
|
|
49
|
+
process.on("SIGTERM", cleanup);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
console.log(chalk.red('\n❌ Error occurred while starting Natoma MCP Gateway:'));
|
|
53
|
+
console.log(chalk.red(` ${error.message}`));
|
|
54
|
+
console.log(chalk.yellow('\nPlease try again or contact support if the issue persists.\n'));
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
yargs(hideBin(process.argv))
|
|
60
|
+
.command(setupCommand)
|
|
61
|
+
.command(startCommand)
|
|
62
|
+
.demandCommand(1, 'You need to specify a command.')
|
|
63
|
+
.help()
|
|
64
|
+
.argv;
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
// ent-gateway.ts
|
|
2
|
+
import { BaseMCPGateway, MCP_SESSION_ID_HEADER, } from "./base.js";
|
|
3
|
+
const JSON_RPC_VERSION = "2.0";
|
|
4
|
+
// Enterprise Gateway - Fetch based with enhanced features
|
|
5
|
+
export class EnterpriseGateway extends BaseMCPGateway {
|
|
6
|
+
sessionHeaders = {};
|
|
7
|
+
endpointUrl;
|
|
8
|
+
timeout;
|
|
9
|
+
sseAbortController = null;
|
|
10
|
+
constructor(config) {
|
|
11
|
+
// Set isEnterprise to true for Enterprise Gateway
|
|
12
|
+
super({ ...config, isEnterprise: true });
|
|
13
|
+
// Debug the inherited baseUrl
|
|
14
|
+
console.error(`[EnterpriseGateway] Inherited baseUrl: ${this.baseUrl}`);
|
|
15
|
+
const slug = config?.slug;
|
|
16
|
+
this.endpointUrl = slug ? `${this.baseUrl}/${slug}` : `${this.baseUrl}`;
|
|
17
|
+
this.timeout = config?.timeout ?? 30000;
|
|
18
|
+
// Debug final endpoint URL
|
|
19
|
+
console.error(`[EnterpriseGateway] Final endpoint URL: ${this.endpointUrl}`);
|
|
20
|
+
console.error(`[EnterpriseGateway] Timeout: ${this.timeout}ms`);
|
|
21
|
+
}
|
|
22
|
+
// Simplified validation for production - only log errors
|
|
23
|
+
validateResponse(response, originalRequest) {
|
|
24
|
+
if (!response || typeof response !== "object") {
|
|
25
|
+
console.error("❌ Invalid response format");
|
|
26
|
+
return response;
|
|
27
|
+
}
|
|
28
|
+
if (Array.isArray(response)) {
|
|
29
|
+
response.forEach((elem, idx) => {
|
|
30
|
+
this.validateSingleResponse(elem, idx);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
this.validateSingleResponse(response);
|
|
35
|
+
}
|
|
36
|
+
return response;
|
|
37
|
+
}
|
|
38
|
+
validateSingleResponse(resp, index) {
|
|
39
|
+
const prefix = index !== undefined ? `#${index}: ` : "";
|
|
40
|
+
if (resp.jsonrpc !== JSON_RPC_VERSION) {
|
|
41
|
+
console.error(`❌ ${prefix}Invalid jsonrpc version`);
|
|
42
|
+
}
|
|
43
|
+
const hasResult = resp.hasOwnProperty("result");
|
|
44
|
+
const hasError = resp.hasOwnProperty("error");
|
|
45
|
+
if (!hasResult && !hasError) {
|
|
46
|
+
console.error(`❌ ${prefix}Response missing both 'result' and 'error' fields`);
|
|
47
|
+
}
|
|
48
|
+
if (hasError && resp.error) {
|
|
49
|
+
console.error(`❌ ${prefix}Server error: ${resp.error.message}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async connect() {
|
|
53
|
+
try {
|
|
54
|
+
console.error("--- Connecting to MCP server");
|
|
55
|
+
console.error(`--- Connection URL: ${this.endpointUrl}`);
|
|
56
|
+
const initializeRequest = {
|
|
57
|
+
jsonrpc: JSON_RPC_VERSION,
|
|
58
|
+
method: "initialize",
|
|
59
|
+
params: {
|
|
60
|
+
capabilities: {
|
|
61
|
+
tools: {},
|
|
62
|
+
prompts: {},
|
|
63
|
+
resources: {},
|
|
64
|
+
logging: {},
|
|
65
|
+
},
|
|
66
|
+
clientInfo: {
|
|
67
|
+
name: "claude-desktop-gateway",
|
|
68
|
+
version: "1.0.0",
|
|
69
|
+
},
|
|
70
|
+
protocolVersion: "2025-03-26",
|
|
71
|
+
},
|
|
72
|
+
id: 1,
|
|
73
|
+
};
|
|
74
|
+
const response = await this.makeRequest(initializeRequest);
|
|
75
|
+
if (response && response.result) {
|
|
76
|
+
this.isReady = true;
|
|
77
|
+
this.reconnectAttempts = 0;
|
|
78
|
+
console.error("--- MCP server connected successfully");
|
|
79
|
+
await this.processQueuedMessages();
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
throw new Error(`Initialize failed: ${JSON.stringify(response.error)}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
87
|
+
console.error(`--- Connection failed: ${errorMessage}`);
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async makeRequest(messageBody) {
|
|
92
|
+
const isInitialize = messageBody?.method === "initialize";
|
|
93
|
+
const headers = {
|
|
94
|
+
"Content-Type": "application/json",
|
|
95
|
+
Accept: isInitialize
|
|
96
|
+
? "application/json"
|
|
97
|
+
: "application/json, text/event-stream",
|
|
98
|
+
...this.sessionHeaders,
|
|
99
|
+
};
|
|
100
|
+
if (this.apiKey) {
|
|
101
|
+
headers["Authorization"] = `Bearer ${this.apiKey}`;
|
|
102
|
+
}
|
|
103
|
+
const controller = new AbortController();
|
|
104
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
105
|
+
let resp;
|
|
106
|
+
try {
|
|
107
|
+
resp = await fetch(this.endpointUrl, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers,
|
|
110
|
+
body: JSON.stringify(messageBody),
|
|
111
|
+
signal: controller.signal,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
116
|
+
throw new Error(`Request timeout after ${this.timeout}ms`);
|
|
117
|
+
}
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
clearTimeout(timeoutId);
|
|
122
|
+
}
|
|
123
|
+
// Update session headers
|
|
124
|
+
resp.headers.forEach((value, key) => {
|
|
125
|
+
if (key.toLowerCase() === MCP_SESSION_ID_HEADER.toLowerCase()) {
|
|
126
|
+
this.sessionHeaders[MCP_SESSION_ID_HEADER] = value;
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
if (!resp.ok) {
|
|
130
|
+
const text = await resp.text();
|
|
131
|
+
console.error(`❌ HTTP Error ${resp.status}: ${text}`);
|
|
132
|
+
throw new Error(`HTTP ${resp.status}: ${text}`);
|
|
133
|
+
}
|
|
134
|
+
const contentType = resp.headers.get("content-type") || "";
|
|
135
|
+
// Handle JSON response
|
|
136
|
+
if (contentType.includes("application/json")) {
|
|
137
|
+
const raw = await resp.text();
|
|
138
|
+
if (!raw || raw.trim() === "") {
|
|
139
|
+
throw new Error("Empty JSON response");
|
|
140
|
+
}
|
|
141
|
+
let parsed;
|
|
142
|
+
try {
|
|
143
|
+
parsed = JSON.parse(raw);
|
|
144
|
+
}
|
|
145
|
+
catch (parseErr) {
|
|
146
|
+
console.error("❌ Failed to parse JSON response");
|
|
147
|
+
throw new Error(`Invalid JSON: ${parseErr.message}`);
|
|
148
|
+
}
|
|
149
|
+
this.validateResponse(parsed, messageBody);
|
|
150
|
+
return parsed;
|
|
151
|
+
}
|
|
152
|
+
// Handle SSE stream
|
|
153
|
+
if (contentType.includes("text/event-stream")) {
|
|
154
|
+
if (this.sseAbortController) {
|
|
155
|
+
this.sseAbortController.abort();
|
|
156
|
+
}
|
|
157
|
+
this.sseAbortController = new AbortController();
|
|
158
|
+
await this.readSSEStream(resp, messageBody);
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
// Handle 202 Accepted
|
|
162
|
+
if (resp.status === 202) {
|
|
163
|
+
return {};
|
|
164
|
+
}
|
|
165
|
+
const txt = await resp.text();
|
|
166
|
+
console.error(`❌ Unexpected Content-Type ${contentType}`);
|
|
167
|
+
throw new Error(`Unexpected Content-Type: ${contentType}`);
|
|
168
|
+
}
|
|
169
|
+
async readSSEStream(response, originalRequest) {
|
|
170
|
+
if (!response.body) {
|
|
171
|
+
console.error("❌ No response body for SSE");
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const reader = response.body.getReader();
|
|
175
|
+
const decoder = new TextDecoder();
|
|
176
|
+
let buffer = "";
|
|
177
|
+
try {
|
|
178
|
+
while (true) {
|
|
179
|
+
const { done, value } = await reader.read();
|
|
180
|
+
if (done)
|
|
181
|
+
break;
|
|
182
|
+
buffer += decoder.decode(value, { stream: true });
|
|
183
|
+
let parts = buffer.split(/\r?\n\r?\n/);
|
|
184
|
+
buffer = parts.pop() || "";
|
|
185
|
+
for (const chunk of parts) {
|
|
186
|
+
const lines = chunk.split(/\r?\n/);
|
|
187
|
+
for (const line of lines) {
|
|
188
|
+
if (line.startsWith("data:")) {
|
|
189
|
+
const payload = line.slice(5).trim();
|
|
190
|
+
// Handle session establishment
|
|
191
|
+
const sidMatch = payload.match(/sessionId=([^&]+)/);
|
|
192
|
+
if (sidMatch) {
|
|
193
|
+
this.sessionHeaders[MCP_SESSION_ID_HEADER] = sidMatch[1];
|
|
194
|
+
this.isReady = true;
|
|
195
|
+
console.error(`Session established: ${sidMatch[1]}`);
|
|
196
|
+
await this.processQueuedMessages();
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
// Handle JSON-RPC messages
|
|
200
|
+
try {
|
|
201
|
+
const obj = JSON.parse(payload);
|
|
202
|
+
this.validateResponse(obj, originalRequest);
|
|
203
|
+
console.log(JSON.stringify(obj));
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
console.log(payload);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
console.error("⚠️ SSE stream closed by server");
|
|
213
|
+
await this.handleConnectionError(new Error("SSE closed"));
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
if (err.name !== "AbortError") {
|
|
217
|
+
console.error("❌ Error reading SSE:", err);
|
|
218
|
+
await this.handleConnectionError(err);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
reader.releaseLock();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
async handleConnectionError(error) {
|
|
226
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
227
|
+
console.error(`Connection error: ${errorMessage}`);
|
|
228
|
+
if (this.sseAbortController) {
|
|
229
|
+
this.sseAbortController.abort();
|
|
230
|
+
this.sseAbortController = null;
|
|
231
|
+
}
|
|
232
|
+
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
233
|
+
console.error(`Max reconnect attempts reached. Exiting.`);
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
this.reconnectAttempts++;
|
|
237
|
+
this.isReady = false;
|
|
238
|
+
this.sessionHeaders = {};
|
|
239
|
+
console.error(`Reconnecting in ${this.reconnectDelay}ms (attempt ${this.reconnectAttempts})`);
|
|
240
|
+
await new Promise((r) => setTimeout(r, this.reconnectDelay));
|
|
241
|
+
await this.connect();
|
|
242
|
+
}
|
|
243
|
+
parseMultipleJSON(input) {
|
|
244
|
+
const messages = [];
|
|
245
|
+
let braceCount = 0;
|
|
246
|
+
let inString = false;
|
|
247
|
+
let escapeNext = false;
|
|
248
|
+
let startIndex = -1;
|
|
249
|
+
for (let i = 0; i < input.length; i++) {
|
|
250
|
+
const char = input[i];
|
|
251
|
+
if (escapeNext) {
|
|
252
|
+
escapeNext = false;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (char === "\\") {
|
|
256
|
+
escapeNext = true;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (char === '"' && !escapeNext) {
|
|
260
|
+
inString = !inString;
|
|
261
|
+
}
|
|
262
|
+
if (!inString) {
|
|
263
|
+
if (char === "{") {
|
|
264
|
+
if (braceCount === 0)
|
|
265
|
+
startIndex = i;
|
|
266
|
+
braceCount++;
|
|
267
|
+
}
|
|
268
|
+
else if (char === "}") {
|
|
269
|
+
braceCount--;
|
|
270
|
+
if (braceCount === 0 && startIndex >= 0) {
|
|
271
|
+
const jsonObj = input.substring(startIndex, i + 1).trim();
|
|
272
|
+
if (jsonObj)
|
|
273
|
+
messages.push(jsonObj);
|
|
274
|
+
startIndex = -1;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return messages;
|
|
280
|
+
}
|
|
281
|
+
async processMessage(input) {
|
|
282
|
+
if (!this.isReady) {
|
|
283
|
+
this.messageQueue.push(input);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const rawInput = input.toString().trim();
|
|
287
|
+
try {
|
|
288
|
+
const fragments = this.parseMultipleJSON(rawInput);
|
|
289
|
+
if (fragments.length === 0) {
|
|
290
|
+
console.error("⚠️ No JSON found in input");
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
for (const raw of fragments) {
|
|
294
|
+
console.error(`--> ${raw}`);
|
|
295
|
+
const body = JSON.parse(raw);
|
|
296
|
+
const isNotification = !body.hasOwnProperty("id");
|
|
297
|
+
if (isNotification) {
|
|
298
|
+
await this.makeNotificationRequest(body);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
const responseObj = await this.makeRequest(body);
|
|
302
|
+
if (responseObj) {
|
|
303
|
+
const respStr = JSON.stringify(responseObj);
|
|
304
|
+
console.log(respStr);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
310
|
+
console.error(`❌ Request error: ${errMsg}`);
|
|
311
|
+
if (errMsg.includes("fetch") ||
|
|
312
|
+
errMsg.includes("timeout") ||
|
|
313
|
+
errMsg.includes("503") ||
|
|
314
|
+
errMsg.includes("502")) {
|
|
315
|
+
await this.handleConnectionError(err);
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
// Send error response if we can determine the request ID
|
|
319
|
+
let originalId = null;
|
|
320
|
+
try {
|
|
321
|
+
const fragments = this.parseMultipleJSON(rawInput);
|
|
322
|
+
if (fragments.length > 0) {
|
|
323
|
+
const parsed = JSON.parse(fragments[0]);
|
|
324
|
+
if (parsed.hasOwnProperty("id")) {
|
|
325
|
+
originalId = parsed.id;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
catch (parseErr) {
|
|
330
|
+
console.error(`⚠️ Failed to extract request ID for error response: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`);
|
|
331
|
+
}
|
|
332
|
+
if (originalId !== null) {
|
|
333
|
+
const errorResponse = {
|
|
334
|
+
jsonrpc: JSON_RPC_VERSION,
|
|
335
|
+
error: {
|
|
336
|
+
code: -32603,
|
|
337
|
+
message: `Gateway error: ${errMsg}`,
|
|
338
|
+
},
|
|
339
|
+
id: originalId,
|
|
340
|
+
};
|
|
341
|
+
console.log(JSON.stringify(errorResponse));
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
async makeNotificationRequest(messageBody) {
|
|
347
|
+
const headers = {
|
|
348
|
+
"Content-Type": "application/json",
|
|
349
|
+
Accept: "application/json, text/event-stream",
|
|
350
|
+
...this.sessionHeaders,
|
|
351
|
+
};
|
|
352
|
+
if (this.apiKey) {
|
|
353
|
+
headers["Authorization"] = `Bearer ${this.apiKey}`;
|
|
354
|
+
}
|
|
355
|
+
const controller = new AbortController();
|
|
356
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
357
|
+
try {
|
|
358
|
+
const resp = await fetch(this.endpointUrl, {
|
|
359
|
+
method: "POST",
|
|
360
|
+
headers,
|
|
361
|
+
body: JSON.stringify(messageBody),
|
|
362
|
+
signal: controller.signal,
|
|
363
|
+
});
|
|
364
|
+
resp.headers.forEach((value, key) => {
|
|
365
|
+
if (key.toLowerCase() === MCP_SESSION_ID_HEADER.toLowerCase()) {
|
|
366
|
+
this.sessionHeaders[MCP_SESSION_ID_HEADER] = value;
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
if (!resp.ok && resp.status !== 202) {
|
|
370
|
+
const text = await resp.text();
|
|
371
|
+
console.error(`❌ Notification error ${resp.status}: ${text}`);
|
|
372
|
+
throw new Error(`HTTP ${resp.status}: ${text}`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
catch (err) {
|
|
376
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
377
|
+
throw new Error(`Notification timeout after ${this.timeout}ms`);
|
|
378
|
+
}
|
|
379
|
+
throw err;
|
|
380
|
+
}
|
|
381
|
+
finally {
|
|
382
|
+
clearTimeout(timeoutId);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
cleanup() {
|
|
386
|
+
this.isReady = false;
|
|
387
|
+
this.sessionHeaders = {};
|
|
388
|
+
if (this.sseAbortController) {
|
|
389
|
+
this.sseAbortController.abort();
|
|
390
|
+
this.sseAbortController = null;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
async healthCheck() {
|
|
394
|
+
try {
|
|
395
|
+
const pingRequest = {
|
|
396
|
+
jsonrpc: JSON_RPC_VERSION,
|
|
397
|
+
method: "ping",
|
|
398
|
+
id: "health-check",
|
|
399
|
+
};
|
|
400
|
+
const resp = await this.makeRequest(pingRequest);
|
|
401
|
+
return resp !== null && !resp.hasOwnProperty("error");
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
package/build/gateway.js
CHANGED
|
@@ -1,170 +1,66 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
baseUrl;
|
|
11
|
-
sseUrl;
|
|
12
|
-
messageUrl;
|
|
13
|
-
maxReconnectAttempts;
|
|
14
|
-
reconnectDelay;
|
|
15
|
-
apiKey; // Store the API key
|
|
16
|
-
constructor(config) {
|
|
17
|
-
const slug = config?.slug;
|
|
18
|
-
this.baseUrl = NATOMA_MCP_SERVER_URL;
|
|
19
|
-
this.sseUrl = slug ? `${this.baseUrl}/${slug}` : `${this.baseUrl}`;
|
|
20
|
-
this.messageUrl = slug
|
|
21
|
-
? `${this.baseUrl}/${slug}/message`
|
|
22
|
-
: `${this.baseUrl}/message`;
|
|
23
|
-
this.maxReconnectAttempts = config?.maxReconnectAttempts ?? 3;
|
|
24
|
-
this.reconnectDelay = config?.reconnectDelay ?? 1000;
|
|
25
|
-
this.apiKey = config?.apiKey;
|
|
26
|
-
}
|
|
27
|
-
async connect() {
|
|
28
|
-
if (this.eventSource) {
|
|
29
|
-
console.error("Closing existing connection");
|
|
30
|
-
this.eventSource.close();
|
|
31
|
-
}
|
|
32
|
-
return new Promise((resolve, reject) => {
|
|
33
|
-
// Include API key in headers if it exists
|
|
34
|
-
const headers = {
|
|
35
|
-
Accept: "text/event-stream",
|
|
36
|
-
};
|
|
37
|
-
// Add Authorization header if API key is provided
|
|
38
|
-
if (this.apiKey) {
|
|
39
|
-
headers["Authorization"] = `Bearer ${this.apiKey}`;
|
|
40
|
-
}
|
|
41
|
-
this.eventSource = new EventSource(this.sseUrl, { headers });
|
|
42
|
-
this.eventSource.onopen = () => {
|
|
43
|
-
console.error("--- SSE backend connected");
|
|
44
|
-
this.reconnectAttempts = 0;
|
|
45
|
-
resolve();
|
|
46
|
-
};
|
|
47
|
-
this.eventSource.onerror = (error) => {
|
|
48
|
-
console.error(`--- SSE backend error: ${error?.message}`);
|
|
49
|
-
this.handleConnectionError(error);
|
|
50
|
-
reject(error);
|
|
51
|
-
};
|
|
52
|
-
this.setupEventListeners();
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
setupEventListeners() {
|
|
56
|
-
if (!this.eventSource)
|
|
57
|
-
return;
|
|
58
|
-
this.eventSource.addEventListener("endpoint", (event) => {
|
|
59
|
-
const match = event.data.match(/sessionId=([^&]+)/);
|
|
60
|
-
if (match) {
|
|
61
|
-
this.sessionId = match[1];
|
|
62
|
-
this.isReady = true;
|
|
63
|
-
console.error(`Session established: ${this.sessionId}`);
|
|
64
|
-
this.processQueuedMessages();
|
|
65
|
-
}
|
|
66
|
-
});
|
|
67
|
-
this.eventSource.addEventListener("message", (event) => {
|
|
68
|
-
try {
|
|
69
|
-
console.error(`<-- ${event.data}`);
|
|
70
|
-
console.log(event.data); // Forward to stdout
|
|
71
|
-
}
|
|
72
|
-
catch (error) {
|
|
73
|
-
console.error(`Error handling message: ${error}`);
|
|
74
|
-
}
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
async handleConnectionError(error) {
|
|
78
|
-
console.error(`Connection error: ${error.message}`);
|
|
79
|
-
if (this.eventSource?.readyState === EventSource.CLOSED) {
|
|
80
|
-
console.error("EventSource connection closed");
|
|
81
|
-
await this.reconnect();
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
async reconnect() {
|
|
85
|
-
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
86
|
-
console.error(`Max reconnection attempts (${this.maxReconnectAttempts}) reached, exiting...`);
|
|
87
|
-
process.exit(1);
|
|
88
|
-
}
|
|
89
|
-
this.reconnectAttempts++;
|
|
90
|
-
this.isReady = false;
|
|
91
|
-
try {
|
|
92
|
-
await new Promise((resolve) => setTimeout(resolve, this.reconnectDelay));
|
|
93
|
-
await this.connect();
|
|
94
|
-
}
|
|
95
|
-
catch (error) {
|
|
96
|
-
console.error(`Reconnection failed: ${error}`);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
async processMessage(input) {
|
|
100
|
-
if (!this.isReady || !this.sessionId) {
|
|
101
|
-
this.messageQueue.push(input);
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
const message = input.toString().trim();
|
|
105
|
-
console.error(`--> ${message}`);
|
|
106
|
-
try {
|
|
107
|
-
const url = `${this.messageUrl}?sessionId=${this.sessionId}`;
|
|
108
|
-
// Define headers with content type
|
|
109
|
-
const headers = {
|
|
110
|
-
"Content-Type": "application/json",
|
|
111
|
-
};
|
|
112
|
-
// Add Authorization header if API key is provided
|
|
113
|
-
if (this.apiKey) {
|
|
114
|
-
headers["Authorization"] = `Bearer ${this.apiKey}`;
|
|
115
|
-
}
|
|
116
|
-
const response = await fetch(url, {
|
|
117
|
-
method: "POST",
|
|
118
|
-
headers,
|
|
119
|
-
body: message,
|
|
120
|
-
});
|
|
121
|
-
if (!response.ok) {
|
|
122
|
-
if (response.status === 503) {
|
|
123
|
-
await this.reconnect();
|
|
124
|
-
}
|
|
125
|
-
else {
|
|
126
|
-
console.error(`Error from server: ${response.status} ${response.statusText}`);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
catch (error) {
|
|
131
|
-
console.error(`Request error: ${error}`);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
async processQueuedMessages() {
|
|
135
|
-
while (this.messageQueue.length > 0) {
|
|
136
|
-
const message = this.messageQueue.shift();
|
|
137
|
-
if (message) {
|
|
138
|
-
await this.processMessage(message);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
cleanup() {
|
|
143
|
-
if (this.eventSource) {
|
|
144
|
-
this.eventSource.close();
|
|
145
|
-
}
|
|
146
|
-
}
|
|
2
|
+
import { NMSGateway } from "./nms-gateway.js";
|
|
3
|
+
import { EnterpriseGateway } from "./ent-gateway.js";
|
|
4
|
+
// Parse command line arguments
|
|
5
|
+
function parseArgs() {
|
|
6
|
+
const args = process.argv.slice(2);
|
|
7
|
+
return {
|
|
8
|
+
enterprise: args.includes("--enterprise"),
|
|
9
|
+
};
|
|
147
10
|
}
|
|
148
|
-
//
|
|
11
|
+
// Main function
|
|
149
12
|
async function main() {
|
|
150
|
-
const
|
|
13
|
+
const { enterprise } = parseArgs();
|
|
14
|
+
const slug = process.env.NATOMA_MCP_SERVER_INSTALLATION_ID;
|
|
15
|
+
if (!slug) {
|
|
16
|
+
console.error("Please set NATOMA_MCP_SERVER_INSTALLATION_ID env var");
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
const config = {
|
|
20
|
+
slug,
|
|
151
21
|
apiKey: process.env.NATOMA_MCP_API_KEY,
|
|
152
|
-
|
|
153
|
-
|
|
22
|
+
maxReconnectAttempts: 5,
|
|
23
|
+
reconnectDelay: enterprise ? 2000 : 1000,
|
|
24
|
+
timeout: enterprise ? 60000 : undefined,
|
|
25
|
+
};
|
|
26
|
+
// Create appropriate gateway based on flag
|
|
27
|
+
const gateway = enterprise
|
|
28
|
+
? new EnterpriseGateway(config)
|
|
29
|
+
: new NMSGateway(config);
|
|
30
|
+
console.error(`--- Starting ${enterprise ? "Enterprise" : "NMS"} Gateway ---`);
|
|
154
31
|
try {
|
|
155
32
|
await gateway.connect();
|
|
156
|
-
process.stdin.on("data", (data) =>
|
|
157
|
-
|
|
158
|
-
|
|
33
|
+
process.stdin.on("data", (data) => {
|
|
34
|
+
gateway.processMessage(data).catch((err) => {
|
|
35
|
+
console.error("Error in processMessage:", err);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
const shutdown = () => {
|
|
159
39
|
console.error("Shutting down...");
|
|
160
40
|
gateway.cleanup();
|
|
161
41
|
process.exit(0);
|
|
162
42
|
};
|
|
163
|
-
process.on("SIGINT",
|
|
164
|
-
process.on("SIGTERM",
|
|
43
|
+
process.on("SIGINT", shutdown);
|
|
44
|
+
process.on("SIGTERM", shutdown);
|
|
45
|
+
process.on("uncaughtException", (err) => {
|
|
46
|
+
console.error("Uncaught exception:", err);
|
|
47
|
+
shutdown();
|
|
48
|
+
});
|
|
49
|
+
// Optional health check for enterprise gateway
|
|
50
|
+
if (enterprise && gateway instanceof EnterpriseGateway) {
|
|
51
|
+
setInterval(async () => {
|
|
52
|
+
if (gateway.ready) {
|
|
53
|
+
const ok = await gateway.healthCheck();
|
|
54
|
+
if (!ok) {
|
|
55
|
+
console.error("Health check failed; reconnecting");
|
|
56
|
+
await gateway.handleConnectionError(new Error("Health check failed"));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}, 5 * 60 * 1000); // Every 5 minutes
|
|
60
|
+
}
|
|
165
61
|
}
|
|
166
|
-
catch (
|
|
167
|
-
console.error(
|
|
62
|
+
catch (err) {
|
|
63
|
+
console.error("Fatal error:", err);
|
|
168
64
|
process.exit(1);
|
|
169
65
|
}
|
|
170
66
|
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// nms-gateway.ts
|
|
2
|
+
import EventSource from "eventsource";
|
|
3
|
+
import { BaseMCPGateway } from "./base.js";
|
|
4
|
+
// NMS Gateway (Non-Enterprise) - EventSource based
|
|
5
|
+
export class NMSGateway extends BaseMCPGateway {
|
|
6
|
+
eventSource = null;
|
|
7
|
+
sessionId = null;
|
|
8
|
+
sseUrl;
|
|
9
|
+
messageUrl;
|
|
10
|
+
constructor(config) {
|
|
11
|
+
// Set isEnterprise to false for NMS Gateway
|
|
12
|
+
super({ ...config, isEnterprise: false });
|
|
13
|
+
const slug = config?.slug;
|
|
14
|
+
this.sseUrl = slug ? `${this.baseUrl}/${slug}` : `${this.baseUrl}`;
|
|
15
|
+
this.messageUrl = slug
|
|
16
|
+
? `${this.baseUrl}/${slug}/message`
|
|
17
|
+
: `${this.baseUrl}/message`;
|
|
18
|
+
// Debug the URLs
|
|
19
|
+
console.error(`[NMSGateway] Base URL: ${this.baseUrl}`);
|
|
20
|
+
console.error(`[NMSGateway] SSE URL: ${this.sseUrl}`);
|
|
21
|
+
console.error(`[NMSGateway] Message URL: ${this.messageUrl}`);
|
|
22
|
+
}
|
|
23
|
+
async connect() {
|
|
24
|
+
if (this.eventSource) {
|
|
25
|
+
console.error("Closing existing connection");
|
|
26
|
+
this.eventSource.close();
|
|
27
|
+
}
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const headers = {
|
|
30
|
+
Accept: "text/event-stream",
|
|
31
|
+
};
|
|
32
|
+
if (this.apiKey) {
|
|
33
|
+
headers["Authorization"] = `Bearer ${this.apiKey}`;
|
|
34
|
+
}
|
|
35
|
+
this.eventSource = new EventSource(this.sseUrl, { headers });
|
|
36
|
+
this.eventSource.onopen = () => {
|
|
37
|
+
console.error("--- SSE backend connected");
|
|
38
|
+
this.reconnectAttempts = 0;
|
|
39
|
+
resolve();
|
|
40
|
+
};
|
|
41
|
+
this.eventSource.onerror = (error) => {
|
|
42
|
+
console.error(`--- SSE backend error: ${error?.message}`);
|
|
43
|
+
this.handleConnectionError(error);
|
|
44
|
+
reject(error);
|
|
45
|
+
};
|
|
46
|
+
this.setupEventListeners();
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
setupEventListeners() {
|
|
50
|
+
if (!this.eventSource)
|
|
51
|
+
return;
|
|
52
|
+
this.eventSource.addEventListener("endpoint", (event) => {
|
|
53
|
+
const match = event.data.match(/sessionId=([^&]+)/);
|
|
54
|
+
if (match) {
|
|
55
|
+
this.sessionId = match[1];
|
|
56
|
+
this.isReady = true;
|
|
57
|
+
console.error(`Session established: ${this.sessionId}`);
|
|
58
|
+
this.processQueuedMessages();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
this.eventSource.addEventListener("message", (event) => {
|
|
62
|
+
try {
|
|
63
|
+
console.error(`<-- ${event.data}`);
|
|
64
|
+
console.log(event.data);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
console.error(`Error handling message: ${error}`);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
async handleConnectionError(error) {
|
|
72
|
+
console.error(`Connection error: ${error.message}`);
|
|
73
|
+
if (this.eventSource?.readyState === EventSource.CLOSED) {
|
|
74
|
+
console.error("EventSource connection closed");
|
|
75
|
+
await this.reconnect();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async reconnect() {
|
|
79
|
+
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
80
|
+
console.error(`Max reconnection attempts (${this.maxReconnectAttempts}) reached, exiting...`);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
this.reconnectAttempts++;
|
|
84
|
+
this.isReady = false;
|
|
85
|
+
try {
|
|
86
|
+
await new Promise((resolve) => setTimeout(resolve, this.reconnectDelay));
|
|
87
|
+
await this.connect();
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
console.error(`Reconnection failed: ${error}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async processMessage(input) {
|
|
94
|
+
if (!this.isReady || !this.sessionId) {
|
|
95
|
+
this.messageQueue.push(input);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const message = input.toString().trim();
|
|
99
|
+
console.error(`--> ${message}`);
|
|
100
|
+
try {
|
|
101
|
+
const url = `${this.messageUrl}?sessionId=${this.sessionId}`;
|
|
102
|
+
const headers = {
|
|
103
|
+
"Content-Type": "application/json",
|
|
104
|
+
};
|
|
105
|
+
if (this.apiKey) {
|
|
106
|
+
headers["Authorization"] = `Bearer ${this.apiKey}`;
|
|
107
|
+
}
|
|
108
|
+
const response = await fetch(url, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers,
|
|
111
|
+
body: message,
|
|
112
|
+
});
|
|
113
|
+
if (!response.ok) {
|
|
114
|
+
if (response.status === 503) {
|
|
115
|
+
await this.reconnect();
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
console.error(`Error from server: ${response.status} ${response.statusText}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
console.error(`Request error: ${error}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
cleanup() {
|
|
127
|
+
if (this.eventSource) {
|
|
128
|
+
this.eventSource.close();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
package/build/setup.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// setup.ts
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import os from "os";
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
export const setupCommand = {
|
|
7
|
+
command: "setup <url>",
|
|
8
|
+
describe: "Setup command for Natoma MCP Gateway integration",
|
|
9
|
+
builder: (yargs) => {
|
|
10
|
+
return yargs
|
|
11
|
+
.positional("url", {
|
|
12
|
+
type: "string",
|
|
13
|
+
describe: "The Natoma MCP URL to use",
|
|
14
|
+
demandOption: true,
|
|
15
|
+
})
|
|
16
|
+
.option("client", {
|
|
17
|
+
type: "string",
|
|
18
|
+
describe: "Client to use (claude, windsurf, cursor)",
|
|
19
|
+
default: "claude",
|
|
20
|
+
choices: ["claude", "windsurf", "cursor"],
|
|
21
|
+
})
|
|
22
|
+
.option("apiKey", {
|
|
23
|
+
type: "string",
|
|
24
|
+
describe: "API key for authentication",
|
|
25
|
+
demandOption: true,
|
|
26
|
+
});
|
|
27
|
+
},
|
|
28
|
+
handler: async (argv) => {
|
|
29
|
+
const { url, client, apiKey } = argv;
|
|
30
|
+
try {
|
|
31
|
+
console.log(chalk.cyan("📝 Configuration Details:"));
|
|
32
|
+
console.log(` URL: ${chalk.green(url)}`);
|
|
33
|
+
console.log(` Client: ${chalk.green(client)}`);
|
|
34
|
+
if (apiKey) {
|
|
35
|
+
console.log(` API Key: ${chalk.green("****" + apiKey.slice(-4))}`);
|
|
36
|
+
}
|
|
37
|
+
console.log("");
|
|
38
|
+
console.log(chalk.cyan("💾 Saving configurations..."));
|
|
39
|
+
saveMcpConfig(url, client, apiKey);
|
|
40
|
+
console.log(chalk.cyan(`\n🚀 All done! Please restart ${client} for changes to take effect\n`));
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
console.log(chalk.red("\n❌ Error occurred while setting up Natoma MCP:"));
|
|
44
|
+
console.log(chalk.red(` ${error.message}`));
|
|
45
|
+
console.log(chalk.yellow("\nPlease try again or contact support if the issue persists.\n"));
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
function saveMcpConfig(url, clientType, apiKey) {
|
|
50
|
+
// Create config object for the gateway
|
|
51
|
+
const config = {
|
|
52
|
+
command: "npx",
|
|
53
|
+
args: ["@natomalabs/natoma-mcp-gateway@latest", "start", "--url", url],
|
|
54
|
+
env: {
|
|
55
|
+
npm_config_yes: "true",
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
// If API key is provided, add it to the environment variables
|
|
59
|
+
if (apiKey) {
|
|
60
|
+
config.env["NATOMA_MCP_API_KEY"] = apiKey;
|
|
61
|
+
// Also add it to the args so it's passed to the start command
|
|
62
|
+
config.args.push("--apiKey", apiKey);
|
|
63
|
+
}
|
|
64
|
+
// Extract installation ID from URL for Cursor
|
|
65
|
+
const urlParts = url.split("/");
|
|
66
|
+
const installationId = urlParts[urlParts.length - 1];
|
|
67
|
+
const sseConfig = {
|
|
68
|
+
url: url,
|
|
69
|
+
apiKey: apiKey,
|
|
70
|
+
slug: installationId,
|
|
71
|
+
};
|
|
72
|
+
const homeDir = os.homedir();
|
|
73
|
+
// Define platform-specific paths
|
|
74
|
+
const platformPaths = {
|
|
75
|
+
win32: {
|
|
76
|
+
baseDir: process.env.APPDATA || path.join(homeDir, "AppData", "Roaming"),
|
|
77
|
+
vscodePath: path.join("Code", "User", "globalStorage"),
|
|
78
|
+
},
|
|
79
|
+
darwin: {
|
|
80
|
+
baseDir: path.join(homeDir, "Library", "Application Support"),
|
|
81
|
+
vscodePath: path.join("Code", "User", "globalStorage"),
|
|
82
|
+
},
|
|
83
|
+
linux: {
|
|
84
|
+
baseDir: process.env.XDG_CONFIG_HOME || path.join(homeDir, ".config"),
|
|
85
|
+
vscodePath: path.join("Code/User/globalStorage"),
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
const platform = process.platform;
|
|
89
|
+
// Check if platform is supported
|
|
90
|
+
if (!platformPaths[platform]) {
|
|
91
|
+
console.log(chalk.yellow(`\n⚠️ Platform ${platform} is not supported.`));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const { baseDir } = platformPaths[platform];
|
|
95
|
+
// Define client-specific paths
|
|
96
|
+
const clientPaths = {
|
|
97
|
+
claude: {
|
|
98
|
+
configDir: path.join(baseDir, "Claude"),
|
|
99
|
+
configPath: path.join(baseDir, "Claude", "claude_desktop_config.json"),
|
|
100
|
+
},
|
|
101
|
+
windsurf: {
|
|
102
|
+
configDir: path.join(homeDir, ".codeium", "windsurf"),
|
|
103
|
+
configPath: path.join(homeDir, ".codeium", "windsurf", "mcp_config.json"),
|
|
104
|
+
},
|
|
105
|
+
cursor: {
|
|
106
|
+
configDir: path.join(homeDir, ".cursor"),
|
|
107
|
+
configPath: path.join(homeDir, ".cursor", "mcp.json"),
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
if (!clientPaths[clientType]) {
|
|
111
|
+
console.log(chalk.yellow(`\n⚠️ Client ${clientType} is not supported.`));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const { configDir, configPath } = clientPaths[clientType];
|
|
115
|
+
// Create config directory if it doesn't exist
|
|
116
|
+
if (!fs.existsSync(configDir)) {
|
|
117
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
118
|
+
}
|
|
119
|
+
// Handle client-specific configuration format
|
|
120
|
+
if (clientType === "claude") {
|
|
121
|
+
let claudeConfig = { mcpServers: {} };
|
|
122
|
+
if (fs.existsSync(configPath)) {
|
|
123
|
+
try {
|
|
124
|
+
claudeConfig = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
console.log(chalk.yellow("⚠️ Creating new config file"));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// Ensure mcpServers exists
|
|
131
|
+
if (!claudeConfig.mcpServers)
|
|
132
|
+
claudeConfig.mcpServers = {};
|
|
133
|
+
// Update only the mcpServers entry
|
|
134
|
+
claudeConfig.mcpServers[url] = config;
|
|
135
|
+
fs.writeFileSync(configPath, JSON.stringify(claudeConfig, null, 2));
|
|
136
|
+
console.log(chalk.green(`✅ Configuration saved to: ${configPath}`));
|
|
137
|
+
}
|
|
138
|
+
else if (clientType === "windsurf") {
|
|
139
|
+
let windsurfConfig = { mcpServers: {} };
|
|
140
|
+
if (fs.existsSync(configPath)) {
|
|
141
|
+
try {
|
|
142
|
+
windsurfConfig = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
console.log(chalk.yellow("⚠️ Creating new config file"));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// Ensure mcpServers exists
|
|
149
|
+
if (!windsurfConfig.mcpServers)
|
|
150
|
+
windsurfConfig.mcpServers = {};
|
|
151
|
+
// Now TypeScript knows mcpServers exists
|
|
152
|
+
windsurfConfig.mcpServers[url] = config;
|
|
153
|
+
fs.writeFileSync(configPath, JSON.stringify(windsurfConfig, null, 2));
|
|
154
|
+
console.log(chalk.green(`✅ Configuration saved to: ${configPath}`));
|
|
155
|
+
}
|
|
156
|
+
else if (clientType === "cursor") {
|
|
157
|
+
let cursorConfig = { mcpServers: {} };
|
|
158
|
+
if (fs.existsSync(configPath)) {
|
|
159
|
+
try {
|
|
160
|
+
cursorConfig = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
console.log(chalk.yellow("⚠️ Creating new config file"));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// Ensure mcpServers exists
|
|
167
|
+
if (!cursorConfig.mcpServers)
|
|
168
|
+
cursorConfig.mcpServers = {};
|
|
169
|
+
// Remove existing config if it exists
|
|
170
|
+
if (cursorConfig.mcpServers[url]) {
|
|
171
|
+
delete cursorConfig.mcpServers[url];
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
// Create a unique key for Cursor's configuration
|
|
175
|
+
const newKey = `natoma_${installationId}`;
|
|
176
|
+
cursorConfig.mcpServers[newKey] = sseConfig;
|
|
177
|
+
fs.writeFileSync(configPath, JSON.stringify(cursorConfig, null, 2));
|
|
178
|
+
console.log(chalk.green(`✅ Configuration saved to: ${configPath}`));
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
console.log(chalk.red("❌ Error occurred while setting up MCP:"));
|
|
182
|
+
console.log(chalk.red(` ${error.message}`));
|
|
183
|
+
console.log(chalk.yellow("\nPlease try again or contact support if the issue persists.\n"));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@natomalabs/natoma-mcp-gateway",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Natoma MCP Gateway",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "Natoma MCP Gateway with NMS and Enterprise modes",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -13,21 +13,24 @@
|
|
|
13
13
|
"scripts": {
|
|
14
14
|
"build": "tsc",
|
|
15
15
|
"prepare": "npm run build",
|
|
16
|
-
"inspector": "node build/gateway.js"
|
|
16
|
+
"inspector": "node build/gateway.js",
|
|
17
|
+
"inspector:enterprise": "node build/gateway.js --enterprise",
|
|
18
|
+
"dev": "tsx watch src/gateway.ts",
|
|
19
|
+
"dev:enterprise": "tsx watch src/gateway.ts --enterprise"
|
|
17
20
|
},
|
|
18
21
|
"dependencies": {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
"@modelcontextprotocol/sdk": "^1.0.3",
|
|
23
|
+
"eventsource": "^2.0.2",
|
|
24
|
+
"express": "^4.21.1"
|
|
22
25
|
},
|
|
23
26
|
"devDependencies": {
|
|
24
27
|
"@types/eventsource": "^1.1.15",
|
|
25
|
-
"@types/node": "^20.11.0",
|
|
26
28
|
"@types/express": "^5.0.0",
|
|
29
|
+
"@types/node": "^20.11.0",
|
|
30
|
+
"tsx": "^4.0.0",
|
|
27
31
|
"typescript": "^5.3.3"
|
|
28
32
|
},
|
|
29
33
|
"engines": {
|
|
30
|
-
|
|
34
|
+
"node": ">=18"
|
|
31
35
|
}
|
|
32
|
-
|
|
33
|
-
}
|
|
36
|
+
}
|