@opencoven/coven-code 0.0.1 → 0.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 +2 -1
- package/docs/CLI.md +65 -1
- package/docs/DEMO.md +450 -0
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/README.md +1 -0
- package/package.json +7 -6
- package/src/agent/{local.mjs → fixture.mjs} +1 -1
- package/src/cli/execute.mjs +6 -4
- package/src/cli/interactive-core.mjs +5 -279
- package/src/cli/interactive-io.mjs +101 -0
- package/src/cli/interactive-slash.mjs +184 -0
- package/src/cli/repl.mjs +1 -2
- package/src/cli/tui-actions.mjs +72 -0
- package/src/cli/tui-blessed.mjs +198 -0
- package/src/cli/tui-keys.mjs +80 -0
- package/src/cli/tui-lane.mjs +73 -0
- package/src/cli/tui-render.mjs +169 -0
- package/src/cli/tui-submit.mjs +82 -0
- package/src/cli/tui.mjs +30 -613
- package/src/commands/permissions-eval.mjs +122 -0
- package/src/commands/permissions-rules.mjs +53 -0
- package/src/commands/permissions-text.mjs +112 -0
- package/src/commands/permissions.mjs +15 -281
- package/src/commands/usage.mjs +1 -1
- package/src/constants.mjs +7 -1
- package/src/mcp/local.mjs +55 -0
- package/src/mcp/parsers.mjs +46 -0
- package/src/mcp/probe.mjs +12 -351
- package/src/mcp/remote-oauth.mjs +55 -0
- package/src/mcp/remote-session.mjs +54 -0
- package/src/mcp/remote-sse.mjs +82 -0
- package/src/mcp/remote.mjs +74 -0
- package/src/plugins/api.mjs +187 -0
- package/src/plugins/configuration.mjs +124 -0
- package/src/plugins/discover.mjs +8 -804
- package/src/plugins/helpers.mjs +187 -0
- package/src/plugins/subsystems.mjs +198 -0
- package/src/plugins/validators.mjs +142 -0
- package/src/sdk-execute.mjs +82 -0
- package/src/sdk-settings.mjs +88 -0
- package/src/sdk.mjs +13 -164
- package/src/tools/builtin/oracle.mjs +2 -2
- package/src/tools/builtin/runtime-content.mjs +31 -0
- package/src/tools/builtin/runtime-decisions.mjs +115 -0
- package/src/tools/builtin/runtime.mjs +18 -148
- package/src/tools/builtin/task.mjs +2 -2
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export function parseMcpCallOutput(stdout = '') {
|
|
2
|
+
for (const line of stdout.split(/\r?\n/).filter(Boolean)) {
|
|
3
|
+
try {
|
|
4
|
+
const message = JSON.parse(line);
|
|
5
|
+
const content = message.result?.content ?? message.content;
|
|
6
|
+
if (Array.isArray(content)) {
|
|
7
|
+
return content.map((entry) => entry.text ?? entry.content ?? JSON.stringify(entry)).join('\n');
|
|
8
|
+
}
|
|
9
|
+
if (typeof content === 'string') return content;
|
|
10
|
+
if (message.result !== undefined) return JSON.stringify(message.result);
|
|
11
|
+
} catch {
|
|
12
|
+
// Non-JSON diagnostic output from MCP server startup is ignored.
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return '';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseMcpResourceOutput(stdout = '') {
|
|
19
|
+
for (const line of stdout.split(/\r?\n/).filter(Boolean)) {
|
|
20
|
+
try {
|
|
21
|
+
const message = JSON.parse(line);
|
|
22
|
+
const contents = message.result?.contents ?? message.contents;
|
|
23
|
+
if (Array.isArray(contents)) {
|
|
24
|
+
return contents.map((entry) => entry.text ?? entry.content ?? entry.blob ?? JSON.stringify(entry)).join('\n');
|
|
25
|
+
}
|
|
26
|
+
if (typeof contents === 'string') return contents;
|
|
27
|
+
if (message.result !== undefined) return JSON.stringify(message.result);
|
|
28
|
+
} catch {
|
|
29
|
+
// Non-JSON diagnostic output from MCP server startup is ignored.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return '';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function parseMcpToolsOutput(stdout = '') {
|
|
36
|
+
for (const line of stdout.split(/\r?\n/).filter(Boolean)) {
|
|
37
|
+
try {
|
|
38
|
+
const message = JSON.parse(line);
|
|
39
|
+
if (Array.isArray(message.result?.tools)) return message.result.tools;
|
|
40
|
+
if (Array.isArray(message.tools)) return message.tools;
|
|
41
|
+
} catch {
|
|
42
|
+
// Non-JSON diagnostic output from MCP server startup is ignored.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return [];
|
|
46
|
+
}
|
package/src/mcp/probe.mjs
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import { readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import os from 'node:os';
|
|
4
|
-
import path from 'node:path';
|
|
5
1
|
import { globMatch } from '../util/glob.mjs';
|
|
6
|
-
|
|
7
|
-
|
|
2
|
+
import {
|
|
3
|
+
callLocalMcpTool,
|
|
4
|
+
queryLocalMcpTools,
|
|
5
|
+
queryLocalMcpToolsResult,
|
|
6
|
+
readLocalMcpResource,
|
|
7
|
+
} from './local.mjs';
|
|
8
|
+
import { parseMcpToolsOutput } from './parsers.mjs';
|
|
9
|
+
import {
|
|
10
|
+
callRemoteMcpTool,
|
|
11
|
+
queryRemoteMcpTools,
|
|
12
|
+
readRemoteMcpResource,
|
|
13
|
+
} from './remote.mjs';
|
|
8
14
|
|
|
9
15
|
export async function discoverMcpToolRows(servers) {
|
|
10
16
|
const rows = [];
|
|
@@ -55,20 +61,6 @@ async function queryMcpTools(config = {}, serverName = '') {
|
|
|
55
61
|
return queryLocalMcpTools(config);
|
|
56
62
|
}
|
|
57
63
|
|
|
58
|
-
function queryLocalMcpTools(config = {}) {
|
|
59
|
-
return parseMcpToolsOutput(queryLocalMcpToolsResult(config).stdout);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function queryLocalMcpToolsResult(config = {}) {
|
|
63
|
-
if (!config.command) return [];
|
|
64
|
-
return spawnSync(config.command, config.args ?? [], {
|
|
65
|
-
input: localMcpRequestInput('tools/list', {}),
|
|
66
|
-
env: { ...process.env, ...(config.env ?? {}) },
|
|
67
|
-
encoding: 'utf8',
|
|
68
|
-
timeout: 1500,
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
|
|
72
64
|
export async function callMcpTool(config = {}, name, args = {}, serverName = '') {
|
|
73
65
|
if (config.url) return callRemoteMcpTool(config, name, args, serverName);
|
|
74
66
|
return callLocalMcpTool(config, name, args);
|
|
@@ -79,337 +71,6 @@ export async function readMcpResource(config = {}, uri, serverName = '') {
|
|
|
79
71
|
return readLocalMcpResource(config, uri);
|
|
80
72
|
}
|
|
81
73
|
|
|
82
|
-
function callLocalMcpTool(config = {}, name, args = {}) {
|
|
83
|
-
if (!config.command) return '';
|
|
84
|
-
const result = spawnSync(config.command, config.args ?? [], {
|
|
85
|
-
input: localMcpRequestInput('tools/call', { name, arguments: args }),
|
|
86
|
-
env: { ...process.env, ...(config.env ?? {}) },
|
|
87
|
-
encoding: 'utf8',
|
|
88
|
-
timeout: 1500,
|
|
89
|
-
});
|
|
90
|
-
return parseMcpCallOutput(result.stdout);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function readLocalMcpResource(config = {}, uri) {
|
|
94
|
-
if (!config.command) return '';
|
|
95
|
-
const result = spawnSync(config.command, config.args ?? [], {
|
|
96
|
-
input: localMcpRequestInput('resources/read', { uri }),
|
|
97
|
-
env: { ...process.env, ...(config.env ?? {}) },
|
|
98
|
-
encoding: 'utf8',
|
|
99
|
-
timeout: 1500,
|
|
100
|
-
});
|
|
101
|
-
return parseMcpResourceOutput(result.stdout);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function localMcpRequestInput(method, params = {}) {
|
|
105
|
-
return [
|
|
106
|
-
{
|
|
107
|
-
jsonrpc: '2.0',
|
|
108
|
-
id: 1,
|
|
109
|
-
method: 'initialize',
|
|
110
|
-
params: {
|
|
111
|
-
protocolVersion: '2025-06-18',
|
|
112
|
-
capabilities: {},
|
|
113
|
-
clientInfo: { name: 'coven-code', version: '0.0.0' },
|
|
114
|
-
},
|
|
115
|
-
},
|
|
116
|
-
{ jsonrpc: '2.0', method: 'notifications/initialized' },
|
|
117
|
-
{ jsonrpc: '2.0', id: 2, method, params },
|
|
118
|
-
].map((message) => JSON.stringify(message)).join('\n') + '\n';
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
async function queryRemoteMcpTools(config = {}, serverName = '') {
|
|
122
|
-
const message = await postRemoteMcp(config, 'tools/list', {}, serverName);
|
|
123
|
-
if (Array.isArray(message.result?.tools)) return message.result.tools;
|
|
124
|
-
if (Array.isArray(message.tools)) return message.tools;
|
|
125
|
-
return [];
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
async function callRemoteMcpTool(config = {}, name, args = {}, serverName = '') {
|
|
129
|
-
const message = await postRemoteMcp(config, 'tools/call', { name, arguments: args }, serverName);
|
|
130
|
-
return parseMcpCallOutput(`${JSON.stringify(message)}\n`);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
async function readRemoteMcpResource(config = {}, uri, serverName = '') {
|
|
134
|
-
const message = await postRemoteMcp(config, 'resources/read', { uri }, serverName);
|
|
135
|
-
return parseMcpResourceOutput(`${JSON.stringify(message)}\n`);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
async function postRemoteMcp(config = {}, method, params, serverName = '') {
|
|
139
|
-
const controller = new AbortController();
|
|
140
|
-
const timeout = setTimeout(() => controller.abort(), 1500);
|
|
141
|
-
const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method, params });
|
|
142
|
-
try {
|
|
143
|
-
if (config.transport === 'sse') {
|
|
144
|
-
return await postLegacySseMcp(config, body, controller.signal, serverName);
|
|
145
|
-
}
|
|
146
|
-
const response = await fetch(config.url, {
|
|
147
|
-
method: 'POST',
|
|
148
|
-
headers: remoteMcpHeaders(config, 'application/json, text/event-stream', serverName),
|
|
149
|
-
body,
|
|
150
|
-
signal: controller.signal,
|
|
151
|
-
});
|
|
152
|
-
rememberRemoteMcpSession(config, serverName, response);
|
|
153
|
-
if (response.status === 400 && !remoteMcpSessions.has(remoteMcpSessionKey(config, serverName)) && await initializeRemoteMcpSession(config, serverName, controller.signal)) {
|
|
154
|
-
const retry = await fetch(config.url, {
|
|
155
|
-
method: 'POST',
|
|
156
|
-
headers: remoteMcpHeaders(config, 'application/json, text/event-stream', serverName),
|
|
157
|
-
body,
|
|
158
|
-
signal: controller.signal,
|
|
159
|
-
});
|
|
160
|
-
rememberRemoteMcpSession(config, serverName, retry);
|
|
161
|
-
return parseRemoteMcpResponse(await retry.text());
|
|
162
|
-
}
|
|
163
|
-
if (response.status === 401 && await refreshMcpOauthToken(serverName, config)) {
|
|
164
|
-
const retry = await fetch(config.url, {
|
|
165
|
-
method: 'POST',
|
|
166
|
-
headers: remoteMcpHeaders(config, 'application/json, text/event-stream', serverName),
|
|
167
|
-
body,
|
|
168
|
-
signal: controller.signal,
|
|
169
|
-
});
|
|
170
|
-
rememberRemoteMcpSession(config, serverName, retry);
|
|
171
|
-
return parseRemoteMcpResponse(await retry.text());
|
|
172
|
-
}
|
|
173
|
-
if (response.status >= 400 && response.status < 500) {
|
|
174
|
-
return await postLegacySseMcp(config, body, controller.signal, serverName);
|
|
175
|
-
}
|
|
176
|
-
const text = await response.text();
|
|
177
|
-
return parseRemoteMcpResponse(text);
|
|
178
|
-
} catch {
|
|
179
|
-
return {};
|
|
180
|
-
} finally {
|
|
181
|
-
clearTimeout(timeout);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
async function postLegacySseMcp(config = {}, body, signal, serverName = '') {
|
|
186
|
-
const endpoint = await discoverLegacySseEndpoint(config, signal, serverName);
|
|
187
|
-
if (!endpoint) return {};
|
|
188
|
-
const response = await fetch(endpoint, {
|
|
189
|
-
method: 'POST',
|
|
190
|
-
headers: remoteMcpHeaders(config, 'application/json, text/event-stream', serverName),
|
|
191
|
-
body,
|
|
192
|
-
signal,
|
|
193
|
-
});
|
|
194
|
-
rememberRemoteMcpSession(config, serverName, response);
|
|
195
|
-
if (response.status === 401 && await refreshMcpOauthToken(serverName, config)) {
|
|
196
|
-
const retry = await fetch(endpoint, {
|
|
197
|
-
method: 'POST',
|
|
198
|
-
headers: remoteMcpHeaders(config, 'application/json, text/event-stream', serverName),
|
|
199
|
-
body,
|
|
200
|
-
signal,
|
|
201
|
-
});
|
|
202
|
-
rememberRemoteMcpSession(config, serverName, retry);
|
|
203
|
-
return parseRemoteMcpResponse(await retry.text());
|
|
204
|
-
}
|
|
205
|
-
return parseRemoteMcpResponse(await response.text());
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
async function discoverLegacySseEndpoint(config = {}, signal, serverName = '') {
|
|
209
|
-
const response = await fetch(config.url, {
|
|
210
|
-
method: 'GET',
|
|
211
|
-
headers: remoteMcpHeaders(config, 'text/event-stream', serverName),
|
|
212
|
-
signal,
|
|
213
|
-
});
|
|
214
|
-
if (!response.ok) return '';
|
|
215
|
-
return resolveRemoteMcpUrl(config.url, parseLegacySseEndpoint(await response.text()));
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function remoteMcpHeaders(config = {}, accept, serverName = '') {
|
|
219
|
-
return {
|
|
220
|
-
'content-type': 'application/json',
|
|
221
|
-
accept,
|
|
222
|
-
...oauthMcpHeaders(serverName),
|
|
223
|
-
...remoteMcpSessionHeader(config, serverName),
|
|
224
|
-
...(config.headers ?? {}),
|
|
225
|
-
};
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
async function initializeRemoteMcpSession(config = {}, serverName = '', signal) {
|
|
229
|
-
const response = await fetch(config.url, {
|
|
230
|
-
method: 'POST',
|
|
231
|
-
headers: remoteMcpHeaders(config, 'application/json, text/event-stream', serverName),
|
|
232
|
-
body: JSON.stringify({
|
|
233
|
-
jsonrpc: '2.0',
|
|
234
|
-
id: 0,
|
|
235
|
-
method: 'initialize',
|
|
236
|
-
params: {
|
|
237
|
-
protocolVersion: '2025-06-18',
|
|
238
|
-
capabilities: {},
|
|
239
|
-
clientInfo: { name: 'coven-code', version: '0.0.0' },
|
|
240
|
-
},
|
|
241
|
-
}),
|
|
242
|
-
signal,
|
|
243
|
-
});
|
|
244
|
-
rememberRemoteMcpSession(config, serverName, response);
|
|
245
|
-
if (!response.ok || !remoteMcpSessions.has(remoteMcpSessionKey(config, serverName))) return false;
|
|
246
|
-
await fetch(config.url, {
|
|
247
|
-
method: 'POST',
|
|
248
|
-
headers: remoteMcpHeaders(config, 'application/json, text/event-stream', serverName),
|
|
249
|
-
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
|
|
250
|
-
signal,
|
|
251
|
-
});
|
|
252
|
-
return true;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
function rememberRemoteMcpSession(config = {}, serverName = '', response) {
|
|
256
|
-
const sessionId = response.headers.get('mcp-session-id');
|
|
257
|
-
if (sessionId) remoteMcpSessions.set(remoteMcpSessionKey(config, serverName), sessionId);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
function remoteMcpSessionHeader(config = {}, serverName = '') {
|
|
261
|
-
const sessionId = remoteMcpSessions.get(remoteMcpSessionKey(config, serverName));
|
|
262
|
-
return sessionId ? { 'Mcp-Session-Id': sessionId } : {};
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function remoteMcpSessionKey(config = {}, serverName = '') {
|
|
266
|
-
return `${serverName}\n${config.url ?? ''}`;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
function oauthMcpHeaders(serverName = '') {
|
|
270
|
-
const credential = readMcpOauthCredential(serverName);
|
|
271
|
-
return credential.accessToken || credential.access_token ? { Authorization: `Bearer ${credential.accessToken ?? credential.access_token}` } : {};
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
function readMcpOauthCredential(serverName = '') {
|
|
275
|
-
if (!serverName) return {};
|
|
276
|
-
try {
|
|
277
|
-
return JSON.parse(readFileSync(mcpOauthCredentialPath(serverName), 'utf8'));
|
|
278
|
-
} catch {
|
|
279
|
-
return {};
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
async function refreshMcpOauthToken(serverName = '', config = {}) {
|
|
284
|
-
if (hasExplicitAuthorizationHeader(config)) return false;
|
|
285
|
-
const credential = readMcpOauthCredential(serverName);
|
|
286
|
-
const refreshToken = credential.refreshToken ?? credential.refresh_token;
|
|
287
|
-
const tokenUrl = credential.tokenUrl ?? credential.token_url;
|
|
288
|
-
if (!refreshToken || !tokenUrl) return false;
|
|
289
|
-
const response = await fetch(tokenUrl, {
|
|
290
|
-
method: 'POST',
|
|
291
|
-
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
292
|
-
body: new URLSearchParams({
|
|
293
|
-
grant_type: 'refresh_token',
|
|
294
|
-
refresh_token: refreshToken,
|
|
295
|
-
...(credential.clientId || credential.client_id ? { client_id: credential.clientId ?? credential.client_id } : {}),
|
|
296
|
-
...(credential.clientSecret || credential.client_secret ? { client_secret: credential.clientSecret ?? credential.client_secret } : {}),
|
|
297
|
-
}),
|
|
298
|
-
});
|
|
299
|
-
if (!response.ok) return false;
|
|
300
|
-
const token = await response.json();
|
|
301
|
-
const nextCredential = {
|
|
302
|
-
...credential,
|
|
303
|
-
accessToken: token.access_token ?? token.accessToken ?? credential.accessToken,
|
|
304
|
-
refreshToken: token.refresh_token ?? token.refreshToken ?? credential.refreshToken,
|
|
305
|
-
...(token.expires_in || token.expiresIn ? { expiresAt: Date.now() + Number(token.expires_in ?? token.expiresIn) * 1000 } : {}),
|
|
306
|
-
};
|
|
307
|
-
writeFileSync(mcpOauthCredentialPath(serverName), `${JSON.stringify(nextCredential, null, 2)}\n`);
|
|
308
|
-
return Boolean(nextCredential.accessToken);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
function hasExplicitAuthorizationHeader(config = {}) {
|
|
312
|
-
return Object.keys(config.headers ?? {}).some((key) => key.toLowerCase() === 'authorization');
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
function mcpOauthCredentialPath(serverName) {
|
|
316
|
-
return path.join(os.homedir(), '.coven-code', 'oauth', `${serverName}.json`);
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
function parseLegacySseEndpoint(text = '') {
|
|
320
|
-
let event = 'message';
|
|
321
|
-
const data = [];
|
|
322
|
-
for (const line of text.split(/\r?\n/)) {
|
|
323
|
-
if (!line) {
|
|
324
|
-
if (event === 'endpoint' && data.length) return data.join('\n').trim();
|
|
325
|
-
event = 'message';
|
|
326
|
-
data.length = 0;
|
|
327
|
-
continue;
|
|
328
|
-
}
|
|
329
|
-
if (line.startsWith(':')) continue;
|
|
330
|
-
const separator = line.indexOf(':');
|
|
331
|
-
const field = separator === -1 ? line : line.slice(0, separator);
|
|
332
|
-
const value = separator === -1 ? '' : line.slice(separator + 1).replace(/^ /, '');
|
|
333
|
-
if (field === 'event') event = value;
|
|
334
|
-
if (field === 'data') data.push(value);
|
|
335
|
-
}
|
|
336
|
-
if (event === 'endpoint' && data.length) return data.join('\n').trim();
|
|
337
|
-
return '';
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
function resolveRemoteMcpUrl(base, endpoint) {
|
|
341
|
-
if (!endpoint) return '';
|
|
342
|
-
try {
|
|
343
|
-
return new URL(endpoint, base).href;
|
|
344
|
-
} catch {
|
|
345
|
-
return '';
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
function parseRemoteMcpResponse(text = '') {
|
|
350
|
-
for (const chunk of text.split(/\r?\n/).filter(Boolean)) {
|
|
351
|
-
const line = chunk.startsWith('data:') ? chunk.slice('data:'.length).trim() : chunk.trim();
|
|
352
|
-
if (!line || line === '[DONE]') continue;
|
|
353
|
-
try {
|
|
354
|
-
return JSON.parse(line);
|
|
355
|
-
} catch {
|
|
356
|
-
// Remote MCP servers can include diagnostic or event wrapper lines.
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
try {
|
|
360
|
-
return JSON.parse(text);
|
|
361
|
-
} catch {
|
|
362
|
-
return {};
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
function parseMcpCallOutput(stdout = '') {
|
|
367
|
-
for (const line of stdout.split(/\r?\n/).filter(Boolean)) {
|
|
368
|
-
try {
|
|
369
|
-
const message = JSON.parse(line);
|
|
370
|
-
const content = message.result?.content ?? message.content;
|
|
371
|
-
if (Array.isArray(content)) {
|
|
372
|
-
return content.map((entry) => entry.text ?? entry.content ?? JSON.stringify(entry)).join('\n');
|
|
373
|
-
}
|
|
374
|
-
if (typeof content === 'string') return content;
|
|
375
|
-
if (message.result !== undefined) return JSON.stringify(message.result);
|
|
376
|
-
} catch {
|
|
377
|
-
// Non-JSON diagnostic output from MCP server startup is ignored.
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
return '';
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
function parseMcpResourceOutput(stdout = '') {
|
|
384
|
-
for (const line of stdout.split(/\r?\n/).filter(Boolean)) {
|
|
385
|
-
try {
|
|
386
|
-
const message = JSON.parse(line);
|
|
387
|
-
const contents = message.result?.contents ?? message.contents;
|
|
388
|
-
if (Array.isArray(contents)) {
|
|
389
|
-
return contents.map((entry) => entry.text ?? entry.content ?? entry.blob ?? JSON.stringify(entry)).join('\n');
|
|
390
|
-
}
|
|
391
|
-
if (typeof contents === 'string') return contents;
|
|
392
|
-
if (message.result !== undefined) return JSON.stringify(message.result);
|
|
393
|
-
} catch {
|
|
394
|
-
// Non-JSON diagnostic output from MCP server startup is ignored.
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
return '';
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
function parseMcpToolsOutput(stdout = '') {
|
|
401
|
-
for (const line of stdout.split(/\r?\n/).filter(Boolean)) {
|
|
402
|
-
try {
|
|
403
|
-
const message = JSON.parse(line);
|
|
404
|
-
if (Array.isArray(message.result?.tools)) return message.result.tools;
|
|
405
|
-
if (Array.isArray(message.tools)) return message.tools;
|
|
406
|
-
} catch {
|
|
407
|
-
// Non-JSON diagnostic output from MCP server startup is ignored.
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
return [];
|
|
411
|
-
}
|
|
412
|
-
|
|
413
74
|
export function skillMcpTools(config = {}) {
|
|
414
75
|
return (config.includeTools ?? []).map((name) => ({
|
|
415
76
|
name,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export function oauthMcpHeaders(serverName = '') {
|
|
6
|
+
const credential = readMcpOauthCredential(serverName);
|
|
7
|
+
return credential.accessToken || credential.access_token ? { Authorization: `Bearer ${credential.accessToken ?? credential.access_token}` } : {};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function readMcpOauthCredential(serverName = '') {
|
|
11
|
+
if (!serverName) return {};
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(readFileSync(mcpOauthCredentialPath(serverName), 'utf8'));
|
|
14
|
+
} catch {
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function refreshMcpOauthToken(serverName = '', config = {}) {
|
|
20
|
+
if (hasExplicitAuthorizationHeader(config)) return false;
|
|
21
|
+
const credential = readMcpOauthCredential(serverName);
|
|
22
|
+
const refreshToken = credential.refreshToken ?? credential.refresh_token;
|
|
23
|
+
const tokenUrl = credential.tokenUrl ?? credential.token_url;
|
|
24
|
+
if (!refreshToken || !tokenUrl) return false;
|
|
25
|
+
const response = await fetch(tokenUrl, {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
28
|
+
body: new URLSearchParams({
|
|
29
|
+
grant_type: 'refresh_token',
|
|
30
|
+
refresh_token: refreshToken,
|
|
31
|
+
...(credential.clientId || credential.client_id ? { client_id: credential.clientId ?? credential.client_id } : {}),
|
|
32
|
+
...(credential.clientSecret || credential.client_secret ? { client_secret: credential.clientSecret ?? credential.client_secret } : {}),
|
|
33
|
+
}),
|
|
34
|
+
});
|
|
35
|
+
if (!response.ok) return false;
|
|
36
|
+
const token = await response.json();
|
|
37
|
+
const nextCredential = {
|
|
38
|
+
...credential,
|
|
39
|
+
accessToken: token.access_token ?? token.accessToken ?? credential.accessToken,
|
|
40
|
+
refreshToken: token.refresh_token ?? token.refreshToken ?? credential.refreshToken,
|
|
41
|
+
...(token.expires_in || token.expiresIn ? { expiresAt: Date.now() + Number(token.expires_in ?? token.expiresIn) * 1000 } : {}),
|
|
42
|
+
};
|
|
43
|
+
const credentialPath = mcpOauthCredentialPath(serverName);
|
|
44
|
+
mkdirSync(path.dirname(credentialPath), { recursive: true, mode: 0o700 });
|
|
45
|
+
writeFileSync(credentialPath, `${JSON.stringify(nextCredential, null, 2)}\n`, { mode: 0o600 });
|
|
46
|
+
return Boolean(nextCredential.accessToken);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function hasExplicitAuthorizationHeader(config = {}) {
|
|
50
|
+
return Object.keys(config.headers ?? {}).some((key) => key.toLowerCase() === 'authorization');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function mcpOauthCredentialPath(serverName) {
|
|
54
|
+
return path.join(os.homedir(), '.coven-code', 'oauth', `${serverName}.json`);
|
|
55
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { oauthMcpHeaders } from './remote-oauth.mjs';
|
|
2
|
+
|
|
3
|
+
export const remoteMcpSessions = new Map();
|
|
4
|
+
|
|
5
|
+
export function remoteMcpHeaders(config = {}, accept, serverName = '') {
|
|
6
|
+
return {
|
|
7
|
+
'content-type': 'application/json',
|
|
8
|
+
accept,
|
|
9
|
+
...oauthMcpHeaders(serverName),
|
|
10
|
+
...remoteMcpSessionHeader(config, serverName),
|
|
11
|
+
...(config.headers ?? {}),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function initializeRemoteMcpSession(config = {}, serverName = '', signal) {
|
|
16
|
+
const response = await fetch(config.url, {
|
|
17
|
+
method: 'POST',
|
|
18
|
+
headers: remoteMcpHeaders(config, 'application/json, text/event-stream', serverName),
|
|
19
|
+
body: JSON.stringify({
|
|
20
|
+
jsonrpc: '2.0',
|
|
21
|
+
id: 0,
|
|
22
|
+
method: 'initialize',
|
|
23
|
+
params: {
|
|
24
|
+
protocolVersion: '2025-06-18',
|
|
25
|
+
capabilities: {},
|
|
26
|
+
clientInfo: { name: 'coven-code', version: '0.0.0' },
|
|
27
|
+
},
|
|
28
|
+
}),
|
|
29
|
+
signal,
|
|
30
|
+
});
|
|
31
|
+
rememberRemoteMcpSession(config, serverName, response);
|
|
32
|
+
if (!response.ok || !remoteMcpSessions.has(remoteMcpSessionKey(config, serverName))) return false;
|
|
33
|
+
await fetch(config.url, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: remoteMcpHeaders(config, 'application/json, text/event-stream', serverName),
|
|
36
|
+
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
|
|
37
|
+
signal,
|
|
38
|
+
});
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function rememberRemoteMcpSession(config = {}, serverName = '', response) {
|
|
43
|
+
const sessionId = response.headers.get('mcp-session-id');
|
|
44
|
+
if (sessionId) remoteMcpSessions.set(remoteMcpSessionKey(config, serverName), sessionId);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function remoteMcpSessionHeader(config = {}, serverName = '') {
|
|
48
|
+
const sessionId = remoteMcpSessions.get(remoteMcpSessionKey(config, serverName));
|
|
49
|
+
return sessionId ? { 'Mcp-Session-Id': sessionId } : {};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function remoteMcpSessionKey(config = {}, serverName = '') {
|
|
53
|
+
return `${serverName}\n${config.url ?? ''}`;
|
|
54
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { refreshMcpOauthToken } from './remote-oauth.mjs';
|
|
2
|
+
import { rememberRemoteMcpSession, remoteMcpHeaders } from './remote-session.mjs';
|
|
3
|
+
|
|
4
|
+
export async function postLegacySseMcp(config = {}, body, signal, serverName = '') {
|
|
5
|
+
const endpoint = await discoverLegacySseEndpoint(config, signal, serverName);
|
|
6
|
+
if (!endpoint) return {};
|
|
7
|
+
const response = await fetch(endpoint, {
|
|
8
|
+
method: 'POST',
|
|
9
|
+
headers: remoteMcpHeaders(config, 'application/json, text/event-stream', serverName),
|
|
10
|
+
body,
|
|
11
|
+
signal,
|
|
12
|
+
});
|
|
13
|
+
rememberRemoteMcpSession(config, serverName, response);
|
|
14
|
+
if (response.status === 401 && await refreshMcpOauthToken(serverName, config)) {
|
|
15
|
+
const retry = await fetch(endpoint, {
|
|
16
|
+
method: 'POST',
|
|
17
|
+
headers: remoteMcpHeaders(config, 'application/json, text/event-stream', serverName),
|
|
18
|
+
body,
|
|
19
|
+
signal,
|
|
20
|
+
});
|
|
21
|
+
rememberRemoteMcpSession(config, serverName, retry);
|
|
22
|
+
return parseRemoteMcpResponse(await retry.text());
|
|
23
|
+
}
|
|
24
|
+
return parseRemoteMcpResponse(await response.text());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function discoverLegacySseEndpoint(config = {}, signal, serverName = '') {
|
|
28
|
+
const response = await fetch(config.url, {
|
|
29
|
+
method: 'GET',
|
|
30
|
+
headers: remoteMcpHeaders(config, 'text/event-stream', serverName),
|
|
31
|
+
signal,
|
|
32
|
+
});
|
|
33
|
+
if (!response.ok) return '';
|
|
34
|
+
return resolveRemoteMcpUrl(config.url, parseLegacySseEndpoint(await response.text()));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseLegacySseEndpoint(text = '') {
|
|
38
|
+
let event = 'message';
|
|
39
|
+
const data = [];
|
|
40
|
+
for (const line of text.split(/\r?\n/)) {
|
|
41
|
+
if (!line) {
|
|
42
|
+
if (event === 'endpoint' && data.length) return data.join('\n').trim();
|
|
43
|
+
event = 'message';
|
|
44
|
+
data.length = 0;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (line.startsWith(':')) continue;
|
|
48
|
+
const separator = line.indexOf(':');
|
|
49
|
+
const field = separator === -1 ? line : line.slice(0, separator);
|
|
50
|
+
const value = separator === -1 ? '' : line.slice(separator + 1).replace(/^ /, '');
|
|
51
|
+
if (field === 'event') event = value;
|
|
52
|
+
if (field === 'data') data.push(value);
|
|
53
|
+
}
|
|
54
|
+
if (event === 'endpoint' && data.length) return data.join('\n').trim();
|
|
55
|
+
return '';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function resolveRemoteMcpUrl(base, endpoint) {
|
|
59
|
+
if (!endpoint) return '';
|
|
60
|
+
try {
|
|
61
|
+
return new URL(endpoint, base).href;
|
|
62
|
+
} catch {
|
|
63
|
+
return '';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function parseRemoteMcpResponse(text = '') {
|
|
68
|
+
for (const chunk of text.split(/\r?\n/).filter(Boolean)) {
|
|
69
|
+
const line = chunk.startsWith('data:') ? chunk.slice('data:'.length).trim() : chunk.trim();
|
|
70
|
+
if (!line || line === '[DONE]') continue;
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(line);
|
|
73
|
+
} catch {
|
|
74
|
+
// Remote MCP servers can include diagnostic or event wrapper lines.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(text);
|
|
79
|
+
} catch {
|
|
80
|
+
return {};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { parseMcpCallOutput, parseMcpResourceOutput } from './parsers.mjs';
|
|
2
|
+
import { refreshMcpOauthToken } from './remote-oauth.mjs';
|
|
3
|
+
import {
|
|
4
|
+
initializeRemoteMcpSession,
|
|
5
|
+
rememberRemoteMcpSession,
|
|
6
|
+
remoteMcpHeaders,
|
|
7
|
+
remoteMcpSessionKey,
|
|
8
|
+
remoteMcpSessions,
|
|
9
|
+
} from './remote-session.mjs';
|
|
10
|
+
import { parseRemoteMcpResponse, postLegacySseMcp } from './remote-sse.mjs';
|
|
11
|
+
|
|
12
|
+
export async function queryRemoteMcpTools(config = {}, serverName = '') {
|
|
13
|
+
const message = await postRemoteMcp(config, 'tools/list', {}, serverName);
|
|
14
|
+
if (Array.isArray(message.result?.tools)) return message.result.tools;
|
|
15
|
+
if (Array.isArray(message.tools)) return message.tools;
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function callRemoteMcpTool(config = {}, name, args = {}, serverName = '') {
|
|
20
|
+
const message = await postRemoteMcp(config, 'tools/call', { name, arguments: args }, serverName);
|
|
21
|
+
return parseMcpCallOutput(`${JSON.stringify(message)}\n`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function readRemoteMcpResource(config = {}, uri, serverName = '') {
|
|
25
|
+
const message = await postRemoteMcp(config, 'resources/read', { uri }, serverName);
|
|
26
|
+
return parseMcpResourceOutput(`${JSON.stringify(message)}\n`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function postRemoteMcp(config = {}, method, params, serverName = '') {
|
|
30
|
+
const controller = new AbortController();
|
|
31
|
+
const timeout = setTimeout(() => controller.abort(), 1500);
|
|
32
|
+
const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method, params });
|
|
33
|
+
try {
|
|
34
|
+
if (config.transport === 'sse') {
|
|
35
|
+
return await postLegacySseMcp(config, body, controller.signal, serverName);
|
|
36
|
+
}
|
|
37
|
+
const response = await fetch(config.url, {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: remoteMcpHeaders(config, 'application/json, text/event-stream', serverName),
|
|
40
|
+
body,
|
|
41
|
+
signal: controller.signal,
|
|
42
|
+
});
|
|
43
|
+
rememberRemoteMcpSession(config, serverName, response);
|
|
44
|
+
if (response.status === 400 && !remoteMcpSessions.has(remoteMcpSessionKey(config, serverName)) && await initializeRemoteMcpSession(config, serverName, controller.signal)) {
|
|
45
|
+
const retry = await fetch(config.url, {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: remoteMcpHeaders(config, 'application/json, text/event-stream', serverName),
|
|
48
|
+
body,
|
|
49
|
+
signal: controller.signal,
|
|
50
|
+
});
|
|
51
|
+
rememberRemoteMcpSession(config, serverName, retry);
|
|
52
|
+
return parseRemoteMcpResponse(await retry.text());
|
|
53
|
+
}
|
|
54
|
+
if (response.status === 401 && await refreshMcpOauthToken(serverName, config)) {
|
|
55
|
+
const retry = await fetch(config.url, {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: remoteMcpHeaders(config, 'application/json, text/event-stream', serverName),
|
|
58
|
+
body,
|
|
59
|
+
signal: controller.signal,
|
|
60
|
+
});
|
|
61
|
+
rememberRemoteMcpSession(config, serverName, retry);
|
|
62
|
+
return parseRemoteMcpResponse(await retry.text());
|
|
63
|
+
}
|
|
64
|
+
if (response.status >= 400 && response.status < 500) {
|
|
65
|
+
return await postLegacySseMcp(config, body, controller.signal, serverName);
|
|
66
|
+
}
|
|
67
|
+
const text = await response.text();
|
|
68
|
+
return parseRemoteMcpResponse(text);
|
|
69
|
+
} catch {
|
|
70
|
+
return {};
|
|
71
|
+
} finally {
|
|
72
|
+
clearTimeout(timeout);
|
|
73
|
+
}
|
|
74
|
+
}
|