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.
- package/index.js +204 -75
- 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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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.
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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.
|
|
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"
|