grove-mcp 1.0.6 → 1.0.8

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.
Files changed (2) hide show
  1. package/index.js +204 -75
  2. package/package.json +2 -2
package/index.js CHANGED
@@ -13,6 +13,16 @@
13
13
  * 2. Server sends: event: endpoint data: /messages/?session_id=<uuid>
14
14
  * 3. stdin lines → POST to session endpoint (with affinity cookie if set)
15
15
  * 4. SSE message events → stdout lines
16
+ *
17
+ * Reconnect behaviour:
18
+ * On SSE disconnect the bridge reconnects automatically with exponential
19
+ * back-off. Each reconnect creates a new server-side MCP session that
20
+ * must be re-initialized before tool calls can proceed. The bridge stores
21
+ * the original `initialize` request and `notifications/initialized`
22
+ * notification from Claude Desktop and silently replays them on every
23
+ * reconnect so the server session is fully initialized before any queued
24
+ * tool calls are flushed. The replay response is intercepted and NOT
25
+ * forwarded to Claude Desktop, which already considers itself initialized.
16
26
  */
17
27
 
18
28
  const https = require('https');
@@ -33,6 +43,8 @@ if (process.env.GROVE_OUTLINE_TOKEN)
33
43
  TOKEN_HEADERS['Grove-Outline-Token'] = process.env.GROVE_OUTLINE_TOKEN;
34
44
  if (process.env.GROVE_MATTERMOST_TOKEN)
35
45
  TOKEN_HEADERS['Grove-Mattermost-Token'] = process.env.GROVE_MATTERMOST_TOKEN;
46
+ if (process.env.GROVE_NOCODB_TOKEN)
47
+ TOKEN_HEADERS['Grove-Nocodb-Token'] = process.env.GROVE_NOCODB_TOKEN;
36
48
 
37
49
  // Per-user session isolation: scopes Redis session keys so multiple users
38
50
  // on the shared 3-pod cluster never overwrite each other's auth tokens.
@@ -92,95 +104,200 @@ function stripMcpCompat(raw) {
92
104
  }
93
105
  }
94
106
 
107
+ // ── MCP initialization replay state ─────────────────────────────────────────
108
+ //
109
+ // The MCP session lives on the server; each SSE reconnect creates a new one.
110
+ // Claude Desktop does NOT re-send `initialize` on reconnect — it assumes the
111
+ // session is continuous. We fix this by storing the original handshake and
112
+ // replaying it silently on every reconnect before flushing queued messages.
113
+
114
+ // Raw JSON lines captured from stdin on first connection.
115
+ let storedInitRequest = null; // {"method":"initialize", ...}
116
+ let storedInitNotification = null; // {"method":"notifications/initialized"}
117
+
118
+ // Bridge-internal init replay tracking.
119
+ let bridgeInitId = null; // ID we assign to the replayed initialize request
120
+ let awaitingBridgeInit = false; // true while waiting for the server's init response
121
+ let heldPending = []; // messages queued while awaiting bridge init response
122
+
123
+ // ── Transport state ──────────────────────────────────────────────────────────
124
+
95
125
  let sessionPath = null;
96
126
  let affinityCookie = null; // sticky-session cookie from SSE response headers
97
127
  const pending = [];
98
128
 
