dowafu 0.4.0 → 0.5.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/README.md +236 -66
- package/README_zh-tw.md +216 -66
- package/dist/adapters/anthropic-messages.js +5 -3
- package/dist/adapters/gemini-native.js +13 -5
- package/dist/adapters/responses.js +5 -1
- package/dist/api-token.js +54 -0
- package/dist/cli-args.js +45 -3
- package/dist/cli.js +680 -101
- package/dist/cost.js +24 -0
- package/dist/daemon.js +209 -0
- package/dist/db.js +219 -0
- package/dist/dispatch-home.js +8 -19
- package/dist/doctor.js +105 -45
- package/dist/gate.js +9 -4
- package/dist/job.js +97 -0
- package/dist/liveness.js +39 -0
- package/dist/mcp/http.js +178 -0
- package/dist/mcp/protocol.js +308 -0
- package/dist/mcp/stdio.js +36 -0
- package/dist/messages.js +338 -52
- package/dist/output.js +91 -104
- package/dist/progress.js +36 -0
- package/dist/provider-key.js +84 -0
- package/dist/providers-store.js +165 -0
- package/dist/providers.js +18 -6
- package/dist/report.js +38 -14
- package/dist/runner.js +18 -0
- package/dist/ticket-store.js +195 -0
- package/dist/ticket.js +22 -1
- package/dist/validate.js +58 -21
- package/package.json +1 -2
- package/providers.json +12 -10
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { defaultDbPath, openDb } from "../db.js";
|
|
3
|
+
import { approveJob, findJobsByIdPrefix, getJob, submitJob } from "../job.js";
|
|
4
|
+
import { DAEMON_STALE_MS, daemonAlive, daemonStatePath, daemonWarning } from "../liveness.js";
|
|
5
|
+
import { readProgress } from "../progress.js";
|
|
6
|
+
import { m } from "../messages.js";
|
|
7
|
+
import { readResultOutcomes, readResults } from "../output.js";
|
|
8
|
+
import { listModels } from "../providers-store.js";
|
|
9
|
+
import { buildKeyStatusRows } from "../provider-key.js";
|
|
10
|
+
import { getPackageVersion } from "../pkg-info.js";
|
|
11
|
+
import { formatTicket, getTicketDoc, getTicketHardCapUsd } from "../ticket-store.js";
|
|
12
|
+
export const TOOLS = [
|
|
13
|
+
{
|
|
14
|
+
name: "dispatch_tickets",
|
|
15
|
+
description: "List DB tickets that can be submitted, including their spokes and allowlist file counts.",
|
|
16
|
+
inputSchema: { type: "object", properties: {} },
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: "dispatch_submit",
|
|
20
|
+
description: "Queue a DB ticket by name. It remains pending human terminal approval and is not executed by this tool.",
|
|
21
|
+
inputSchema: {
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: { ticket: { type: "string", description: "DB ticket name, not a filesystem path" } },
|
|
24
|
+
required: ["ticket"],
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: "dispatch_status",
|
|
29
|
+
description: "Get a queued dispatch job's status.",
|
|
30
|
+
inputSchema: {
|
|
31
|
+
type: "object",
|
|
32
|
+
properties: { jobId: { type: "string" } },
|
|
33
|
+
required: ["jobId"],
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: "dispatch_result",
|
|
38
|
+
description: "Get a completed dispatch job's stored spoke results, or report its current status.",
|
|
39
|
+
inputSchema: {
|
|
40
|
+
type: "object",
|
|
41
|
+
properties: { jobId: { type: "string" } },
|
|
42
|
+
required: ["jobId"],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: "dispatch_approve",
|
|
47
|
+
description: "Approve a dispatch job waiting in the queue so a worker daemon may claim it. Requires the job's hard-cap dollar amount (from dispatch_submit's summary) to match the database's own calculation; a mismatch is rejected and nothing is approved.",
|
|
48
|
+
inputSchema: {
|
|
49
|
+
type: "object",
|
|
50
|
+
properties: {
|
|
51
|
+
jobId: { type: "string" },
|
|
52
|
+
hardCapUsd: { type: "string", description: "The hard-cap USD amount shown in the ticket summary, e.g. \"1.7712\" or \"$1.7712\"" },
|
|
53
|
+
},
|
|
54
|
+
required: ["jobId", "hardCapUsd"],
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
// 工單 C §E:讀 pkg-info 而非寫死版號——bump 之前會回報 package.json 目前的 0.4.1,
|
|
59
|
+
// 那是正確的,現在就是這個版本。
|
|
60
|
+
// 工單 approve-tool §零之二:本票推翻票 C 的禁令 #3,新增第五個 tool(dispatch_approve)。
|
|
61
|
+
// 「不得把 approve 開成 MCP tool」那條已作廢,見該工單。
|
|
62
|
+
export const SERVER_INFO = { name: "dowafu", version: getPackageVersion() };
|
|
63
|
+
function text(value, isError) {
|
|
64
|
+
return { content: [{ type: "text", text: value }], ...(isError ? { isError } : {}) };
|
|
65
|
+
}
|
|
66
|
+
function record(value) {
|
|
67
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
68
|
+
}
|
|
69
|
+
function stringParam(params, name) {
|
|
70
|
+
const value = record(params)?.[name];
|
|
71
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
72
|
+
}
|
|
73
|
+
function keyStatusText(lang, row) {
|
|
74
|
+
if (row.maskedTail === null)
|
|
75
|
+
return m(lang, "keyStatusMissing");
|
|
76
|
+
const configured = m(lang, "keyStatusDb", row.maskedTail, row.updatedAt ? row.updatedAt.slice(0, 10) : "");
|
|
77
|
+
if (row.lastTestSucceeded === null || row.lastTestedAt === null || row.lastTestModel === null)
|
|
78
|
+
return configured + m(lang, "keyStatusUntested");
|
|
79
|
+
return configured + m(lang, row.lastTestSucceeded ? "keyStatusTestSucceeded" : "keyStatusTestFailed", row.lastTestModel, row.lastTestedAt.slice(0, 10));
|
|
80
|
+
}
|
|
81
|
+
// 工單 approve-tool §A-1:比對容許格式差異("$1.77" 與 "1.77"),但比對本身用
|
|
82
|
+
// getTicketHardCapUsd 同一份 toFixed(4) 精度,數值不符(含解析失敗)一律回 null 視為不符。
|
|
83
|
+
function parseUsdAmount(raw) {
|
|
84
|
+
const cleaned = raw.trim().replace(/^\$/, "");
|
|
85
|
+
if (cleaned === "")
|
|
86
|
+
return null;
|
|
87
|
+
const value = Number(cleaned);
|
|
88
|
+
return Number.isFinite(value) ? value : null;
|
|
89
|
+
}
|
|
90
|
+
function requestId(message) {
|
|
91
|
+
const id = message.id;
|
|
92
|
+
return id === null || typeof id === "string" || typeof id === "number" ? id : undefined;
|
|
93
|
+
}
|
|
94
|
+
// 熱修補(票 C 手測):MCP 的 jobId 也接受前綴。
|
|
95
|
+
//
|
|
96
|
+
// 票 B 的 B-1 #7 裁定「只有 CLI 接受前綴」。真 client 上跑一次就看到那個裁定的漏洞:
|
|
97
|
+
// `dispatch_submit` 的回傳文字裡印著核准指令 `approve b66b5d47`,Codex 因此拿 `b66b5d47`
|
|
98
|
+
// 去呼叫 `dispatch_result`,被回「找不到」——而那個 job 明明存在。**我們親手把 client
|
|
99
|
+
// 教去用一個自己不接受的形式。**
|
|
100
|
+
//
|
|
101
|
+
// 沿用 job.ts 的 findJobsByIdPrefix(票 B 刻意放在那裡,就是為了不要每個介面各寫一次),
|
|
102
|
+
// 歧義的處理與 CLI 一致:不猜,列出候選。job id 本身仍然不變,回傳的一律是完整 id。
|
|
103
|
+
function resolveJob(db, idOrPrefix) {
|
|
104
|
+
const exact = getJob(db, idOrPrefix);
|
|
105
|
+
if (exact)
|
|
106
|
+
return { job: exact };
|
|
107
|
+
const matches = findJobsByIdPrefix(db, idOrPrefix);
|
|
108
|
+
if (matches.length === 1)
|
|
109
|
+
return { job: matches[0] };
|
|
110
|
+
if (matches.length > 1)
|
|
111
|
+
return { ambiguous: matches };
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
export function createProtocol(options) {
|
|
115
|
+
const liveness = options.liveness ?? (() => daemonAlive(daemonStatePath(options.dbPath)));
|
|
116
|
+
const warning = () => daemonWarning(liveness(), options.lang);
|
|
117
|
+
const callTool = (name, args) => {
|
|
118
|
+
switch (name) {
|
|
119
|
+
case "dispatch_tickets": {
|
|
120
|
+
const names = options.db.prepare("SELECT name FROM tickets ORDER BY name").all();
|
|
121
|
+
const lines = names.length === 0 ? [m(options.lang, "mcpTicketsEmpty")] : [m(options.lang, "mcpTicketsTitle")];
|
|
122
|
+
for (const { name: ticket } of names) {
|
|
123
|
+
const doc = getTicketDoc(options.db, ticket, options.lang);
|
|
124
|
+
lines.push(m(options.lang, "mcpTicketsRow", ticket, doc.spokes.length));
|
|
125
|
+
for (const spoke of doc.spokes) {
|
|
126
|
+
lines.push(m(options.lang, "mcpTicketsSpoke", spoke.agent, spoke.provider, spoke.model, spoke.allow.length));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
lines.push(m(options.lang, "mcpKeysTitle"));
|
|
130
|
+
for (const row of buildKeyStatusRows(options.db))
|
|
131
|
+
lines.push(m(options.lang, "mcpKeysRow", row.provider, keyStatusText(options.lang, row)));
|
|
132
|
+
lines.push(m(options.lang, "mcpEnabledModelsTitle"));
|
|
133
|
+
for (const model of listModels(options.db).filter((model) => model.enabled)) {
|
|
134
|
+
lines.push(m(options.lang, "mcpEnabledModelRow", model.provider, model.model));
|
|
135
|
+
}
|
|
136
|
+
return text(lines.join("\n"));
|
|
137
|
+
}
|
|
138
|
+
case "dispatch_submit": {
|
|
139
|
+
const ticket = stringParam(args, "ticket");
|
|
140
|
+
if (!ticket)
|
|
141
|
+
return text("dispatch_submit requires a non-empty ticket name", true);
|
|
142
|
+
try {
|
|
143
|
+
const job = submitJob(options.db, ticket);
|
|
144
|
+
const cliPath = path.resolve(process.argv[1] ?? "cli.js");
|
|
145
|
+
const approveCommand = `node ${JSON.stringify(cliPath)} approve ${job.id.slice(0, 8)} --db ${JSON.stringify(options.dbPath)}`;
|
|
146
|
+
const lines = [m(options.lang, "mcpSubmitQueued", job.id), m(options.lang, "mcpApproveCommand", approveCommand), formatTicket(options.db, ticket, options.lang)];
|
|
147
|
+
const offline = warning();
|
|
148
|
+
if (offline)
|
|
149
|
+
lines.push(offline);
|
|
150
|
+
return text(lines.join("\n"));
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
return text(err instanceof Error ? err.message : String(err), true);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
case "dispatch_status": {
|
|
157
|
+
const jobId = stringParam(args, "jobId");
|
|
158
|
+
if (!jobId)
|
|
159
|
+
return text("dispatch_status requires a non-empty jobId", true);
|
|
160
|
+
const found = resolveJob(options.db, jobId);
|
|
161
|
+
if (!found)
|
|
162
|
+
return text(m(options.lang, "jobNotFound", jobId), true);
|
|
163
|
+
if ("ambiguous" in found) {
|
|
164
|
+
return text([m(options.lang, "mcpJobPrefixAmbiguous", jobId, found.ambiguous.length),
|
|
165
|
+
...found.ambiguous.map((candidate) => ` ${candidate.id} ${candidate.ticket} ${candidate.status}`)].join("\n"), true);
|
|
166
|
+
}
|
|
167
|
+
const job = found.job;
|
|
168
|
+
const lines = [`job ${job.id}: ${job.status}`];
|
|
169
|
+
if (job.claimed_at)
|
|
170
|
+
lines.push(m(options.lang, "mcpJobStarted", job.claimed_at));
|
|
171
|
+
if (job.finished_at)
|
|
172
|
+
lines.push(m(options.lang, "mcpJobFinished", job.finished_at));
|
|
173
|
+
if (job.error)
|
|
174
|
+
lines.push(m(options.lang, "mcpJobError", job.error));
|
|
175
|
+
if (job.status === "pending_approval" || job.status === "approved") {
|
|
176
|
+
const offline = warning();
|
|
177
|
+
if (offline)
|
|
178
|
+
lines.push(offline);
|
|
179
|
+
}
|
|
180
|
+
// B(gaps #6):進了 running 之後先前反而什麼都不講——而那正是最需要講的時候。
|
|
181
|
+
// 兩個不同的問題各自回答:進度(有在往前走嗎)與心跳(worker 還活著嗎)。
|
|
182
|
+
// 卡在 429 重試會長成「心跳新鮮、進度不動」,行程死掉則是兩個都停。
|
|
183
|
+
if (job.status === "running") {
|
|
184
|
+
const progress = readProgress(options.db, job.id);
|
|
185
|
+
if (progress.length === 0)
|
|
186
|
+
lines.push(m(options.lang, "mcpJobNoProgressYet"));
|
|
187
|
+
for (const p of progress) {
|
|
188
|
+
lines.push(m(options.lang, "mcpJobProgress", p.agent, p.lastRound, p.tokensIn, p.tokensOut, p.lastAt));
|
|
189
|
+
}
|
|
190
|
+
const staleMs = job.heartbeat_at ? (options.now ?? Date.now)() - Date.parse(job.heartbeat_at) : null;
|
|
191
|
+
if (staleMs === null || staleMs > DAEMON_STALE_MS) {
|
|
192
|
+
lines.push(m(options.lang, "mcpJobHeartbeatStale", Math.round((staleMs ?? 0) / 1000)));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// 工單 status-stdin §A-3:CLI 子行程只在全部 spoke 都失敗時才回非零 exit
|
|
196
|
+
// (見 cli.ts 的 allFailed 判定),三支死一支照樣 exit 0、job 標成 completed——
|
|
197
|
+
// 先前只印 `job <id>: completed`,看不出哪幾支付了錢卻沒拿到東西。failed 的 job
|
|
198
|
+
// 也要(先前只印子行程的 exit 訊息,看不出是哪一支出事)。
|
|
199
|
+
if (job.status === "completed" || job.status === "failed") {
|
|
200
|
+
const outcomes = readResultOutcomes(options.db, job.id);
|
|
201
|
+
if (outcomes.length > 0) {
|
|
202
|
+
const failedAgents = outcomes.filter((o) => o.failed).map((o) => o.agent);
|
|
203
|
+
const succeeded = outcomes.length - failedAgents.length;
|
|
204
|
+
const failedAgentsSuffix = failedAgents.length > 0 ? m(options.lang, "mcpJobFailedAgentsSuffix", failedAgents.join(", ")) : "";
|
|
205
|
+
lines.push(m(options.lang, "mcpJobOutcome", succeeded, failedAgents.length, failedAgentsSuffix));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return text(lines.join("\n"));
|
|
209
|
+
}
|
|
210
|
+
case "dispatch_result": {
|
|
211
|
+
const jobId = stringParam(args, "jobId");
|
|
212
|
+
if (!jobId)
|
|
213
|
+
return text("dispatch_result requires a non-empty jobId", true);
|
|
214
|
+
const found = resolveJob(options.db, jobId);
|
|
215
|
+
if (!found)
|
|
216
|
+
return text(m(options.lang, "jobNotFound", jobId), true);
|
|
217
|
+
if ("ambiguous" in found) {
|
|
218
|
+
return text([m(options.lang, "mcpJobPrefixAmbiguous", jobId, found.ambiguous.length),
|
|
219
|
+
...found.ambiguous.map((candidate) => ` ${candidate.id} ${candidate.ticket} ${candidate.status}`)].join("\n"), true);
|
|
220
|
+
}
|
|
221
|
+
const job = found.job;
|
|
222
|
+
if (job.status !== "completed") {
|
|
223
|
+
const lines = [m(options.lang, "mcpResultPending", job.id, job.status)];
|
|
224
|
+
if (job.error)
|
|
225
|
+
lines.push(m(options.lang, "mcpJobError", job.error));
|
|
226
|
+
return text(lines.join("\n"));
|
|
227
|
+
}
|
|
228
|
+
// 驗收熱修補:同 cli.ts 的 result 分支——lang 不傳的話失敗字樣固定英文。
|
|
229
|
+
const results = readResults(options.db, job.id, options.lang);
|
|
230
|
+
return text([m(options.lang, "mcpResultComplete", job.id), ...(results.length ? results : [m(options.lang, "mcpResultEmpty")])].join("\n"));
|
|
231
|
+
}
|
|
232
|
+
case "dispatch_approve": {
|
|
233
|
+
const jobId = stringParam(args, "jobId");
|
|
234
|
+
if (!jobId)
|
|
235
|
+
return text("dispatch_approve requires a non-empty jobId", true);
|
|
236
|
+
const hardCapUsdRaw = stringParam(args, "hardCapUsd");
|
|
237
|
+
if (!hardCapUsdRaw)
|
|
238
|
+
return text("dispatch_approve requires a hardCapUsd amount", true);
|
|
239
|
+
const found = resolveJob(options.db, jobId);
|
|
240
|
+
if (!found)
|
|
241
|
+
return text(m(options.lang, "jobNotFound", jobId), true);
|
|
242
|
+
if ("ambiguous" in found) {
|
|
243
|
+
return text([m(options.lang, "mcpJobPrefixAmbiguous", jobId, found.ambiguous.length),
|
|
244
|
+
...found.ambiguous.map((candidate) => ` ${candidate.id} ${candidate.ticket} ${candidate.status}`)].join("\n"), true);
|
|
245
|
+
}
|
|
246
|
+
const job = found.job;
|
|
247
|
+
// 工單 §A-1 之二:與 formatTicket 共用同一份算法(getTicketHardCapUsd),不得在這裡
|
|
248
|
+
// 另算一份——變異 #5 鎖這件事。
|
|
249
|
+
const expected = getTicketHardCapUsd(options.db, job.ticket, options.lang);
|
|
250
|
+
if (expected === null)
|
|
251
|
+
return text(m(options.lang, "mcpApproveNoPricing", job.id), true);
|
|
252
|
+
const provided = parseUsdAmount(hardCapUsdRaw);
|
|
253
|
+
if (provided === null || provided.toFixed(4) !== expected.toFixed(4)) {
|
|
254
|
+
return text(m(options.lang, "mcpApproveAmountMismatch", job.id, hardCapUsdRaw, expected.toFixed(4)), true);
|
|
255
|
+
}
|
|
256
|
+
const approved = approveJob(options.db, job.id);
|
|
257
|
+
if (!approved)
|
|
258
|
+
return text(m(options.lang, "jobNotApprovable", job.id, job.status), true);
|
|
259
|
+
const lines = [m(options.lang, "jobApproved", approved.id)];
|
|
260
|
+
const offline = warning();
|
|
261
|
+
if (offline)
|
|
262
|
+
lines.push(offline);
|
|
263
|
+
return text(lines.join("\n"));
|
|
264
|
+
}
|
|
265
|
+
default:
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
const handleRpc = (raw, log = () => { }) => {
|
|
270
|
+
const message = record(raw);
|
|
271
|
+
if (!message || typeof message.method !== "string") {
|
|
272
|
+
return { response: { jsonrpc: "2.0", id: null, error: { code: -32600, message: "invalid request" } } };
|
|
273
|
+
}
|
|
274
|
+
const id = requestId(message);
|
|
275
|
+
const respond = (result) => (id === undefined ? { none: true } : { response: { jsonrpc: "2.0", id, result } });
|
|
276
|
+
const fail = (code, message) => id === undefined ? { none: true } : { response: { jsonrpc: "2.0", id, error: { code, message } } };
|
|
277
|
+
if (message.method.startsWith("notifications/"))
|
|
278
|
+
return { none: true };
|
|
279
|
+
if (message.method === "server/discover")
|
|
280
|
+
return fail(-32601, "server/discover not implemented (legacy server)");
|
|
281
|
+
if (message.method === "initialize") {
|
|
282
|
+
const params = record(message.params);
|
|
283
|
+
log(`clientInfo: ${JSON.stringify(params?.clientInfo ?? null)}`);
|
|
284
|
+
log(`protocolVersion: ${JSON.stringify(params?.protocolVersion ?? null)}`);
|
|
285
|
+
log(`capabilities: ${JSON.stringify(params?.capabilities ?? null)}`);
|
|
286
|
+
return respond({
|
|
287
|
+
protocolVersion: typeof params?.protocolVersion === "string" ? params.protocolVersion : "2025-11-25",
|
|
288
|
+
capabilities: { tools: {} },
|
|
289
|
+
serverInfo: SERVER_INFO,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
if (message.method === "ping")
|
|
293
|
+
return respond({});
|
|
294
|
+
if (message.method === "tools/list")
|
|
295
|
+
return respond({ tools: TOOLS });
|
|
296
|
+
if (message.method === "tools/call") {
|
|
297
|
+
const params = record(message.params);
|
|
298
|
+
const name = typeof params?.name === "string" ? params.name : "";
|
|
299
|
+
const result = callTool(name, params?.arguments);
|
|
300
|
+
return result ? respond(result) : fail(-32602, `unknown tool: ${name}`);
|
|
301
|
+
}
|
|
302
|
+
return fail(-32601, `method not found: ${message.method}`);
|
|
303
|
+
};
|
|
304
|
+
return { handleRpc, callTool };
|
|
305
|
+
}
|
|
306
|
+
export function createDefaultProtocol(dbPath = defaultDbPath(), lang = "en") {
|
|
307
|
+
return createProtocol({ db: openDb(dbPath), dbPath, lang });
|
|
308
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import readline from "node:readline";
|
|
2
|
+
import { defaultDbPath } from "../db.js";
|
|
3
|
+
import { createDefaultProtocol } from "./protocol.js";
|
|
4
|
+
// stdout is reserved exclusively for one-line JSON-RPC responses. Diagnostics,
|
|
5
|
+
// including client capability observations, are deliberately routed to stderr.
|
|
6
|
+
export function startStdioServer(options = {}) {
|
|
7
|
+
const input = options.input ?? process.stdin;
|
|
8
|
+
const output = options.output ?? process.stdout;
|
|
9
|
+
const stderr = options.stderr ?? process.stderr;
|
|
10
|
+
const exit = options.exit ?? ((code) => process.exit(code));
|
|
11
|
+
const protocol = createDefaultProtocol(options.dbPath ?? defaultDbPath(), options.lang ?? "en");
|
|
12
|
+
const log = (line) => stderr.write(`${line}\n`);
|
|
13
|
+
const reader = readline.createInterface({ input, crlfDelay: Infinity });
|
|
14
|
+
reader.on("line", (line) => {
|
|
15
|
+
if (!line.trim())
|
|
16
|
+
return;
|
|
17
|
+
let message;
|
|
18
|
+
try {
|
|
19
|
+
message = JSON.parse(line);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
log("invalid JSON-RPC input");
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const outcome = protocol.handleRpc(message, log);
|
|
26
|
+
if ("none" in outcome)
|
|
27
|
+
return;
|
|
28
|
+
// JSON.stringify escapes embedded string newlines, so each write is exactly
|
|
29
|
+
// one legal newline-delimited MCP message.
|
|
30
|
+
output.write(`${JSON.stringify(outcome.response)}\n`);
|
|
31
|
+
});
|
|
32
|
+
input.once("end", () => {
|
|
33
|
+
log("stdin closed; exiting");
|
|
34
|
+
exit(0);
|
|
35
|
+
});
|
|
36
|
+
}
|