grove-mcp 1.0.4 → 1.0.7
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 +167 -74
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
|
|
18
18
|
const https = require('https');
|
|
19
19
|
const readline = require('readline');
|
|
20
|
+
const crypto = require('crypto');
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
const os = require('os');
|
|
20
24
|
|
|
21
25
|
const HOSTNAME = 'mcp.lyra.tg';
|
|
22
26
|
const SSE_PATH = '/sse';
|
|
@@ -29,100 +33,189 @@ if (process.env.GROVE_OUTLINE_TOKEN)
|
|
|
29
33
|
TOKEN_HEADERS['Grove-Outline-Token'] = process.env.GROVE_OUTLINE_TOKEN;
|
|
30
34
|
if (process.env.GROVE_MATTERMOST_TOKEN)
|
|
31
35
|
TOKEN_HEADERS['Grove-Mattermost-Token'] = process.env.GROVE_MATTERMOST_TOKEN;
|
|
36
|
+
|
|
32
37
|
// Per-user session isolation: scopes Redis session keys so multiple users
|
|
33
38
|
// on the shared 3-pod cluster never overwrite each other's auth tokens.
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
// If GROVE_SESSION_KEY is set in the environment and is not the placeholder
|
|
40
|
+
// value, use it directly. Otherwise auto-generate a stable random key the
|
|
41
|
+
// first time and persist it to ~/.grove-mcp/session-key so that it survives
|
|
42
|
+
// restarts without any manual configuration.
|
|
43
|
+
(function resolveSessionKey() {
|
|
44
|
+
const PLACEHOLDER = '<your-session-key-here>';
|
|
45
|
+
const envKey = process.env.GROVE_SESSION_KEY;
|
|
46
|
+
|
|
47
|
+
if (envKey && envKey !== PLACEHOLDER) {
|
|
48
|
+
TOKEN_HEADERS['X-Grove-Session-Key'] = envKey;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const keyFile = path.join(os.homedir(), '.grove-mcp', 'session-key');
|
|
53
|
+
try {
|
|
54
|
+
const stored = fs.readFileSync(keyFile, 'utf8').trim();
|
|
55
|
+
if (stored) {
|
|
56
|
+
TOKEN_HEADERS['X-Grove-Session-Key'] = stored;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
} catch (_) {
|
|
60
|
+
// File doesn't exist yet — generate below.
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const generated = crypto.randomBytes(16).toString('hex');
|
|
64
|
+
try {
|
|
65
|
+
fs.mkdirSync(path.dirname(keyFile), { recursive: true });
|
|
66
|
+
fs.writeFileSync(keyFile, generated, { mode: 0o600 });
|
|
67
|
+
} catch (err) {
|
|
68
|
+
process.stderr.write(
|
|
69
|
+
`grove-mcp-bridge: warning: could not persist session key to ${keyFile}: ${err.message}\n`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
TOKEN_HEADERS['X-Grove-Session-Key'] = generated;
|
|
73
|
+
})();
|
|
74
|
+
|
|
75
|
+
// Strip MCP spec fields that older Claude Desktop clients don't handle.
|
|
76
|
+
// FastMCP 3.x auto-generates outputSchema from Python return type annotations;
|
|
77
|
+
// Claude Desktop silently drops ALL tools when it sees this field (claude-code#25081).
|
|
78
|
+
function stripMcpCompat(raw) {
|
|
79
|
+
try {
|
|
80
|
+
const msg = JSON.parse(raw);
|
|
81
|
+
if (msg.result && Array.isArray(msg.result.tools)) {
|
|
82
|
+
msg.result.tools = msg.result.tools.map(function (t) {
|
|
83
|
+
// eslint-disable-next-line no-unused-vars
|
|
84
|
+
var outputSchema = t.outputSchema, rest = Object.assign({}, t);
|
|
85
|
+
delete rest.outputSchema;
|
|
86
|
+
return rest;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return JSON.stringify(msg);
|
|
90
|
+
} catch (_) {
|
|
91
|
+
return raw;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
38
94
|
|
|
39
95
|
let sessionPath = null;
|
|
40
96
|
let affinityCookie = null; // sticky-session cookie from SSE response headers
|
|
41
97
|
const pending = [];
|
|
42
98
|
|
|
43
|
-
// ── SSE connection
|
|
44
|
-
|
|
45
|
-
const sseReq = https.request(
|
|
46
|
-
{
|
|
47
|
-
hostname: HOSTNAME,
|
|
48
|
-
path: SSE_PATH,
|
|
49
|
-
method: 'GET',
|
|
50
|
-
headers: { Accept: 'text/event-stream' },
|
|
51
|
-
},
|
|
52
|
-
(res) => {
|
|
53
|
-
if (res.statusCode !== 200) {
|
|
54
|
-
process.stderr.write(
|
|
55
|
-
`grove-mcp-bridge: SSE connect failed (HTTP ${res.statusCode})\n`
|
|
56
|
-
);
|
|
57
|
-
process.exit(1);
|
|
58
|
-
}
|
|
99
|
+
// ── SSE connection with reconnect ───────────────────────────────────────────
|
|
59
100
|
|
|
60
|
-
|
|
61
|
-
const setCookie = res.headers['set-cookie'];
|
|
62
|
-
if (setCookie) {
|
|
63
|
-
const cookies = Array.isArray(setCookie) ? setCookie : [setCookie];
|
|
64
|
-
const parts = cookies.map((c) => c.split(';')[0].trim());
|
|
65
|
-
affinityCookie = parts.join('; ');
|
|
66
|
-
}
|
|
101
|
+
let reconnectDelay = 1000; // ms; doubles on each failure, capped at 30s
|
|
67
102
|
|
|
68
|
-
|
|
69
|
-
|
|
103
|
+
function connect() {
|
|
104
|
+
// Reset session state so stdin messages are queued until the new endpoint arrives
|
|
105
|
+
sessionPath = null;
|
|
106
|
+
affinityCookie = null;
|
|
70
107
|
|
|
71
|
-
|
|
72
|
-
|
|
108
|
+
const sseReq = https.request(
|
|
109
|
+
{
|
|
110
|
+
hostname: HOSTNAME,
|
|
111
|
+
path: SSE_PATH,
|
|
112
|
+
method: 'GET',
|
|
113
|
+
headers: { Accept: 'text/event-stream' },
|
|
114
|
+
},
|
|
115
|
+
(res) => {
|
|
116
|
+
if (res.statusCode !== 200) {
|
|
117
|
+
process.stderr.write(
|
|
118
|
+
`grove-mcp-bridge: SSE connect failed (HTTP ${res.statusCode}), retrying in ${reconnectDelay}ms\n`
|
|
119
|
+
);
|
|
120
|
+
res.on('error', () => {}); // prevent unhandled-error crash while draining
|
|
121
|
+
res.resume();
|
|
122
|
+
scheduleReconnect();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
73
125
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const line = buf.slice(0, nl).replace(/\r$/, '');
|
|
77
|
-
buf = buf.slice(nl + 1);
|
|
126
|
+
// Successful connection — reset backoff
|
|
127
|
+
reconnectDelay = 1000;
|
|
78
128
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
129
|
+
// Capture nginx sticky-session cookie so POSTs reach the same pod
|
|
130
|
+
const setCookie = res.headers['set-cookie'];
|
|
131
|
+
if (setCookie) {
|
|
132
|
+
const cookies = Array.isArray(setCookie) ? setCookie : [setCookie];
|
|
133
|
+
const parts = cookies.map((c) => c.split(';')[0].trim());
|
|
134
|
+
affinityCookie = parts.join('; ');
|
|
135
|
+
}
|
|
83
136
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
137
|
+
let buf = '';
|
|
138
|
+
let eventType = '';
|
|
139
|
+
|
|
140
|
+
res.on('data', (chunk) => {
|
|
141
|
+
buf += chunk.toString('utf8');
|
|
142
|
+
|
|
143
|
+
let nl;
|
|
144
|
+
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
145
|
+
const line = buf.slice(0, nl).replace(/\r$/, '');
|
|
146
|
+
buf = buf.slice(nl + 1);
|
|
147
|
+
|
|
148
|
+
if (line.startsWith('event:')) {
|
|
149
|
+
eventType = line.slice(6).trim();
|
|
150
|
+
} else if (line.startsWith('data:')) {
|
|
151
|
+
const raw = line.slice(5).trim();
|
|
152
|
+
|
|
153
|
+
if (eventType === 'endpoint') {
|
|
154
|
+
try {
|
|
155
|
+
sessionPath = JSON.parse(raw).uri;
|
|
156
|
+
} catch (_) {
|
|
157
|
+
// Fallback: server sent a plain path instead of JSON
|
|
158
|
+
sessionPath = raw;
|
|
159
|
+
}
|
|
160
|
+
// Flush any stdin lines that arrived before the endpoint was ready
|
|
161
|
+
for (const msg of pending) sendToServer(msg);
|
|
162
|
+
pending.length = 0;
|
|
163
|
+
} else if (eventType === 'message' && raw) {
|
|
164
|
+
// Strip modern MCP spec fields that cause Claude Desktop to silently
|
|
165
|
+
// drop all tools (anthropics/claude-code#25081): FastMCP 3.x emits
|
|
166
|
+
// outputSchema in tools/list which Claude Desktop doesn't understand.
|
|
167
|
+
process.stdout.write(stripMcpCompat(raw) + '\n');
|
|
90
168
|
}
|
|
91
|
-
// Flush any stdin lines that arrived before the endpoint was ready
|
|
92
|
-
for (const msg of pending) sendToServer(msg);
|
|
93
|
-
pending.length = 0;
|
|
94
|
-
} else if (eventType === 'message' && raw) {
|
|
95
|
-
// raw is already a serialised JSON-RPC object — write directly
|
|
96
|
-
process.stdout.write(raw + '\n');
|
|
97
|
-
}
|
|
98
169
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
170
|
+
eventType = '';
|
|
171
|
+
} else if (line === '') {
|
|
172
|
+
eventType = '';
|
|
173
|
+
}
|
|
102
174
|
}
|
|
103
|
-
}
|
|
104
|
-
});
|
|
175
|
+
});
|
|
105
176
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
177
|
+
res.on('end', () => {
|
|
178
|
+
process.stderr.write(`grove-mcp-bridge: SSE stream ended, reconnecting in ${reconnectDelay}ms\n`);
|
|
179
|
+
scheduleReconnect();
|
|
180
|
+
});
|
|
110
181
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
);
|
|
117
|
-
|
|
118
|
-
sseReq.on('error', (err) => {
|
|
119
|
-
process.stderr.write(
|
|
120
|
-
`grove-mcp-bridge: cannot reach ${HOSTNAME}: ${err.message}\n`
|
|
182
|
+
res.on('error', (err) => {
|
|
183
|
+
process.stderr.write(`grove-mcp-bridge: SSE error: ${err.message}, reconnecting in ${reconnectDelay}ms\n`);
|
|
184
|
+
scheduleReconnect();
|
|
185
|
+
});
|
|
186
|
+
}
|
|
121
187
|
);
|
|
122
|
-
process.exit(1);
|
|
123
|
-
});
|
|
124
188
|
|
|
125
|
-
sseReq.
|
|
189
|
+
sseReq.on('error', (err) => {
|
|
190
|
+
process.stderr.write(
|
|
191
|
+
`grove-mcp-bridge: cannot reach ${HOSTNAME}: ${err.message}, retrying in ${reconnectDelay}ms\n`
|
|
192
|
+
);
|
|
193
|
+
scheduleReconnect();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Server sends keepalive pings every 30s. If nothing arrives in 75s the
|
|
197
|
+
// connection is stale (server crash, network partition) — destroy and reconnect.
|
|
198
|
+
sseReq.setTimeout(75000);
|
|
199
|
+
sseReq.on('timeout', () => {
|
|
200
|
+
process.stderr.write(`grove-mcp-bridge: SSE idle timeout, reconnecting in ${reconnectDelay}ms\n`);
|
|
201
|
+
sseReq.destroy(); // triggers sseReq.on('error') or res.on('error') → scheduleReconnect()
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
sseReq.end();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function scheduleReconnect() {
|
|
208
|
+
// Null the session immediately so stdin messages are queued rather than
|
|
209
|
+
// fired at a dead endpoint during the reconnect delay window.
|
|
210
|
+
sessionPath = null;
|
|
211
|
+
affinityCookie = null;
|
|
212
|
+
setTimeout(() => {
|
|
213
|
+
connect();
|
|
214
|
+
}, reconnectDelay);
|
|
215
|
+
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
connect();
|
|
126
219
|
|
|
127
220
|
// ── stdin → POST ────────────────────────────────────────────────────────────
|
|
128
221
|
|