99
- // ── SSE connection ──────────────────────────────────────────────────────────
100
-
101
- const sseReq = https.request(
102
- {
103
- hostname: HOSTNAME,
104
- path: SSE_PATH,
105
- method: 'GET',
106
- headers: { Accept: 'text/event-stream' },
107
- },
108
- (res) => {
109
- if (res.statusCode !== 200) {
110
- process.stderr.write(
111
- `grove-mcp-bridge: SSE connect failed (HTTP ${res.statusCode})\n`
112
- );
113
- process.exit(1);
114
- }
129
+ // ── SSE connection with reconnect ───────────────────────────────────────────
115
130
 
116
- // Capture nginx sticky-session cookie so POSTs reach the same pod
117
- const setCookie = res.headers['set-cookie'];
118
- if (setCookie) {
119
- const cookies = Array.isArray(setCookie) ? setCookie : [setCookie];
120
- const parts = cookies.map((c) => c.split(';')[0].trim());
121
- affinityCookie = parts.join('; ');
122
- }
131
+ let reconnectDelay = 1000; // ms; doubles on each failure, capped at 30s
123
132
 
124
- let buf = '';
125
- let eventType = '';
133
+ function connect() {
134
+ // Reset session state so stdin messages are queued until the new endpoint arrives
135
+ sessionPath = null;
136
+ affinityCookie = null;
126
137
 
127
- res.on('data', (chunk) => {
128
- buf += chunk.toString('utf8');
138
+ const sseReq = https.request(
139
+ {
140
+ hostname: HOSTNAME,
141
+ path: SSE_PATH,
142
+ method: 'GET',
143
+ headers: { Accept: 'text/event-stream' },
144
+ },
145
+ (res) => {
146
+ if (res.statusCode !== 200) {
147
+ process.stderr.write(
148
+ `grove-mcp-bridge: SSE connect failed (HTTP ${res.statusCode}), retrying in ${reconnectDelay}ms\n`
149
+ );
150
+ res.on('error', () => {}); // prevent unhandled-error crash while draining
151
+ res.resume();
152
+ scheduleReconnect();
153
+ return;
154
+ }
129
155
 
130
- let nl;
131
- while ((nl = buf.indexOf('\n')) !== -1) {
132
- const line = buf.slice(0, nl).replace(/\r$/, '');
133
- buf = buf.slice(nl + 1);
156
+ // Successful connection — reset backoff
157
+ reconnectDelay = 1000;
134
158
 
135
- if (line.startsWith('event:')) {
136
- eventType = line.slice(6).trim();
137
- } else if (line.startsWith('data:')) {
138
- const raw = line.slice(5).trim();
159
+ // Capture nginx sticky-session cookie so POSTs reach the same pod
160
+ const setCookie = res.headers['set-cookie'];
161
+ if (setCookie) {
162
+ const cookies = Array.isArray(setCookie) ? setCookie : [setCookie];
163
+ const parts = cookies.map((c) => c.split(';')[0].trim());
164
+ affinityCookie = parts.join('; ');
165
+ }
139
166
 
140
- if (eventType === 'endpoint') {
141
- try {
142
- sessionPath = JSON.parse(raw).uri;
143
- } catch (_) {
144
- // Fallback: server sent a plain path instead of JSON
145
- sessionPath = raw;
167
+ let buf = '';
168
+ let eventType = '';
169
+
170
+ res.on('data', (chunk) => {
171
+ buf += chunk.toString('utf8');
172
+
173
+ let nl;
174
+ while ((nl = buf.indexOf('\n')) !== -1) {
175
+ const line = buf.slice(0, nl).replace(/\r$/, '');
176
+ buf = buf.slice(nl + 1);
177
+
178
+ if (line.startsWith('event:')) {
179
+ eventType = line.slice(6).trim();
180
+ } else if (line.startsWith('data:')) {
181
+ const raw = line.slice(5).trim();
182
+
183
+ if (eventType === 'endpoint') {
184
+ try {
185
+ sessionPath = JSON.parse(raw).uri;
186
+ } catch (_) {
187
+ // Fallback: server sent a plain path instead of JSON
188
+ sessionPath = raw;
189
+ }
190
+
191
+ if (storedInitRequest !== null) {
192
+ // ── Reconnect path ─────────────────────────────────────────
193
+ // New server session — must replay the initialize handshake
194
+ // before any tool calls can proceed. Use a bridge-internal
195
+ // ID so we can intercept the response without forwarding it
196
+ // to Claude Desktop (which already considers itself initialized).
197
+ process.stderr.write('grove-mcp-bridge: reconnected — replaying MCP init\n');
198
+ bridgeInitId = '__bridge_init_' + Date.now();
199
+ awaitingBridgeInit = true;
200
+ // Move all pending messages to held; they'll be flushed after
201
+ // the server acknowledges initialization.
202
+ heldPending = pending.splice(0);
203
+ try {
204
+ const initMsg = JSON.parse(storedInitRequest);
205
+ sendToServer(JSON.stringify(Object.assign({}, initMsg, { id: bridgeInitId })));
206
+ } catch (_) {
207
+ // Malformed stored request — fall back to plain flush
208
+ awaitingBridgeInit = false;
209
+ bridgeInitId = null;
210
+ for (const msg of heldPending) sendToServer(msg);
211
+ heldPending = [];
212
+ }
213
+ } else {
214
+ // ── First connection path ──────────────────────────────────
215
+ // Claude Desktop's real initialize request is in pending.
216
+ // Flush everything; the server will process them in order.
217
+ for (const msg of pending) sendToServer(msg);
218
+ pending.length = 0;
219
+ }
220
+ } else if (eventType === 'message' && raw) {
221
+ if (awaitingBridgeInit) {
222
+ // Check whether this is the response to our replayed initialize.
223
+ try {
224
+ const msg = JSON.parse(raw);
225
+ if (msg.id === bridgeInitId) {
226
+ // Server acknowledged init — complete the handshake and
227
+ // flush held messages. Do NOT forward to Claude Desktop.
228
+ awaitingBridgeInit = false;
229
+ bridgeInitId = null;
230
+ if (storedInitNotification) sendToServer(storedInitNotification);
231
+ for (const msg of heldPending) sendToServer(msg);
232
+ heldPending = [];
233
+ process.stderr.write('grove-mcp-bridge: MCP init replay complete\n');
234
+ eventType = '';
235
+ continue; // skip stdout.write
236
+ }
237
+ } catch (_) {}
238
+ }
239
+
240
+ // Normal message — strip compat fields and forward to Claude Desktop.
241
+ process.stdout.write(stripMcpCompat(raw) + '\n');
146
242
  }
147
- // Flush any stdin lines that arrived before the endpoint was ready
148
- for (const msg of pending) sendToServer(msg);
149
- pending.length = 0;
150
- } else if (eventType === 'message' && raw) {
151
- // Strip modern MCP spec fields that cause Claude Desktop to silently
152
- // drop all tools (anthropics/claude-code#25081): FastMCP 3.x emits
153
- // outputSchema in tools/list which Claude Desktop doesn't understand.
154
- process.stdout.write(stripMcpCompat(raw) + '\n');
155
- }
156
243
 
157
- eventType = '';
158
- } else if (line === '') {
159
- eventType = '';
244
+ eventType = '';
245
+ } else if (line === '') {
246
+ eventType = '';
247
+ }
160
248
  }
161
- }
162
- });
163
-
164
- res.on('end', () => {
165
- process.stderr.write('grove-mcp-bridge: SSE stream ended\n');
166
- process.exit(0);
167
- });
249
+ });
168
250
 
169
- res.on('error', (err) => {
170
- process.stderr.write(`grove-mcp-bridge: SSE error: ${err.message}\n`);
171
- process.exit(1);
172
- });
173
- }
174
- );
251
+ res.on('end', () => {
252
+ process.stderr.write(`grove-mcp-bridge: SSE stream ended, reconnecting in ${reconnectDelay}ms\n`);
253
+ scheduleReconnect();
254
+ });
175
255
 
176
- sseReq.on('error', (err) => {
177
- process.stderr.write(
178
- `grove-mcp-bridge: cannot reach ${HOSTNAME}: ${err.message}\n`
256
+ res.on('error', (err) => {
257
+ process.stderr.write(`grove-mcp-bridge: SSE error: ${err.message}, reconnecting in ${reconnectDelay}ms\n`);
258
+ scheduleReconnect();
259
+ });
260
+ }
179
261
  );
180
- process.exit(1);
181
- });
182
262
 
183
- sseReq.end();
263
+ sseReq.on('error', (err) => {
264
+ process.stderr.write(
265
+ `grove-mcp-bridge: cannot reach ${HOSTNAME}: ${err.message}, retrying in ${reconnectDelay}ms\n`
266
+ );
267
+ scheduleReconnect();
268
+ });
269
+
270
+ // Server sends keepalive pings every 30s. If nothing arrives in 75s the
271
+ // connection is stale (server crash, network partition) — destroy and reconnect.
272
+ sseReq.setTimeout(75000);
273
+ sseReq.on('timeout', () => {
274
+ process.stderr.write(`grove-mcp-bridge: SSE idle timeout, reconnecting in ${reconnectDelay}ms\n`);
275
+ sseReq.destroy(); // triggers sseReq.on('error') or res.on('error') → scheduleReconnect()
276
+ });
277
+
278
+ sseReq.end();
279
+ }
280
+
281
+ function scheduleReconnect() {
282
+ // Null the session immediately so stdin messages are queued rather than
283
+ // fired at a dead endpoint during the reconnect delay window.
284
+ sessionPath = null;
285
+ affinityCookie = null;
286
+ // Reset bridge-init state so a clean replay happens on the next connect().
287
+ awaitingBridgeInit = false;
288
+ bridgeInitId = null;
289
+ // Move any messages held mid-replay back to pending so they aren't lost.
290
+ if (heldPending.length > 0) {
291
+ pending.unshift(...heldPending);
292
+ heldPending = [];
293
+ }
294
+ setTimeout(() => {
295
+ connect();
296
+ }, reconnectDelay);
297
+ reconnectDelay = Math.min(reconnectDelay * 2, 30000);
298
+ }
299
+
300
+ connect();
184
301
 
185
302
  // ── stdin → POST ────────────────────────────────────────────────────────────
186
303
 
@@ -221,9 +338,21 @@ const rl = readline.createInterface({
221
338
  rl.on('line', (line) => {
222
339
  if (!line.trim()) return;
223
340
 
224
- if (sessionPath === null) {
225
- // Session not yet established — queue the message
226
- pending.push(line);
341
+ // Capture the initialize handshake for reconnect replay.
342
+ try {
343
+ const msg = JSON.parse(line);
344
+ if (msg.method === 'initialize') storedInitRequest = line;
345
+ if (msg.method === 'notifications/initialized') storedInitNotification = line;
346
+ } catch (_) {}
347
+
348
+ if (sessionPath === null || awaitingBridgeInit) {
349
+ // Either: session not yet established, or we're mid-replay.
350
+ // In both cases queue the message — it'll be flushed once ready.
351
+ if (awaitingBridgeInit) {
352
+ heldPending.push(line);
353
+ } else {
354
+ pending.push(line);
355
+ }
227
356
  } else {
228
357
  sendToServer(line);
229
358
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "grove-mcp",
3
- "version": "1.0.6",
4
- "description": "Claude Desktop bridge for the Grove MCP Server (Mattermost + Outline)",
3
+ "version": "1.0.8",
4
+ "description": "Claude Desktop bridge for the Grove MCP Server (Mattermost + Outline + NocoDB)",
5
5
  "main": "index.js",
6
6
  "bin": {
7
7
  "grove-mcp": "index.js"