@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 CHANGED
@@ -1,28 +1,145 @@
1
- # Natoma's Server Gateway for all things MCP
1
+ # Natoma MCP Gateway
2
2
 
3
- This repo hosts the MCP Bridge proxy that enables the communication between an stdio-based Claude Desktop app and HTTP/SSE supported MCP servers.
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
- ## Purpose
5
+ ## Overview
6
6
 
7
- This Bridge Proxy service enables interoperability by:
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
- - Creating a unified communication layer between different MCP implementations
10
- - Translating stdio protocol messages into HTTP/SSE format
11
- - Converting server responses back into the expected stdio format
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
- Reference: https://modelcontextprotocol.io/introduction
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
- ## How It Works
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
- The acts as an intermediary that:
139
+ ## License
20
140
 
21
- - Listens for incoming stdio messages from AI client hosts. Example: Claude Desktop
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
- This allows applications built for stdio-based MCP to work smoothly with the Natoma MCP Platform without requiring changes to either side.
143
+ ## Support
27
144
 
28
- Required Node.js Version: Node.js 18 or higher is required to run this project.
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 EventSource from "eventsource";
3
- const NATOMA_MCP_SERVER_URL = "https://api.app.natoma.ai/api/mcp";
4
- export class MCPGateway {
5
- eventSource = null;
6
- sessionId = null;
7
- isReady = false;
8
- messageQueue = [];
9
- reconnectAttempts = 0;
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
- // Example usage
11
+ // Main function
149
12
  async function main() {
150
- const gateway = new MCPGateway({
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
- slug: process.env.NATOMA_MCP_SERVER_INSTALLATION_ID,
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) => gateway.processMessage(data));
157
- // Handle cleanup
158
- const cleanup = () => {
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", cleanup);
164
- process.on("SIGTERM", cleanup);
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 (error) {
167
- console.error(`Fatal error:`, 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.1",
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
- "@modelcontextprotocol/sdk": "^1.0.3",
20
- "eventsource": "^2.0.2",
21
- "express": "^4.21.1"
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
- "node": ">=18"
34
+ "node": ">=18"
31
35
  }
32
-
33
- }
36
+ }