s-app-scoder-agent 0.0.0-stage → 0.3.0
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/LICENSE +21 -0
- package/README.md +368 -2
- package/dist/agent.js +478 -0
- package/dist/build.js +44 -0
- package/dist/config.js +93 -0
- package/dist/context.js +44 -0
- package/dist/diff.js +156 -0
- package/dist/index.js +285 -0
- package/dist/llm.js +266 -0
- package/dist/logger.js +78 -0
- package/dist/mcp.js +189 -0
- package/dist/permissions.js +277 -0
- package/dist/provider-anthropic.js +322 -0
- package/dist/retry.js +53 -0
- package/dist/session.js +143 -0
- package/dist/tools.js +356 -0
- package/dist/ui.js +96 -0
- package/dist/version.js +18 -0
- package/package.json +48 -3
package/dist/llm.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal OpenAI-compatible chat-completions client with function calling
|
|
3
|
+
* and optional SSE streaming.
|
|
4
|
+
*/
|
|
5
|
+
import { withRetry } from "./retry.js";
|
|
6
|
+
export class LlmError extends Error {
|
|
7
|
+
status;
|
|
8
|
+
retryAfterMs;
|
|
9
|
+
constructor(message, status, retryAfterMs) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "LlmError";
|
|
12
|
+
if (status !== undefined)
|
|
13
|
+
this.status = status;
|
|
14
|
+
if (retryAfterMs !== undefined)
|
|
15
|
+
this.retryAfterMs = retryAfterMs;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Build an LlmError from a failed Response, carrying status + Retry-After. */
|
|
19
|
+
async function toLlmError(res, url) {
|
|
20
|
+
let body = await res.text().catch(() => "");
|
|
21
|
+
// Redact Bearer token and raw key patterns to avoid leaking secrets in logs/errors
|
|
22
|
+
body = body.replace(/Bearer\s+[^\s,&\]]+/gi, "Bearer ***REDACTED***");
|
|
23
|
+
body = body.replace(/sk-[A-Za-z0-9_-]{8,}/g, "sk-***REDACTED***");
|
|
24
|
+
const err = new LlmError(`LLM API error ${res.status} at ${url}: ${body.slice(0, 500)}`, res.status);
|
|
25
|
+
// Only honor a REAL Retry-After value: Number("") === 0 would zero the backoff
|
|
26
|
+
const retryAfterHeader = res.headers.get("retry-after");
|
|
27
|
+
if (retryAfterHeader != null && retryAfterHeader !== "") {
|
|
28
|
+
const sec = Number(retryAfterHeader);
|
|
29
|
+
if (Number.isFinite(sec) && sec > 0)
|
|
30
|
+
err.retryAfterMs = sec * 1000;
|
|
31
|
+
}
|
|
32
|
+
return err;
|
|
33
|
+
}
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Request timeout / abort helpers
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
/** Default per-request timeout for LLM calls (a hung connection must not block the agent forever). */
|
|
38
|
+
export const LLM_REQUEST_TIMEOUT_MS = 120_000;
|
|
39
|
+
/**
|
|
40
|
+
* Combine an external signal with a request timeout.
|
|
41
|
+
* Returns a derived signal plus a cleanup fn (removes listeners).
|
|
42
|
+
* Works on Node >= 18 (no AbortSignal.any dependency).
|
|
43
|
+
*/
|
|
44
|
+
export function combinedSignal(external, timeoutMs) {
|
|
45
|
+
const ac = new AbortController();
|
|
46
|
+
const timer = setTimeout(() => ac.abort(new DOMException("LLM request timed out", "TimeoutError")), timeoutMs);
|
|
47
|
+
const onExternal = () => ac.abort(new DOMException("aborted", "AbortError"));
|
|
48
|
+
if (external) {
|
|
49
|
+
if (external.aborted)
|
|
50
|
+
ac.abort(external.reason);
|
|
51
|
+
else
|
|
52
|
+
external.addEventListener("abort", onExternal, { once: true });
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
signal: ac.signal,
|
|
56
|
+
cleanup: () => {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
external?.removeEventListener("abort", onExternal);
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// SSE parsing helpers
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
const SSE_PREFIX = "data: ";
|
|
66
|
+
const SSE_DONE = "[DONE]";
|
|
67
|
+
/** Parse SSE chunks from an OpenAI-compatible stream. */
|
|
68
|
+
function parseSSELine(line) {
|
|
69
|
+
if (!line.startsWith(SSE_PREFIX))
|
|
70
|
+
return null;
|
|
71
|
+
const data = line.slice(SSE_PREFIX.length);
|
|
72
|
+
if (data === SSE_DONE)
|
|
73
|
+
return null; // [DONE] signals end
|
|
74
|
+
return data;
|
|
75
|
+
}
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// LlmClient
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
export class LlmClient {
|
|
80
|
+
opts;
|
|
81
|
+
streamEnabled;
|
|
82
|
+
constructor(opts) {
|
|
83
|
+
this.opts = opts;
|
|
84
|
+
this.streamEnabled = opts.stream !== false; // default true
|
|
85
|
+
}
|
|
86
|
+
get model() {
|
|
87
|
+
return this.opts.model;
|
|
88
|
+
}
|
|
89
|
+
// ── Non-streaming ──────────────────────────────────────────────────────────
|
|
90
|
+
async chat(messages, tools, opts = {}) {
|
|
91
|
+
const url = `${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`;
|
|
92
|
+
const { signal, cleanup } = combinedSignal(opts.signal, LLM_REQUEST_TIMEOUT_MS);
|
|
93
|
+
const body = JSON.stringify({
|
|
94
|
+
model: this.opts.model,
|
|
95
|
+
messages,
|
|
96
|
+
tools: tools.length > 0 ? tools : undefined,
|
|
97
|
+
temperature: this.opts.temperature,
|
|
98
|
+
max_tokens: this.opts.maxTokens,
|
|
99
|
+
stream: false,
|
|
100
|
+
});
|
|
101
|
+
let res;
|
|
102
|
+
try {
|
|
103
|
+
res = await withRetry(async () => {
|
|
104
|
+
const r = await fetch(url, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.opts.apiKey}` },
|
|
107
|
+
body,
|
|
108
|
+
signal,
|
|
109
|
+
});
|
|
110
|
+
if (!r.ok)
|
|
111
|
+
throw await toLlmError(r, url);
|
|
112
|
+
return r;
|
|
113
|
+
}, {}, (attempt, delay, reason) => console.error(`⚠ LLM request failed (${reason}), retrying in ${(delay / 1000).toFixed(1)}s (attempt ${attempt}/3)...`));
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
cleanup();
|
|
117
|
+
}
|
|
118
|
+
const data = (await res.json());
|
|
119
|
+
const choice = data.choices?.[0]?.message;
|
|
120
|
+
if (!choice)
|
|
121
|
+
throw new LlmError("LLM API returned no message in response");
|
|
122
|
+
return {
|
|
123
|
+
message: {
|
|
124
|
+
role: "assistant",
|
|
125
|
+
content: choice.content ?? null,
|
|
126
|
+
stop_reason: choice.stop_reason ?? (choice.content ? "stop" : undefined),
|
|
127
|
+
tool_calls: choice.tool_calls?.map((tc) => ({
|
|
128
|
+
id: tc.id ?? "",
|
|
129
|
+
name: tc.function?.name ?? "",
|
|
130
|
+
arguments: tc.function?.arguments ?? "",
|
|
131
|
+
})),
|
|
132
|
+
},
|
|
133
|
+
usage: data.usage,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
// ── Streaming ───────────────────────────────────────────────────────────────
|
|
137
|
+
/**
|
|
138
|
+
* Stream chat completions, yielding incremental chunks.
|
|
139
|
+
* Falls back to non-streaming on stream-related errors.
|
|
140
|
+
*/
|
|
141
|
+
async *streamChat(messages, tools, opts = {}) {
|
|
142
|
+
const url = `${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`;
|
|
143
|
+
const { signal, cleanup } = combinedSignal(opts.signal, LLM_REQUEST_TIMEOUT_MS);
|
|
144
|
+
const bodyStr = JSON.stringify({
|
|
145
|
+
model: this.opts.model,
|
|
146
|
+
messages,
|
|
147
|
+
tools: tools.length > 0 ? tools : undefined,
|
|
148
|
+
temperature: this.opts.temperature,
|
|
149
|
+
max_tokens: this.opts.maxTokens,
|
|
150
|
+
stream: true,
|
|
151
|
+
});
|
|
152
|
+
let res;
|
|
153
|
+
try {
|
|
154
|
+
res = await withRetry(async () => {
|
|
155
|
+
const r = await fetch(url, {
|
|
156
|
+
method: "POST",
|
|
157
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.opts.apiKey}` },
|
|
158
|
+
body: bodyStr,
|
|
159
|
+
signal,
|
|
160
|
+
});
|
|
161
|
+
if (!r.ok)
|
|
162
|
+
throw await toLlmError(r, url);
|
|
163
|
+
return r;
|
|
164
|
+
}, {}, (attempt, delay, reason) => console.error(`⚠ LLM stream request failed (${reason}), retrying in ${(delay / 1000).toFixed(1)}s...`));
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
cleanup(); // don't leak the 120s request timer — it would hang the process
|
|
168
|
+
throw new LlmError(`Stream fetch error after retries at ${url}: ${err.message}`);
|
|
169
|
+
}
|
|
170
|
+
if (!res.body) {
|
|
171
|
+
throw new LlmError("Response body is not readable (not a streaming response)");
|
|
172
|
+
}
|
|
173
|
+
// Accumulators for tool_calls across chunks
|
|
174
|
+
const accumulatedToolCalls = [];
|
|
175
|
+
let finalResult;
|
|
176
|
+
const reader = res.body.getReader();
|
|
177
|
+
const decoder = new TextDecoder();
|
|
178
|
+
let buffer = "";
|
|
179
|
+
try {
|
|
180
|
+
while (true) {
|
|
181
|
+
const { done, value } = await reader.read();
|
|
182
|
+
if (done)
|
|
183
|
+
break;
|
|
184
|
+
buffer += decoder.decode(value, { stream: true });
|
|
185
|
+
// Process complete lines
|
|
186
|
+
const lines = buffer.split("\n");
|
|
187
|
+
buffer = lines.pop() ?? ""; // keep incomplete line in buffer
|
|
188
|
+
for (const rawLine of lines) {
|
|
189
|
+
const json = parseSSELine(rawLine);
|
|
190
|
+
if (!json)
|
|
191
|
+
continue;
|
|
192
|
+
let chunkData;
|
|
193
|
+
try {
|
|
194
|
+
chunkData = JSON.parse(json);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
continue; // skip malformed JSON lines
|
|
198
|
+
}
|
|
199
|
+
const choice = chunkData.choices?.[0];
|
|
200
|
+
if (!choice)
|
|
201
|
+
continue;
|
|
202
|
+
const delta = choice.delta;
|
|
203
|
+
// Text content delta
|
|
204
|
+
if (delta?.content != null && delta.content.length > 0) {
|
|
205
|
+
yield { contentDelta: delta.content, done: false };
|
|
206
|
+
}
|
|
207
|
+
// Tool call deltas
|
|
208
|
+
if (delta?.tool_calls) {
|
|
209
|
+
for (const tc of delta.tool_calls) {
|
|
210
|
+
const idx = tc.index ?? 0;
|
|
211
|
+
if (!accumulatedToolCalls[idx]) {
|
|
212
|
+
accumulatedToolCalls[idx] = { arguments: "" };
|
|
213
|
+
}
|
|
214
|
+
if (tc.id)
|
|
215
|
+
accumulatedToolCalls[idx].id = tc.id;
|
|
216
|
+
if (tc.function?.name)
|
|
217
|
+
accumulatedToolCalls[idx].name = tc.function.name;
|
|
218
|
+
if (tc.function?.arguments) {
|
|
219
|
+
accumulatedToolCalls[idx].arguments += tc.function.arguments;
|
|
220
|
+
}
|
|
221
|
+
yield {
|
|
222
|
+
toolCallDelta: {
|
|
223
|
+
index: idx,
|
|
224
|
+
id: tc.id,
|
|
225
|
+
name: tc.function?.name,
|
|
226
|
+
argumentsDelta: tc.function?.arguments,
|
|
227
|
+
},
|
|
228
|
+
done: false,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// Final chunk
|
|
233
|
+
if (choice.finish_reason) {
|
|
234
|
+
// Collect final tool_calls
|
|
235
|
+
const toolCalls = accumulatedToolCalls
|
|
236
|
+
.filter((tc) => tc.name)
|
|
237
|
+
.map((tc) => ({
|
|
238
|
+
id: tc.id ?? "",
|
|
239
|
+
name: tc.name ?? "",
|
|
240
|
+
arguments: tc.arguments,
|
|
241
|
+
}));
|
|
242
|
+
finalResult = {
|
|
243
|
+
message: {
|
|
244
|
+
role: "assistant",
|
|
245
|
+
content: null,
|
|
246
|
+
stop_reason: choice.finish_reason,
|
|
247
|
+
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
248
|
+
},
|
|
249
|
+
usage: chunkData.usage,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
finally {
|
|
256
|
+
// Cancel the body (frees the socket) and clear the request timeout
|
|
257
|
+
try {
|
|
258
|
+
reader.cancel();
|
|
259
|
+
}
|
|
260
|
+
catch { /* already released */ }
|
|
261
|
+
reader.releaseLock();
|
|
262
|
+
cleanup();
|
|
263
|
+
}
|
|
264
|
+
yield { done: true, final: finalResult };
|
|
265
|
+
}
|
|
266
|
+
}
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured JSONL session logging.
|
|
3
|
+
* One JSON object per line — parseable with jq or any log consumer.
|
|
4
|
+
* Never crashes the agent: write failures are swallowed after one warning.
|
|
5
|
+
*/
|
|
6
|
+
import { mkdirSync, appendFileSync, readFileSync } from "node:fs";
|
|
7
|
+
import { dirname, resolve } from "node:path";
|
|
8
|
+
export class JsonlLogger {
|
|
9
|
+
filePath;
|
|
10
|
+
warned = false;
|
|
11
|
+
constructor(path) {
|
|
12
|
+
this.filePath = path;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Create a logger for a session.
|
|
16
|
+
* logFile: true -> <workdir>/.scoder/logs/<sessionId>.jsonl
|
|
17
|
+
* string -> that exact path
|
|
18
|
+
* false/undefined -> disabled (no-op)
|
|
19
|
+
*/
|
|
20
|
+
static forSession(workdir, sessionId, logFile) {
|
|
21
|
+
if (logFile === undefined || logFile === false || logFile === null) {
|
|
22
|
+
return new JsonlLogger(null);
|
|
23
|
+
}
|
|
24
|
+
const path = typeof logFile === "string" && logFile.length > 0
|
|
25
|
+
? resolve(logFile)
|
|
26
|
+
: resolve(workdir, ".scoder/logs", `${sessionId}.jsonl`);
|
|
27
|
+
try {
|
|
28
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
29
|
+
return new JsonlLogger(path);
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
console.error(`⚠ logging disabled (cannot create log dir): ${err.message}`);
|
|
33
|
+
return new JsonlLogger(null);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
get enabled() {
|
|
37
|
+
return this.filePath !== null;
|
|
38
|
+
}
|
|
39
|
+
/** Read a session's JSONL log file, returning parsed events (or null if missing/unreadable). */
|
|
40
|
+
static read(workdir, sessionId) {
|
|
41
|
+
const path = resolve(workdir, ".scoder/logs", `${sessionId}.jsonl`);
|
|
42
|
+
let content;
|
|
43
|
+
try {
|
|
44
|
+
content = readFileSync(path, "utf8");
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const events = [];
|
|
50
|
+
for (const line of content.split("\n")) {
|
|
51
|
+
if (!line.trim())
|
|
52
|
+
continue;
|
|
53
|
+
try {
|
|
54
|
+
events.push(JSON.parse(line));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// skip malformed lines
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return events;
|
|
61
|
+
}
|
|
62
|
+
/** Append one event. Never throws. */
|
|
63
|
+
log(event) {
|
|
64
|
+
if (!this.filePath)
|
|
65
|
+
return;
|
|
66
|
+
try {
|
|
67
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), ...event });
|
|
68
|
+
appendFileSync(this.filePath, line + "\n", "utf8");
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
if (!this.warned) {
|
|
72
|
+
this.warned = true;
|
|
73
|
+
console.error(`⚠ log write failed (${err.message}) — disabling session log`);
|
|
74
|
+
}
|
|
75
|
+
this.filePath = null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal MCP (Model Context Protocol) client for stdio servers.
|
|
3
|
+
*
|
|
4
|
+
* Implements JSON-RPC 2.0 over stdin/stdout with line-delimited messages.
|
|
5
|
+
* Connects to configured stdio servers, exposes their tools as
|
|
6
|
+
* `mcp__<server>__<tool>` prefixed names in the agent's tool registry.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { CLIENT_NAME, getVersion } from "./version.js";
|
|
10
|
+
export const MCP_TOOL_PREFIX = "mcp__";
|
|
11
|
+
export function mcpToolKey(serverName, toolName) {
|
|
12
|
+
return `${MCP_TOOL_PREFIX}${serverName}__${toolName}`;
|
|
13
|
+
}
|
|
14
|
+
/** Expand {env:VAR} references in config env values. */
|
|
15
|
+
function expandEnv(env) {
|
|
16
|
+
const out = {};
|
|
17
|
+
for (const [k, v] of Object.entries(env ?? {})) {
|
|
18
|
+
out[k] = v.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, n) => process.env[n] ?? "");
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
// ── JSON-RPC stdio connection ─────────────────────────────────────────────────
|
|
23
|
+
export class McpConnection {
|
|
24
|
+
name;
|
|
25
|
+
cfg;
|
|
26
|
+
proc = null;
|
|
27
|
+
nextId = 1;
|
|
28
|
+
pending = new Map();
|
|
29
|
+
buf = "";
|
|
30
|
+
constructor(name, cfg) {
|
|
31
|
+
this.name = name;
|
|
32
|
+
this.cfg = cfg;
|
|
33
|
+
}
|
|
34
|
+
get alive() {
|
|
35
|
+
return this.proc !== null && this.proc.exitCode === null;
|
|
36
|
+
}
|
|
37
|
+
async connect(timeoutMs = 15_000) {
|
|
38
|
+
const p = spawn(this.cfg.command, this.cfg.args ?? [], {
|
|
39
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
40
|
+
env: { ...process.env, ...expandEnv(this.cfg.env) },
|
|
41
|
+
});
|
|
42
|
+
this.proc = p;
|
|
43
|
+
p.stderr?.on("data", () => { });
|
|
44
|
+
p.on("error", (e) => this.drain(new Error(`spawn: ${e.message}`)));
|
|
45
|
+
p.on("exit", () => this.drain(new Error("process exited")));
|
|
46
|
+
p.stdout?.on("data", (c) => this.onChunk(c));
|
|
47
|
+
// MCP handshake: initialize + notifications/initialized
|
|
48
|
+
await this.rpc("initialize", {
|
|
49
|
+
protocolVersion: "2024-11-05",
|
|
50
|
+
capabilities: {},
|
|
51
|
+
clientInfo: { name: CLIENT_NAME, version: getVersion() },
|
|
52
|
+
}, timeoutMs);
|
|
53
|
+
this.send({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
54
|
+
}
|
|
55
|
+
async listTools(timeoutMs = 120_000) {
|
|
56
|
+
const res = await this.rpc("tools/list", {}, timeoutMs);
|
|
57
|
+
return (res.tools ?? []).filter((t) => typeof t.name === "string");
|
|
58
|
+
}
|
|
59
|
+
async callTool(name, args, timeoutMs = 120_000) {
|
|
60
|
+
const res = await this.rpc("tools/call", { name, arguments: args }, timeoutMs);
|
|
61
|
+
const isErr = res.isError === true;
|
|
62
|
+
const parts = (res.content ?? []).map((c) => c.text ?? "").filter(Boolean);
|
|
63
|
+
return { ok: !isErr, output: parts.join("\n") || "(empty result)" };
|
|
64
|
+
}
|
|
65
|
+
kill() {
|
|
66
|
+
this.drain(new Error("disconnected"));
|
|
67
|
+
if (this.proc) {
|
|
68
|
+
try {
|
|
69
|
+
this.proc.kill();
|
|
70
|
+
}
|
|
71
|
+
catch { /* already dead */ }
|
|
72
|
+
this.proc = null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// ── internals ─────────────────────────────────────────────────────────────
|
|
76
|
+
send(msg) {
|
|
77
|
+
if (!this.proc?.stdin?.writable)
|
|
78
|
+
return;
|
|
79
|
+
try {
|
|
80
|
+
this.proc.stdin.write(JSON.stringify(msg) + "\n");
|
|
81
|
+
}
|
|
82
|
+
catch { /* ignore */ }
|
|
83
|
+
}
|
|
84
|
+
async rpc(method, params, timeoutMs) {
|
|
85
|
+
if (!this.alive)
|
|
86
|
+
throw new Error(`MCP server "${this.name}" not connected`);
|
|
87
|
+
const id = this.nextId++;
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
const timer = setTimeout(() => {
|
|
90
|
+
this.pending.delete(id);
|
|
91
|
+
reject(new Error(`MCP "${method}" timed out after ${(timeoutMs / 1000).toFixed(0)}s`));
|
|
92
|
+
}, timeoutMs);
|
|
93
|
+
this.pending.set(id, {
|
|
94
|
+
resolve: (v) => { clearTimeout(timer); resolve(v); },
|
|
95
|
+
reject: (e) => { clearTimeout(timer); reject(e); },
|
|
96
|
+
});
|
|
97
|
+
this.send({ jsonrpc: "2.0", id, method, params });
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
onChunk(chunk) {
|
|
101
|
+
this.buf += chunk.toString("utf8");
|
|
102
|
+
let i;
|
|
103
|
+
while ((i = this.buf.indexOf("\n")) !== -1) {
|
|
104
|
+
const line = this.buf.slice(0, i).trim();
|
|
105
|
+
this.buf = this.buf.slice(i + 1);
|
|
106
|
+
if (!line)
|
|
107
|
+
continue;
|
|
108
|
+
let msg;
|
|
109
|
+
try {
|
|
110
|
+
msg = JSON.parse(line);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (msg.id === undefined)
|
|
116
|
+
continue;
|
|
117
|
+
const pending = this.pending.get(msg.id);
|
|
118
|
+
if (!pending)
|
|
119
|
+
continue;
|
|
120
|
+
this.pending.delete(msg.id);
|
|
121
|
+
if (msg.error)
|
|
122
|
+
pending.reject(new Error(`${msg.error.code}: ${msg.error.message}`));
|
|
123
|
+
else
|
|
124
|
+
pending.resolve(msg.result ?? {});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
drain(err) {
|
|
128
|
+
for (const [, p] of this.pending)
|
|
129
|
+
p.reject(err);
|
|
130
|
+
this.pending.clear();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
export class McpManager {
|
|
134
|
+
configs;
|
|
135
|
+
conns = new Map();
|
|
136
|
+
tools = new Map();
|
|
137
|
+
constructor(configs) {
|
|
138
|
+
this.configs = configs;
|
|
139
|
+
}
|
|
140
|
+
/** Connect all servers; skip failures with a warning. Returns discovered tool count. */
|
|
141
|
+
async connectAll() {
|
|
142
|
+
await Promise.allSettled(Object.entries(this.configs).map(async ([name, cfg]) => {
|
|
143
|
+
const conn = new McpConnection(name, cfg);
|
|
144
|
+
await conn.connect();
|
|
145
|
+
const defs = await conn.listTools();
|
|
146
|
+
for (const def of defs) {
|
|
147
|
+
const key = mcpToolKey(name, def.name);
|
|
148
|
+
if (this.tools.has(key))
|
|
149
|
+
throw new Error(`Duplicate MCP tool: ${key}`);
|
|
150
|
+
this.tools.set(key, { conn, serverName: name, toolName: def.name, def });
|
|
151
|
+
}
|
|
152
|
+
this.conns.set(name, conn);
|
|
153
|
+
})).then((results) => {
|
|
154
|
+
results.forEach((r, i) => {
|
|
155
|
+
const name = Object.keys(this.configs)[i];
|
|
156
|
+
if (r.status === "rejected") {
|
|
157
|
+
console.error(`⚠ MCP server "${name}" unavailable: ${String(r.reason?.message).slice(0, 160)}`);
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
const n = [...this.tools.values()].filter((t) => t.serverName === name).length;
|
|
161
|
+
console.error(`✓ MCP "${name}" connected — ${n} tool${n === 1 ? "" : "s"}`);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
return this.tools.size;
|
|
166
|
+
}
|
|
167
|
+
hasTool(name) {
|
|
168
|
+
return this.tools.has(name);
|
|
169
|
+
}
|
|
170
|
+
getTool(name) {
|
|
171
|
+
return this.tools.get(name);
|
|
172
|
+
}
|
|
173
|
+
/** Convert all discovered MCP tools to OpenAI function-calling specs. */
|
|
174
|
+
toToolSpecs() {
|
|
175
|
+
return [...this.tools.values()].map(({ conn: _, serverName, toolName, def }) => ({
|
|
176
|
+
type: "function",
|
|
177
|
+
function: {
|
|
178
|
+
name: mcpToolKey(serverName, toolName),
|
|
179
|
+
description: def.description ?? `MCP tool "${toolName}" from server "${serverName}"`,
|
|
180
|
+
parameters: def.inputSchema ?? { type: "object", properties: {} },
|
|
181
|
+
},
|
|
182
|
+
}));
|
|
183
|
+
}
|
|
184
|
+
killAll() {
|
|
185
|
+
for (const c of this.conns.values())
|
|
186
|
+
c.kill();
|
|
187
|
+
this.conns.clear();
|
|
188
|
+
}
|
|
189
|
+
}
|