@flyle/ai-contact-center-mcp 0.0.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 +53 -0
- package/README.md +111 -0
- package/dist/main.js +2207 -0
- package/generated/manifest.json +10586 -0
- package/package.json +58 -0
package/dist/main.js
ADDED
|
@@ -0,0 +1,2207 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { homedir, hostname, userInfo } from "node:os";
|
|
3
|
+
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
6
|
+
import { mkdir, open, readFile, realpath, unlink, writeFile } from "node:fs/promises";
|
|
7
|
+
import { constants, readFileSync } from "node:fs";
|
|
8
|
+
import { McpServer, fromJsonSchema } from "@modelcontextprotocol/server";
|
|
9
|
+
//#region package.json
|
|
10
|
+
var version = "0.0.0";
|
|
11
|
+
//#endregion
|
|
12
|
+
//#region src/constants.ts
|
|
13
|
+
const MCP_SERVER_NAME = "flyle-ai-contact-center";
|
|
14
|
+
const MCP_SERVER_VERSION = version;
|
|
15
|
+
/** 公開 API の鍵形式(設計書 §6.1)。形式が違う値は API に送る前に拒否する。 */
|
|
16
|
+
const API_KEY_PATTERN = /^flyle_ai_contact_center_sa_[0-9A-Za-z]{43}$/;
|
|
17
|
+
/** auth のテナント名規則(公開 API の `tenantName` パス変数と同じ)。 */
|
|
18
|
+
const TENANT_NAME_PATTERN = /^[a-z0-9](-?[a-z0-9])*$/;
|
|
19
|
+
/** 公開 API のパス変数名。ツール引数からは受けない(設計書 §7.2)。 */
|
|
20
|
+
const TENANT_PATH_PARAM = "tenantName";
|
|
21
|
+
const REDACTED_PLACEHOLDER = "[redacted]";
|
|
22
|
+
/** 応答本文(JSON)の上限。公開 API の `limit` 上限 100 件で十分に収まる。 */
|
|
23
|
+
const MAX_JSON_RESPONSE_BYTES = 16777216;
|
|
24
|
+
/** SSE を終端まで収集する上限。検証の「試す」は数分で終わる想定。 */
|
|
25
|
+
const SSE_TOTAL_TIMEOUT_MS = 9e5;
|
|
26
|
+
const SSE_MAX_EVENTS = 1e4;
|
|
27
|
+
const SSE_MAX_BYTES = 33554432;
|
|
28
|
+
/** 応答ヘッダが返るまでの待ち時間。公開 API 側の request timeout(30 秒)より長く取る。 */
|
|
29
|
+
const RESPONSE_HEADERS_TIMEOUT_MS = 6e4;
|
|
30
|
+
const BINARY_TOTAL_TIMEOUT_MS = 6e5;
|
|
31
|
+
const MAX_DOWNLOAD_BYTES = 536870912;
|
|
32
|
+
const MAX_UPLOAD_BYTES = 536870912;
|
|
33
|
+
/** 署名付き URL を「応答由来」と認める期限。S3 の presign 既定より短く取る。 */
|
|
34
|
+
const PRESIGNED_URL_TTL_MS = 9e5;
|
|
35
|
+
const PRESIGNED_URL_REGISTRY_MAX = 1e3;
|
|
36
|
+
/** 保存するツール結果ファイルの上限。SSE 32 MiB / JSON 16 MiB の応答上限を compact JSON で収める。 */
|
|
37
|
+
const MAX_TOOL_RESULT_FILE_BYTES = 67108864;
|
|
38
|
+
/** 応答に埋め込まれた base64(音声 delta 等)をファイルに書き出すときの累積上限。 */
|
|
39
|
+
const MAX_INLINE_FILE_OUTPUT_BYTES = 33554432;
|
|
40
|
+
//#endregion
|
|
41
|
+
//#region src/errors.ts
|
|
42
|
+
var ManifestLoadError = class extends Error {
|
|
43
|
+
name = "ManifestLoadError";
|
|
44
|
+
};
|
|
45
|
+
var ConfigError = class extends Error {
|
|
46
|
+
name = "ConfigError";
|
|
47
|
+
};
|
|
48
|
+
/** 公開 API が 4xx / 5xx を返した。 */
|
|
49
|
+
var ApiError = class extends Error {
|
|
50
|
+
name = "ApiError";
|
|
51
|
+
info;
|
|
52
|
+
constructor(info) {
|
|
53
|
+
super(`${info.status} ${info.code ?? "http_error"}: ${info.message}`);
|
|
54
|
+
this.info = info;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
/** 応答が公開 API の契約どおりでない、または届かなかった。 */
|
|
58
|
+
var TransportError = class extends Error {
|
|
59
|
+
name = "TransportError";
|
|
60
|
+
kind;
|
|
61
|
+
status;
|
|
62
|
+
constructor(kind, message, status = null) {
|
|
63
|
+
super(message);
|
|
64
|
+
this.kind = kind;
|
|
65
|
+
this.status = status;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
var TransferError = class extends Error {
|
|
69
|
+
name = "TransferError";
|
|
70
|
+
kind;
|
|
71
|
+
status;
|
|
72
|
+
constructor(kind, message, status = null) {
|
|
73
|
+
super(message);
|
|
74
|
+
this.kind = kind;
|
|
75
|
+
this.status = status;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
var LocalFileError = class extends Error {
|
|
79
|
+
name = "LocalFileError";
|
|
80
|
+
kind;
|
|
81
|
+
constructor(kind, message) {
|
|
82
|
+
super(message);
|
|
83
|
+
this.kind = kind;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
var InlineFileError = class extends Error {
|
|
87
|
+
name = "InlineFileError";
|
|
88
|
+
kind;
|
|
89
|
+
constructor(kind, message) {
|
|
90
|
+
super(message);
|
|
91
|
+
this.kind = kind;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
var InvalidArgumentsError = class extends Error {
|
|
95
|
+
name = "InvalidArgumentsError";
|
|
96
|
+
};
|
|
97
|
+
/** 応答は受け取れたが操作としては失敗(SSE の成否判定が success でない等)。payload をそのまま isError で返す。 */
|
|
98
|
+
var OperationFailedError = class extends Error {
|
|
99
|
+
name = "OperationFailedError";
|
|
100
|
+
payload;
|
|
101
|
+
constructor(message, payload) {
|
|
102
|
+
super(message);
|
|
103
|
+
this.payload = payload;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
/** 作業ディレクトリとテナントの 1 対 1 束縛が崩れる。テナント間のデータ持ち込みを防ぐため起動・操作を止める。 */
|
|
107
|
+
var WorkspaceBindingError = class extends Error {
|
|
108
|
+
name = "WorkspaceBindingError";
|
|
109
|
+
kind;
|
|
110
|
+
constructor(kind, message) {
|
|
111
|
+
super(message);
|
|
112
|
+
this.kind = kind;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
//#endregion
|
|
116
|
+
//#region src/config.ts
|
|
117
|
+
const ENV_KEYS = {
|
|
118
|
+
baseUrl: "FLYLE_AI_CONTACT_CENTER_API_BASE_URL",
|
|
119
|
+
tenantName: "FLYLE_AI_CONTACT_CENTER_TENANT_NAME",
|
|
120
|
+
apiKey: "FLYLE_AI_CONTACT_CENTER_API_KEY",
|
|
121
|
+
outputDir: "FLYLE_AI_CONTACT_CENTER_MCP_OUTPUT_DIR",
|
|
122
|
+
fileRoots: "FLYLE_AI_CONTACT_CENTER_MCP_FILE_ROOTS",
|
|
123
|
+
storageHosts: "FLYLE_AI_CONTACT_CENTER_MCP_STORAGE_HOSTS",
|
|
124
|
+
allowInsecureHttp: "FLYLE_AI_CONTACT_CENTER_MCP_ALLOW_INSECURE_HTTP"
|
|
125
|
+
};
|
|
126
|
+
const DEFAULT_OUTPUT_DIR = ".flyle-ai-contact-center-mcp/downloads";
|
|
127
|
+
/** 署名付き転送 URL のホストとして許すサフィックス。S3(各リージョン・仮想ホスト形式)を既定にする。 */
|
|
128
|
+
const DEFAULT_STORAGE_HOST_SUFFIXES = [".amazonaws.com"];
|
|
129
|
+
const LOCAL_HOSTS = /* @__PURE__ */ new Set([
|
|
130
|
+
"localhost",
|
|
131
|
+
"127.0.0.1",
|
|
132
|
+
"[::1]"
|
|
133
|
+
]);
|
|
134
|
+
const trimTrailingSlashes = (value) => {
|
|
135
|
+
let end = value.length;
|
|
136
|
+
while (end > 0 && value[end - 1] === "/") end -= 1;
|
|
137
|
+
return value.slice(0, end);
|
|
138
|
+
};
|
|
139
|
+
const readRequired = (env, key) => {
|
|
140
|
+
const value = env[key];
|
|
141
|
+
if (value == null || value.trim() === "") throw new ConfigError(`environment variable ${key} is required`);
|
|
142
|
+
return value.trim();
|
|
143
|
+
};
|
|
144
|
+
const isLocalHost = (hostname) => LOCAL_HOSTS.has(hostname);
|
|
145
|
+
const parseBaseUrl = (raw, allowInsecureHttp) => {
|
|
146
|
+
let url;
|
|
147
|
+
try {
|
|
148
|
+
url = new URL(raw);
|
|
149
|
+
} catch {
|
|
150
|
+
throw new ConfigError(`${ENV_KEYS.baseUrl} is not a valid URL`);
|
|
151
|
+
}
|
|
152
|
+
if (url.search !== "" || url.hash !== "" || url.username !== "" || url.password !== "") throw new ConfigError(`${ENV_KEYS.baseUrl} must not contain query, hash, or credentials`);
|
|
153
|
+
const insecureLocalhost = url.protocol === "http:" && allowInsecureHttp && isLocalHost(url.hostname);
|
|
154
|
+
if (url.protocol !== "https:" && !insecureLocalhost) throw new ConfigError(`${ENV_KEYS.baseUrl} must use https (http is allowed only for localhost with ${ENV_KEYS.allowInsecureHttp}=1)`);
|
|
155
|
+
const normalizedPath = trimTrailingSlashes(url.pathname);
|
|
156
|
+
return new URL(`${url.origin}${normalizedPath}`);
|
|
157
|
+
};
|
|
158
|
+
const parseTenantName = (raw) => {
|
|
159
|
+
if (raw.length < 2 || raw.length > 32 || !TENANT_NAME_PATTERN.test(raw)) throw new ConfigError(`${ENV_KEYS.tenantName} is not a valid tenant name`);
|
|
160
|
+
return raw;
|
|
161
|
+
};
|
|
162
|
+
const parseApiKey = (raw) => {
|
|
163
|
+
if (!API_KEY_PATTERN.test(raw)) throw new ConfigError(`${ENV_KEYS.apiKey} does not look like a Flyle AI Contact Center service account key`);
|
|
164
|
+
return raw;
|
|
165
|
+
};
|
|
166
|
+
const parseList = (raw) => {
|
|
167
|
+
if (raw == null) return null;
|
|
168
|
+
const items = raw.split(",").map((item) => item.trim()).filter((item) => item !== "");
|
|
169
|
+
return items.length === 0 ? null : items;
|
|
170
|
+
};
|
|
171
|
+
const parseBoolean = (raw) => raw === "1" || raw === "true";
|
|
172
|
+
const loadConfig = (env, cwd) => {
|
|
173
|
+
const allowInsecureHttp = parseBoolean(env[ENV_KEYS.allowInsecureHttp]);
|
|
174
|
+
const baseUrl = parseBaseUrl(readRequired(env, ENV_KEYS.baseUrl), allowInsecureHttp);
|
|
175
|
+
const tenantName = parseTenantName(readRequired(env, ENV_KEYS.tenantName));
|
|
176
|
+
const apiKey = parseApiKey(readRequired(env, ENV_KEYS.apiKey));
|
|
177
|
+
const outputDirSetting = env[ENV_KEYS.outputDir]?.trim();
|
|
178
|
+
return {
|
|
179
|
+
baseUrl,
|
|
180
|
+
tenantName,
|
|
181
|
+
apiKey,
|
|
182
|
+
outputDir: path.resolve(cwd, outputDirSetting == null || outputDirSetting === "" ? DEFAULT_OUTPUT_DIR : outputDirSetting),
|
|
183
|
+
fileRoots: (parseList(env[ENV_KEYS.fileRoots]) ?? [cwd]).map((root) => path.resolve(cwd, root)),
|
|
184
|
+
storageHostSuffixes: (parseList(env[ENV_KEYS.storageHosts]) ?? DEFAULT_STORAGE_HOST_SUFFIXES).map((host) => host.toLowerCase()),
|
|
185
|
+
allowInsecureHttp
|
|
186
|
+
};
|
|
187
|
+
};
|
|
188
|
+
//#endregion
|
|
189
|
+
//#region src/http/cleanup.ts
|
|
190
|
+
const ignoreCleanupError = () => {};
|
|
191
|
+
//#endregion
|
|
192
|
+
//#region src/http/sse.ts
|
|
193
|
+
const DEFAULT_EVENT_NAME = "message";
|
|
194
|
+
const LINE_BREAK_PATTERN = /\r\n|\n|\r/;
|
|
195
|
+
const createSseParser = () => {
|
|
196
|
+
let buffer = "";
|
|
197
|
+
let skipLeadingLf = false;
|
|
198
|
+
let eventName = "";
|
|
199
|
+
let dataLines = [];
|
|
200
|
+
let lastEventId = null;
|
|
201
|
+
const dispatch = () => {
|
|
202
|
+
if (dataLines.length === 0) {
|
|
203
|
+
eventName = "";
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
const event = {
|
|
207
|
+
event: eventName === "" ? DEFAULT_EVENT_NAME : eventName,
|
|
208
|
+
data: dataLines.join("\n"),
|
|
209
|
+
id: lastEventId
|
|
210
|
+
};
|
|
211
|
+
eventName = "";
|
|
212
|
+
dataLines = [];
|
|
213
|
+
return event;
|
|
214
|
+
};
|
|
215
|
+
const consumeLine = (line) => {
|
|
216
|
+
if (line === "") return dispatch();
|
|
217
|
+
if (line.startsWith(":")) return null;
|
|
218
|
+
const colon = line.indexOf(":");
|
|
219
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
220
|
+
let value = colon === -1 ? "" : line.slice(colon + 1);
|
|
221
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
222
|
+
if (field === "event") eventName = value;
|
|
223
|
+
else if (field === "data") dataLines.push(value);
|
|
224
|
+
else if (field === "id" && !value.includes("\0")) lastEventId = value;
|
|
225
|
+
return null;
|
|
226
|
+
};
|
|
227
|
+
const push = (chunk) => {
|
|
228
|
+
buffer += chunk;
|
|
229
|
+
if (skipLeadingLf) {
|
|
230
|
+
skipLeadingLf = false;
|
|
231
|
+
if (buffer.startsWith("\n")) buffer = buffer.slice(1);
|
|
232
|
+
}
|
|
233
|
+
const events = [];
|
|
234
|
+
let newlineIndex = buffer.search(LINE_BREAK_PATTERN);
|
|
235
|
+
while (newlineIndex !== -1) {
|
|
236
|
+
const line = buffer.slice(0, newlineIndex);
|
|
237
|
+
if (buffer[newlineIndex] === "\r" && newlineIndex === buffer.length - 1) skipLeadingLf = true;
|
|
238
|
+
const separatorLength = buffer.startsWith("\r\n", newlineIndex) ? 2 : 1;
|
|
239
|
+
buffer = buffer.slice(newlineIndex + separatorLength);
|
|
240
|
+
const event = consumeLine(line);
|
|
241
|
+
if (event != null) events.push(event);
|
|
242
|
+
newlineIndex = buffer.search(LINE_BREAK_PATTERN);
|
|
243
|
+
}
|
|
244
|
+
return events;
|
|
245
|
+
};
|
|
246
|
+
return {
|
|
247
|
+
push,
|
|
248
|
+
hasPendingIncomplete: () => buffer !== "" || dataLines.length > 0,
|
|
249
|
+
flush: () => {
|
|
250
|
+
buffer = "";
|
|
251
|
+
skipLeadingLf = false;
|
|
252
|
+
eventName = "";
|
|
253
|
+
dataLines = [];
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
};
|
|
258
|
+
const readWithDeadline$1 = async (reader, remainingMs) => {
|
|
259
|
+
if (remainingMs <= 0) return "deadline";
|
|
260
|
+
let timer = null;
|
|
261
|
+
const deadline = new Promise((resolve) => {
|
|
262
|
+
timer = setTimeout(() => resolve("deadline"), remainingMs);
|
|
263
|
+
});
|
|
264
|
+
try {
|
|
265
|
+
return await Promise.race([reader.read(), deadline]);
|
|
266
|
+
} finally {
|
|
267
|
+
if (timer != null) clearTimeout(timer);
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
/** SSE を終端まで収集する。上限に達したらストリームを cancel して打ち切り、集まった分を返す。 */
|
|
271
|
+
const collectSseEvents = async (stream, options) => {
|
|
272
|
+
const reader = stream.getReader();
|
|
273
|
+
const decoder = new TextDecoder("utf-8");
|
|
274
|
+
const parser = createSseParser();
|
|
275
|
+
const events = [];
|
|
276
|
+
let bytes = 0;
|
|
277
|
+
const stop = async (reason) => {
|
|
278
|
+
await reader.cancel().catch(ignoreCleanupError);
|
|
279
|
+
return {
|
|
280
|
+
events,
|
|
281
|
+
truncated: true,
|
|
282
|
+
reason,
|
|
283
|
+
discardedIncomplete: false
|
|
284
|
+
};
|
|
285
|
+
};
|
|
286
|
+
for (;;) {
|
|
287
|
+
const result = await readWithDeadline$1(reader, options.deadlineAt - options.now());
|
|
288
|
+
if (result === "deadline") return await stop("deadline");
|
|
289
|
+
if (result.done) {
|
|
290
|
+
events.push(...parser.push(decoder.decode()));
|
|
291
|
+
const discardedIncomplete = parser.hasPendingIncomplete();
|
|
292
|
+
parser.flush();
|
|
293
|
+
return {
|
|
294
|
+
events,
|
|
295
|
+
truncated: false,
|
|
296
|
+
reason: "end",
|
|
297
|
+
discardedIncomplete
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
bytes += result.value.byteLength;
|
|
301
|
+
if (bytes > options.maxBytes) return await stop("max_bytes");
|
|
302
|
+
events.push(...parser.push(decoder.decode(result.value, { stream: true })));
|
|
303
|
+
if (events.length > options.maxEvents) {
|
|
304
|
+
events.length = options.maxEvents;
|
|
305
|
+
return await stop("max_events");
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
//#endregion
|
|
310
|
+
//#region src/http/api-client.ts
|
|
311
|
+
const JSON_CONTENT_TYPE = "application/json";
|
|
312
|
+
const SSE_CONTENT_TYPE = "text/event-stream";
|
|
313
|
+
const REQUEST_ID_HEADER = "x-request-id";
|
|
314
|
+
const RETRY_AFTER_HEADER = "retry-after";
|
|
315
|
+
const MAX_ERROR_TEXT_CHARS = 500;
|
|
316
|
+
/** 作業ディレクトリの不透明 ID。公開 API が「同じ作業場所から複数テナントへ」の操作を検知する(設計書 §7.4)。 */
|
|
317
|
+
const CLIENT_WORKSPACE_HEADER = "x-flyle-client-workspace";
|
|
318
|
+
const DEFAULT_API_CLIENT_LIMITS = {
|
|
319
|
+
headersTimeoutMs: RESPONSE_HEADERS_TIMEOUT_MS,
|
|
320
|
+
maxJsonBytes: MAX_JSON_RESPONSE_BYTES,
|
|
321
|
+
sseTotalMs: SSE_TOTAL_TIMEOUT_MS,
|
|
322
|
+
sseMaxEvents: SSE_MAX_EVENTS,
|
|
323
|
+
sseMaxBytes: SSE_MAX_BYTES,
|
|
324
|
+
maxRetryAfterSeconds: 30
|
|
325
|
+
};
|
|
326
|
+
/** Authorization を付ける通信先は起動設定の origin だけ(INV-C01 / 設計書 §17)。 */
|
|
327
|
+
const assertFixedOrigin = (url, baseUrl) => {
|
|
328
|
+
if (url.origin !== baseUrl.origin) throw new TransportError("network", "refusing to send credentials outside the configured API origin");
|
|
329
|
+
};
|
|
330
|
+
const buildRequestUrl = (baseUrl, request) => {
|
|
331
|
+
const prefix = trimTrailingSlashes(baseUrl.pathname);
|
|
332
|
+
const url = new URL(`${prefix}${request.path}`, baseUrl);
|
|
333
|
+
for (const [name, value] of request.query) url.searchParams.append(name, value);
|
|
334
|
+
assertFixedOrigin(url, baseUrl);
|
|
335
|
+
return url;
|
|
336
|
+
};
|
|
337
|
+
const acceptFor = (kind) => {
|
|
338
|
+
switch (kind) {
|
|
339
|
+
case "json":
|
|
340
|
+
case "empty": return JSON_CONTENT_TYPE;
|
|
341
|
+
case "sse": return SSE_CONTENT_TYPE;
|
|
342
|
+
case "binary": return "*/*";
|
|
343
|
+
default: throw new Error(`unknown response kind ${String(kind)}`);
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
/** 本文を上限付きで読む。`content-length` が無い応答でも上限を超えたら切る。 */
|
|
347
|
+
const readBodyWithLimit = async (response, maxBytes) => {
|
|
348
|
+
const declared = Number(response.headers.get("content-length"));
|
|
349
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
350
|
+
await response.body?.cancel().catch(ignoreCleanupError);
|
|
351
|
+
throw new TransportError("response_too_large", `response exceeds ${maxBytes} bytes`, response.status);
|
|
352
|
+
}
|
|
353
|
+
if (response.body == null) return /* @__PURE__ */ new Uint8Array(0);
|
|
354
|
+
const reader = response.body.getReader();
|
|
355
|
+
const chunks = [];
|
|
356
|
+
let total = 0;
|
|
357
|
+
for (;;) {
|
|
358
|
+
const { done, value } = await reader.read();
|
|
359
|
+
if (done) break;
|
|
360
|
+
total += value.byteLength;
|
|
361
|
+
if (total > maxBytes) {
|
|
362
|
+
await reader.cancel().catch(ignoreCleanupError);
|
|
363
|
+
throw new TransportError("response_too_large", `response exceeds ${maxBytes} bytes`, response.status);
|
|
364
|
+
}
|
|
365
|
+
chunks.push(value);
|
|
366
|
+
}
|
|
367
|
+
const merged = new Uint8Array(total);
|
|
368
|
+
let offset = 0;
|
|
369
|
+
for (const chunk of chunks) {
|
|
370
|
+
merged.set(chunk, offset);
|
|
371
|
+
offset += chunk.byteLength;
|
|
372
|
+
}
|
|
373
|
+
return merged;
|
|
374
|
+
};
|
|
375
|
+
const decodeUtf8 = (bytes) => new TextDecoder("utf-8").decode(bytes);
|
|
376
|
+
const hasContentType = (response, expected) => (response.headers.get("content-type") ?? "").toLowerCase().startsWith(expected);
|
|
377
|
+
const parseRetryAfter = (raw) => {
|
|
378
|
+
if (raw == null) return null;
|
|
379
|
+
const seconds = Number(raw);
|
|
380
|
+
return Number.isInteger(seconds) && seconds >= 0 ? seconds : null;
|
|
381
|
+
};
|
|
382
|
+
const readStringField = (record, key) => {
|
|
383
|
+
const value = record[key];
|
|
384
|
+
return typeof value === "string" ? value : null;
|
|
385
|
+
};
|
|
386
|
+
const isRecord = (value) => typeof value === "object" && value != null && !Array.isArray(value);
|
|
387
|
+
const toApiError = async (response, maxBytes) => {
|
|
388
|
+
const requestId = response.headers.get(REQUEST_ID_HEADER);
|
|
389
|
+
const retryAfterSeconds = parseRetryAfter(response.headers.get(RETRY_AFTER_HEADER));
|
|
390
|
+
let text;
|
|
391
|
+
try {
|
|
392
|
+
text = decodeUtf8(await readBodyWithLimit(response, maxBytes));
|
|
393
|
+
} catch {
|
|
394
|
+
text = "";
|
|
395
|
+
}
|
|
396
|
+
if (hasContentType(response, JSON_CONTENT_TYPE)) try {
|
|
397
|
+
const parsed = JSON.parse(text);
|
|
398
|
+
const envelope = isRecord(parsed) && isRecord(parsed["error"]) ? parsed["error"] : null;
|
|
399
|
+
if (envelope != null) return new ApiError({
|
|
400
|
+
status: response.status,
|
|
401
|
+
code: readStringField(envelope, "code"),
|
|
402
|
+
message: readStringField(envelope, "message") ?? response.statusText,
|
|
403
|
+
detail: readStringField(envelope, "detail"),
|
|
404
|
+
requestId: readStringField(envelope, "requestId") ?? requestId,
|
|
405
|
+
retryAfterSeconds
|
|
406
|
+
});
|
|
407
|
+
} catch {}
|
|
408
|
+
return new ApiError({
|
|
409
|
+
status: response.status,
|
|
410
|
+
code: null,
|
|
411
|
+
message: text.slice(0, MAX_ERROR_TEXT_CHARS) || response.statusText || "HTTP error",
|
|
412
|
+
detail: null,
|
|
413
|
+
requestId,
|
|
414
|
+
retryAfterSeconds
|
|
415
|
+
});
|
|
416
|
+
};
|
|
417
|
+
const parseContentDispositionFileName = (header) => {
|
|
418
|
+
if (header == null) return null;
|
|
419
|
+
const encoded = /filename\*=UTF-8''([^;]+)/i.exec(header);
|
|
420
|
+
if (encoded?.[1] != null) try {
|
|
421
|
+
return decodeURIComponent(encoded[1].trim());
|
|
422
|
+
} catch {
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
return /filename="?([^";]+)"?/i.exec(header)?.[1]?.trim() ?? null;
|
|
426
|
+
};
|
|
427
|
+
const toTransportError = (error) => {
|
|
428
|
+
if (error instanceof TransportError) return error;
|
|
429
|
+
if (error instanceof Error && error.name === "AbortError") return new TransportError("timeout", "the API did not respond in time");
|
|
430
|
+
return new TransportError("network", error instanceof Error ? error.message : "network failure");
|
|
431
|
+
};
|
|
432
|
+
const createApiClient = (deps, limits = DEFAULT_API_CLIENT_LIMITS) => {
|
|
433
|
+
const { config, workspaceId, fetch, now, sleep, logger } = deps;
|
|
434
|
+
const performOnce = async (request) => {
|
|
435
|
+
const url = buildRequestUrl(config.baseUrl, request);
|
|
436
|
+
const headers = new Headers({
|
|
437
|
+
authorization: `Bearer ${config.apiKey}`,
|
|
438
|
+
accept: acceptFor(request.responseKind),
|
|
439
|
+
"user-agent": `flyle-ai-contact-center-mcp/${MCP_SERVER_VERSION}`,
|
|
440
|
+
[CLIENT_WORKSPACE_HEADER]: workspaceId
|
|
441
|
+
});
|
|
442
|
+
let bodyText;
|
|
443
|
+
if (request.hasBody) {
|
|
444
|
+
bodyText = JSON.stringify(request.body ?? {});
|
|
445
|
+
headers.set("content-type", JSON_CONTENT_TYPE);
|
|
446
|
+
}
|
|
447
|
+
const controller = new AbortController();
|
|
448
|
+
const headersTimer = setTimeout(() => controller.abort(), limits.headersTimeoutMs);
|
|
449
|
+
let response;
|
|
450
|
+
try {
|
|
451
|
+
response = await fetch(url, {
|
|
452
|
+
method: request.method,
|
|
453
|
+
headers,
|
|
454
|
+
...bodyText == null ? {} : { body: bodyText },
|
|
455
|
+
redirect: "manual",
|
|
456
|
+
signal: controller.signal
|
|
457
|
+
});
|
|
458
|
+
} catch (err) {
|
|
459
|
+
clearTimeout(headersTimer);
|
|
460
|
+
throw toTransportError(err);
|
|
461
|
+
}
|
|
462
|
+
if (response.status >= 300 && response.status < 400) {
|
|
463
|
+
clearTimeout(headersTimer);
|
|
464
|
+
await response.body?.cancel().catch(ignoreCleanupError);
|
|
465
|
+
throw new TransportError("redirect_not_followed", "redirect responses are not followed", response.status);
|
|
466
|
+
}
|
|
467
|
+
if (!response.ok) try {
|
|
468
|
+
throw await toApiError(response, limits.maxJsonBytes);
|
|
469
|
+
} finally {
|
|
470
|
+
clearTimeout(headersTimer);
|
|
471
|
+
}
|
|
472
|
+
const requestId = response.headers.get(REQUEST_ID_HEADER);
|
|
473
|
+
switch (request.responseKind) {
|
|
474
|
+
case "json": try {
|
|
475
|
+
if (!hasContentType(response, JSON_CONTENT_TYPE)) {
|
|
476
|
+
await response.body?.cancel().catch(ignoreCleanupError);
|
|
477
|
+
throw new TransportError("unexpected_content_type", "expected an application/json response", response.status);
|
|
478
|
+
}
|
|
479
|
+
const text = decodeUtf8(await readBodyWithLimit(response, limits.maxJsonBytes));
|
|
480
|
+
let body;
|
|
481
|
+
try {
|
|
482
|
+
body = JSON.parse(text);
|
|
483
|
+
} catch {
|
|
484
|
+
throw new TransportError("invalid_json", "the response body is not valid JSON", response.status);
|
|
485
|
+
}
|
|
486
|
+
return {
|
|
487
|
+
kind: "json",
|
|
488
|
+
status: response.status,
|
|
489
|
+
requestId,
|
|
490
|
+
body
|
|
491
|
+
};
|
|
492
|
+
} catch (err) {
|
|
493
|
+
throw toTransportError(err);
|
|
494
|
+
} finally {
|
|
495
|
+
clearTimeout(headersTimer);
|
|
496
|
+
}
|
|
497
|
+
case "empty":
|
|
498
|
+
clearTimeout(headersTimer);
|
|
499
|
+
await response.body?.cancel().catch(ignoreCleanupError);
|
|
500
|
+
return {
|
|
501
|
+
kind: "empty",
|
|
502
|
+
status: response.status,
|
|
503
|
+
requestId
|
|
504
|
+
};
|
|
505
|
+
case "sse": {
|
|
506
|
+
clearTimeout(headersTimer);
|
|
507
|
+
if (!hasContentType(response, SSE_CONTENT_TYPE)) {
|
|
508
|
+
await response.body?.cancel().catch(ignoreCleanupError);
|
|
509
|
+
throw new TransportError("unexpected_content_type", "expected a text/event-stream response", response.status);
|
|
510
|
+
}
|
|
511
|
+
if (response.body == null) throw new TransportError("empty_body", "the stream has no body", response.status);
|
|
512
|
+
const collected = await collectSseEvents(response.body, {
|
|
513
|
+
deadlineAt: now() + limits.sseTotalMs,
|
|
514
|
+
maxEvents: limits.sseMaxEvents,
|
|
515
|
+
maxBytes: limits.sseMaxBytes,
|
|
516
|
+
now
|
|
517
|
+
});
|
|
518
|
+
return {
|
|
519
|
+
kind: "sse",
|
|
520
|
+
status: response.status,
|
|
521
|
+
requestId,
|
|
522
|
+
...collected
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
case "binary":
|
|
526
|
+
clearTimeout(headersTimer);
|
|
527
|
+
if (response.body == null) throw new TransportError("empty_body", "the download has no body", response.status);
|
|
528
|
+
return {
|
|
529
|
+
kind: "binary",
|
|
530
|
+
status: response.status,
|
|
531
|
+
requestId,
|
|
532
|
+
contentType: response.headers.get("content-type"),
|
|
533
|
+
fileName: parseContentDispositionFileName(response.headers.get("content-disposition")),
|
|
534
|
+
body: response.body
|
|
535
|
+
};
|
|
536
|
+
default:
|
|
537
|
+
request.responseKind;
|
|
538
|
+
throw new Error(`unknown response kind ${String(request.responseKind)}`);
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
/**
|
|
542
|
+
* 再送は「GET かつ本文が JSON / 空」の要求だけ、429(`Retry-After` が上限内)と接続失敗で 1 回。
|
|
543
|
+
* 書き込みは冪等でも再送しない(設計書 INV-C06「クライアントは書き込みを自動再送しない」)。
|
|
544
|
+
*/
|
|
545
|
+
const request = async (req) => {
|
|
546
|
+
const canRetry = req.method === "GET" && (req.responseKind === "json" || req.responseKind === "empty");
|
|
547
|
+
try {
|
|
548
|
+
return await performOnce(req);
|
|
549
|
+
} catch (err) {
|
|
550
|
+
if (!canRetry) throw err;
|
|
551
|
+
if (err instanceof ApiError && err.info.status === 429 && err.info.retryAfterSeconds != null && err.info.retryAfterSeconds <= limits.maxRetryAfterSeconds) {
|
|
552
|
+
logger.warn("rate limited; retrying once", {
|
|
553
|
+
path: req.path,
|
|
554
|
+
retryAfterSeconds: err.info.retryAfterSeconds
|
|
555
|
+
});
|
|
556
|
+
await sleep(err.info.retryAfterSeconds * 1e3);
|
|
557
|
+
return await performOnce(req);
|
|
558
|
+
}
|
|
559
|
+
if (err instanceof TransportError && err.kind === "network") {
|
|
560
|
+
logger.warn("network failure; retrying once", { path: req.path });
|
|
561
|
+
return await performOnce(req);
|
|
562
|
+
}
|
|
563
|
+
throw err;
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
return { request };
|
|
567
|
+
};
|
|
568
|
+
//#endregion
|
|
569
|
+
//#region ../../../packages/universal/utils/src/object.ts
|
|
570
|
+
function hasOwn(obj, key) {
|
|
571
|
+
return Object.hasOwn(obj, key);
|
|
572
|
+
}
|
|
573
|
+
/** 指定した key が object の own property かどうかを判定する。 */
|
|
574
|
+
const isOwnKey = (object, key) => hasOwn(object, key);
|
|
575
|
+
/**
|
|
576
|
+
* Object.keys の型安全な代替関数。
|
|
577
|
+
* Object.keys は string[] を返すが、この関数はオブジェクトのキーの型を保持した配列を返す。
|
|
578
|
+
*/
|
|
579
|
+
const objectKeys = (object) => {
|
|
580
|
+
const keys = [];
|
|
581
|
+
for (const key in object) if (isOwnKey(object, key)) keys.push(key);
|
|
582
|
+
return keys;
|
|
583
|
+
};
|
|
584
|
+
/**
|
|
585
|
+
* Object.entries の型安全な代替関数。
|
|
586
|
+
* Object.entries は [string, T][] を返すが、この関数はキーの型を保持したペア配列を返す。
|
|
587
|
+
*/
|
|
588
|
+
const objectEntries = (object) => {
|
|
589
|
+
const entries = [];
|
|
590
|
+
for (const key in object) if (isOwnKey(object, key)) {
|
|
591
|
+
const value = Reflect.get(object, key);
|
|
592
|
+
entries.push([key, value]);
|
|
593
|
+
}
|
|
594
|
+
return entries;
|
|
595
|
+
};
|
|
596
|
+
/**
|
|
597
|
+
* unknown 値が非配列の plain object であるかを判定する型ガード。
|
|
598
|
+
* `typeof === 'object'` + `null` チェック + `Array.isArray` を一括で行い、
|
|
599
|
+
* `as Record<string, unknown>` キャストを不要にする。
|
|
600
|
+
*/
|
|
601
|
+
const isPlainObject = (value) => {
|
|
602
|
+
return typeof value === "object" && value != null && !Array.isArray(value);
|
|
603
|
+
};
|
|
604
|
+
//#endregion
|
|
605
|
+
//#region src/workspace.ts
|
|
606
|
+
/** 作業ディレクトリ直下に置く MCP の状態ディレクトリ。ダウンロード既定先もこの下。 */
|
|
607
|
+
const WORKSPACE_STATE_DIR = ".flyle-ai-contact-center-mcp";
|
|
608
|
+
const BINDING_FILE = "workspace.json";
|
|
609
|
+
const BINDING_FILE_VERSION = 1;
|
|
610
|
+
const WORKSPACE_ID_LENGTH = 32;
|
|
611
|
+
const bindingFilePath = (dir) => path.join(dir, WORKSPACE_STATE_DIR, BINDING_FILE);
|
|
612
|
+
const isNotFound = (err) => err instanceof Error && hasOwn(err, "code") && err.code === "ENOENT";
|
|
613
|
+
const isAlreadyExists = (err) => err instanceof Error && hasOwn(err, "code") && err.code === "EEXIST";
|
|
614
|
+
/** 束縛ファイルの tenantName。無ければ null。壊れていたら上書きせず止める(黙って別テナントに付け替えない)。 */
|
|
615
|
+
const readBoundTenant = async (dir) => {
|
|
616
|
+
let raw;
|
|
617
|
+
try {
|
|
618
|
+
raw = await readFile(bindingFilePath(dir), "utf8");
|
|
619
|
+
} catch (err) {
|
|
620
|
+
if (isNotFound(err)) return null;
|
|
621
|
+
throw err;
|
|
622
|
+
}
|
|
623
|
+
let parsed;
|
|
624
|
+
try {
|
|
625
|
+
parsed = JSON.parse(raw);
|
|
626
|
+
} catch {
|
|
627
|
+
parsed = null;
|
|
628
|
+
}
|
|
629
|
+
if (!isPlainObject(parsed) || typeof parsed["tenantName"] !== "string") throw new WorkspaceBindingError("corrupted", `${bindingFilePath(dir)} is not a valid workspace binding. Inspect it manually; the MCP server never overwrites it.`);
|
|
630
|
+
return parsed["tenantName"];
|
|
631
|
+
};
|
|
632
|
+
const mismatchError = (dir, boundTenant, tenantName) => new WorkspaceBindingError("tenant_mismatch", `${dir} is dedicated to tenant "${boundTenant}", but this server is configured for "${tenantName}". Use a separate directory (and a separate Claude Code session) for each tenant.`);
|
|
633
|
+
const ancestorsOf = (dir) => {
|
|
634
|
+
const result = [];
|
|
635
|
+
let current = path.dirname(dir);
|
|
636
|
+
while (current !== path.dirname(current)) {
|
|
637
|
+
result.push(current);
|
|
638
|
+
current = path.dirname(current);
|
|
639
|
+
}
|
|
640
|
+
result.push(current);
|
|
641
|
+
return result;
|
|
642
|
+
};
|
|
643
|
+
/** 別テナントのディレクトリの中に入れ子で作業させない。 */
|
|
644
|
+
const assertNoForeignAncestor = async (root, tenantName) => {
|
|
645
|
+
const bound = await Promise.all(ancestorsOf(root).map(async (dir) => ({
|
|
646
|
+
dir,
|
|
647
|
+
tenant: await readBoundTenant(dir)
|
|
648
|
+
})));
|
|
649
|
+
for (const { dir, tenant } of bound) if (tenant != null && tenant !== tenantName) throw mismatchError(dir, tenant, tenantName);
|
|
650
|
+
};
|
|
651
|
+
const computeWorkspaceId = (env, root) => createHash("sha256").update(`${env.hostname}\0${env.username}\0${root}`).digest("base64url").slice(0, WORKSPACE_ID_LENGTH);
|
|
652
|
+
/** 束縛ファイルを排他作成する。同時起動した別テナントの MCP と競っても勝者は 1 つ。 */
|
|
653
|
+
const claimOrVerify = async (root, tenantName, now) => {
|
|
654
|
+
const content = {
|
|
655
|
+
version: BINDING_FILE_VERSION,
|
|
656
|
+
tenantName,
|
|
657
|
+
boundAt: now.toISOString()
|
|
658
|
+
};
|
|
659
|
+
await mkdir(path.join(root, WORKSPACE_STATE_DIR), { recursive: true });
|
|
660
|
+
try {
|
|
661
|
+
await writeFile(bindingFilePath(root), `${JSON.stringify(content, null, 2)}\n`, { flag: "wx" });
|
|
662
|
+
return;
|
|
663
|
+
} catch (err) {
|
|
664
|
+
if (!isAlreadyExists(err)) throw err;
|
|
665
|
+
}
|
|
666
|
+
const bound = await readBoundTenant(root);
|
|
667
|
+
if (bound !== tenantName) throw mismatchError(root, bound ?? "(unknown)", tenantName);
|
|
668
|
+
};
|
|
669
|
+
/**
|
|
670
|
+
* 作業ディレクトリを 1 テナント専用にする。Claude Code の記憶・会話履歴・CLAUDE.md・ダウンロード物は
|
|
671
|
+
* ディレクトリ単位で残るので、ディレクトリを分ければテナントをまたいだ持ち込みの経路がまとめて切れる。
|
|
672
|
+
*/
|
|
673
|
+
const bindWorkspace = async (env, tenantName) => {
|
|
674
|
+
const root = await realpath(env.cwd);
|
|
675
|
+
if (root === await realpath(env.homeDir).catch(() => env.homeDir) || root === path.parse(root).root) throw new WorkspaceBindingError("shared_directory", `${root} is shared by everything you do and cannot be dedicated to one tenant. Create a directory for this tenant (e.g. ~/flyle-cs/<tenant>) and start the session there.`);
|
|
676
|
+
await assertNoForeignAncestor(root, tenantName);
|
|
677
|
+
await claimOrVerify(root, tenantName, env.now());
|
|
678
|
+
return {
|
|
679
|
+
root,
|
|
680
|
+
tenantName,
|
|
681
|
+
workspaceId: computeWorkspaceId(env, root)
|
|
682
|
+
};
|
|
683
|
+
};
|
|
684
|
+
/** 起動後に束縛ファイルが差し替えられていないかを操作ごとに確かめる。消えていれば同じテナントで張り直す。 */
|
|
685
|
+
const assertWorkspaceBinding = async (workspace, now) => {
|
|
686
|
+
await claimOrVerify(workspace.root, workspace.tenantName, now);
|
|
687
|
+
};
|
|
688
|
+
const outsideWorkspaceError = (root, target, label) => new WorkspaceBindingError("outside_workspace", `${label} (${target}) must be inside the tenant workspace ${root}; a shared location could mix files between tenants.`);
|
|
689
|
+
/** 起動設定のパス(ファイルの読込元・保存先)が作業ディレクトリの外を指していないか。未作成のパスもあるので字面で比べる。 */
|
|
690
|
+
const assertConfiguredPathInsideWorkspace = (cwd, target, label) => {
|
|
691
|
+
const relative = path.relative(path.resolve(cwd), target);
|
|
692
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw outsideWorkspaceError(cwd, target, label);
|
|
693
|
+
};
|
|
694
|
+
/** 読むファイルが、作業ディレクトリの中に作られた別テナントの作業ディレクトリに属していないか。 */
|
|
695
|
+
const assertFileBelongsToWorkspace = async (workspace, realFilePath) => {
|
|
696
|
+
if (!realFilePath.startsWith(`${workspace.root}${path.sep}`)) throw outsideWorkspaceError(workspace.root, realFilePath, "the file");
|
|
697
|
+
const between = [];
|
|
698
|
+
for (let dir = path.dirname(realFilePath); dir !== workspace.root; dir = path.dirname(dir)) between.push(dir);
|
|
699
|
+
const bound = await Promise.all(between.map(async (dir) => ({
|
|
700
|
+
dir,
|
|
701
|
+
tenant: await readBoundTenant(dir)
|
|
702
|
+
})));
|
|
703
|
+
for (const { dir, tenant } of bound) if (tenant != null && tenant !== workspace.tenantName) throw mismatchError(dir, tenant, workspace.tenantName);
|
|
704
|
+
};
|
|
705
|
+
//#endregion
|
|
706
|
+
//#region src/tools/local-files.ts
|
|
707
|
+
/**
|
|
708
|
+
* 通常ファイルだけを上限付きで読む。FIFO は open 自体が writer 待ちで止まるので O_NONBLOCK で開き、同じ handle の
|
|
709
|
+
* fstat で種類と大きさを見る。最終要素のリンク差し替えは O_NOFOLLOW で拒み、stat 後に伸びた分は読後の再検査で弾く。
|
|
710
|
+
*/
|
|
711
|
+
const openRegularFile = async (filePath) => {
|
|
712
|
+
const handle = await open(filePath, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
|
|
713
|
+
try {
|
|
714
|
+
const info = await handle.stat();
|
|
715
|
+
if (!info.isFile()) throw new LocalFileError("not_a_file", "only regular files can be read");
|
|
716
|
+
return {
|
|
717
|
+
handle,
|
|
718
|
+
size: info.size
|
|
719
|
+
};
|
|
720
|
+
} catch (err) {
|
|
721
|
+
await handle.close();
|
|
722
|
+
throw err;
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
const READ_CHUNK_BYTES = 1048576;
|
|
726
|
+
const readRegularFileBounded = async (filePath, maxBytes) => {
|
|
727
|
+
const { handle, size } = await openRegularFile(filePath);
|
|
728
|
+
try {
|
|
729
|
+
if (size > maxBytes) throw new LocalFileError("too_large", `the file exceeds ${String(maxBytes)} bytes`);
|
|
730
|
+
const chunks = [];
|
|
731
|
+
let total = 0;
|
|
732
|
+
for (;;) {
|
|
733
|
+
const chunk = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, maxBytes - total + 1));
|
|
734
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
|
|
735
|
+
if (bytesRead === 0) break;
|
|
736
|
+
total += bytesRead;
|
|
737
|
+
if (total > maxBytes) throw new LocalFileError("too_large", `the file exceeds ${String(maxBytes)} bytes`);
|
|
738
|
+
chunks.push(chunk.subarray(0, bytesRead));
|
|
739
|
+
}
|
|
740
|
+
return Buffer.concat(chunks, total);
|
|
741
|
+
} finally {
|
|
742
|
+
await handle.close();
|
|
743
|
+
}
|
|
744
|
+
};
|
|
745
|
+
const isWithin = (target, root) => target === root || target.startsWith(`${root}${path.sep}`);
|
|
746
|
+
/**
|
|
747
|
+
* 利用者が信頼する roots を前提に、LLM が渡すパス文字列の範囲外参照を realpath で拒否する。
|
|
748
|
+
* 検証から open までに親ディレクトリを差し替える並行ローカル主体は脅威モデルに含めない。
|
|
749
|
+
*/
|
|
750
|
+
const resolveWithinRoots = async (rawPath, roots, cwd, workspace) => {
|
|
751
|
+
const absolute = path.resolve(cwd, rawPath);
|
|
752
|
+
let real;
|
|
753
|
+
try {
|
|
754
|
+
real = await realpath(absolute);
|
|
755
|
+
} catch {
|
|
756
|
+
throw new LocalFileError("not_found", `file not found: ${rawPath}`);
|
|
757
|
+
}
|
|
758
|
+
if (!(await Promise.all(roots.map((root) => realpath(root).catch(() => root)))).some((root) => isWithin(real, root))) throw new LocalFileError("outside_roots", "the path is outside the configured file roots");
|
|
759
|
+
await assertFileBelongsToWorkspace(workspace, real);
|
|
760
|
+
return real;
|
|
761
|
+
};
|
|
762
|
+
const MIME_BY_EXTENSION = /* @__PURE__ */ new Map([
|
|
763
|
+
[".md", "text/markdown"],
|
|
764
|
+
[".markdown", "text/markdown"],
|
|
765
|
+
[".txt", "text/plain"],
|
|
766
|
+
[".csv", "text/csv"],
|
|
767
|
+
[".json", "application/json"],
|
|
768
|
+
[".html", "text/html"],
|
|
769
|
+
[".htm", "text/html"],
|
|
770
|
+
[".pdf", "application/pdf"],
|
|
771
|
+
[".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
|
|
772
|
+
[".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
|
|
773
|
+
[".pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"],
|
|
774
|
+
[".png", "image/png"],
|
|
775
|
+
[".jpg", "image/jpeg"],
|
|
776
|
+
[".jpeg", "image/jpeg"],
|
|
777
|
+
[".zip", "application/zip"]
|
|
778
|
+
]);
|
|
779
|
+
const DEFAULT_MIME_TYPE = "application/octet-stream";
|
|
780
|
+
const guessMimeType = (fileName) => MIME_BY_EXTENSION.get(path.extname(fileName).toLowerCase()) ?? DEFAULT_MIME_TYPE;
|
|
781
|
+
const hashStream = (stream) => new Promise((resolve, reject) => {
|
|
782
|
+
const hash = createHash("sha256");
|
|
783
|
+
stream.on("data", (chunk) => hash.update(chunk)).on("error", reject).on("end", () => resolve(hash.digest()));
|
|
784
|
+
});
|
|
785
|
+
/** アップロード前の申告値(名前・サイズ・sha256・MIME)をローカルで計算する。 */
|
|
786
|
+
const inspectLocalFile = async (rawPath, roots, cwd, workspace) => {
|
|
787
|
+
const resolved = await resolveWithinRoots(rawPath, roots, cwd, workspace);
|
|
788
|
+
const { handle, size } = await openRegularFile(resolved);
|
|
789
|
+
let digest;
|
|
790
|
+
try {
|
|
791
|
+
digest = await hashStream(handle.createReadStream());
|
|
792
|
+
} finally {
|
|
793
|
+
await handle.close();
|
|
794
|
+
}
|
|
795
|
+
const fileName = path.basename(resolved);
|
|
796
|
+
return {
|
|
797
|
+
path: resolved,
|
|
798
|
+
fileName,
|
|
799
|
+
sizeBytes: size,
|
|
800
|
+
sha256Hex: digest.toString("hex"),
|
|
801
|
+
sha256Base64: digest.toString("base64"),
|
|
802
|
+
mimeType: guessMimeType(fileName)
|
|
803
|
+
};
|
|
804
|
+
};
|
|
805
|
+
//#endregion
|
|
806
|
+
//#region src/http/transfer.ts
|
|
807
|
+
const createPresignedUrlRegistry = (now, ttlMs = PRESIGNED_URL_TTL_MS, maxEntries = PRESIGNED_URL_REGISTRY_MAX) => {
|
|
808
|
+
const entries = /* @__PURE__ */ new Map();
|
|
809
|
+
const evictExpired = () => {
|
|
810
|
+
const current = now();
|
|
811
|
+
for (const [url, entry] of entries) if (entry.expiresAt <= current) entries.delete(url);
|
|
812
|
+
};
|
|
813
|
+
return {
|
|
814
|
+
get size() {
|
|
815
|
+
return entries.size;
|
|
816
|
+
},
|
|
817
|
+
register: (url, kind, headers) => {
|
|
818
|
+
evictExpired();
|
|
819
|
+
while (entries.size >= maxEntries) {
|
|
820
|
+
const oldest = entries.keys().next();
|
|
821
|
+
if (oldest.done) break;
|
|
822
|
+
entries.delete(oldest.value);
|
|
823
|
+
}
|
|
824
|
+
entries.delete(url);
|
|
825
|
+
entries.set(url, {
|
|
826
|
+
kind,
|
|
827
|
+
headers,
|
|
828
|
+
expiresAt: now() + ttlMs
|
|
829
|
+
});
|
|
830
|
+
},
|
|
831
|
+
take: (url, kind) => {
|
|
832
|
+
const entry = entries.get(url);
|
|
833
|
+
if (entry?.kind !== kind) throw new TransferError("unknown_url", "the URL was not returned by a recent API call for this kind of transfer");
|
|
834
|
+
entries.delete(url);
|
|
835
|
+
if (entry.expiresAt <= now()) throw new TransferError("expired_url", "the signed URL has expired; call the API again to get a new one");
|
|
836
|
+
return entry;
|
|
837
|
+
}
|
|
838
|
+
};
|
|
839
|
+
};
|
|
840
|
+
/** 署名付き URL は https かつ許可されたストレージホストに限る(設計書「MCP の実行契約」)。 */
|
|
841
|
+
const assertAllowedStorageUrl = (raw, config) => {
|
|
842
|
+
let url;
|
|
843
|
+
try {
|
|
844
|
+
url = new URL(raw);
|
|
845
|
+
} catch {
|
|
846
|
+
throw new TransferError("not_allowed_host", "the transfer URL is not a valid URL");
|
|
847
|
+
}
|
|
848
|
+
const local = config.allowInsecureHttp && isLocalHost(url.hostname);
|
|
849
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) throw new TransferError("insecure_url", "transfer URLs must use https");
|
|
850
|
+
if (url.username !== "" || url.password !== "") throw new TransferError("not_allowed_host", "transfer URLs must not contain credentials");
|
|
851
|
+
const host = url.hostname.toLowerCase();
|
|
852
|
+
if (!(local || config.storageHostSuffixes.some((suffix) => suffix.startsWith(".") ? host.endsWith(suffix) : host === suffix))) throw new TransferError("not_allowed_host", `the host ${host} is not an allowed storage host`);
|
|
853
|
+
return url;
|
|
854
|
+
};
|
|
855
|
+
const unescapePointerToken$1 = (token) => token.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
856
|
+
const isUnknownArray = (value) => Array.isArray(value);
|
|
857
|
+
/** JSON Pointer を辿る。`*` は配列の全要素に展開するので結果は配列。 */
|
|
858
|
+
const resolvePointer = (body, pointer) => {
|
|
859
|
+
if (pointer === "") return [body];
|
|
860
|
+
const tokens = pointer.split("/").slice(1).map((token) => unescapePointerToken$1(token));
|
|
861
|
+
let current = [body];
|
|
862
|
+
for (const token of tokens) {
|
|
863
|
+
const next = [];
|
|
864
|
+
for (const node of current) if (token === "*") {
|
|
865
|
+
if (isUnknownArray(node)) next.push(...node);
|
|
866
|
+
} else if (isUnknownArray(node)) {
|
|
867
|
+
const index = Number(token);
|
|
868
|
+
if (Number.isInteger(index) && index >= 0 && index < node.length) next.push(node[index]);
|
|
869
|
+
} else if (isPlainObject(node) && hasOwn(node, token)) next.push(node[token]);
|
|
870
|
+
current = next;
|
|
871
|
+
}
|
|
872
|
+
return current;
|
|
873
|
+
};
|
|
874
|
+
const toStringHeaders = (value) => {
|
|
875
|
+
if (!isPlainObject(value)) return {};
|
|
876
|
+
const headers = {};
|
|
877
|
+
for (const [key, item] of objectEntries(value)) if (typeof item === "string") headers[key] = item;
|
|
878
|
+
return headers;
|
|
879
|
+
};
|
|
880
|
+
/**
|
|
881
|
+
* 応答本文から署名付き URL を取り出す。`x-transfer` の pointer が指す位置だけを見る。注記の無い操作は
|
|
882
|
+
* 何も取り出さない(本文中の任意の URL らしき値を転送先に昇格させない。利用者データ由来の URL が
|
|
883
|
+
* upload 登録されると、後続の `upload_file` でローカルファイルを外へ送る経路になる)。
|
|
884
|
+
*/
|
|
885
|
+
const extractTransferUrls = (body, transfer) => {
|
|
886
|
+
if (transfer == null) return [];
|
|
887
|
+
const urls = resolvePointer(body, transfer.urlPointer).filter((value) => typeof value === "string");
|
|
888
|
+
const headerSets = transfer.headersPointer == null ? [] : resolvePointer(body, transfer.headersPointer).map((headers) => toStringHeaders(headers));
|
|
889
|
+
return urls.map((url, index) => ({
|
|
890
|
+
url,
|
|
891
|
+
kind: transfer.kind,
|
|
892
|
+
headers: headerSets.length === 1 ? headerSets[0] ?? {} : headerSets[index] ?? {}
|
|
893
|
+
}));
|
|
894
|
+
};
|
|
895
|
+
/** ストレージのエラー本文は先頭だけ読む(全量読みは上限外のメモリ消費になる)。 */
|
|
896
|
+
const MAX_ERROR_TEXT_BYTES = 4096;
|
|
897
|
+
const readErrorTextBounded = async (response) => {
|
|
898
|
+
if (response.body == null) return "";
|
|
899
|
+
const reader = response.body.getReader();
|
|
900
|
+
const chunks = [];
|
|
901
|
+
let total = 0;
|
|
902
|
+
try {
|
|
903
|
+
while (total < MAX_ERROR_TEXT_BYTES) {
|
|
904
|
+
const { done, value } = await reader.read();
|
|
905
|
+
if (done) break;
|
|
906
|
+
chunks.push(value);
|
|
907
|
+
total += value.byteLength;
|
|
908
|
+
}
|
|
909
|
+
} catch {} finally {
|
|
910
|
+
await reader.cancel().catch(ignoreCleanupError);
|
|
911
|
+
}
|
|
912
|
+
return new TextDecoder("utf-8").decode(Buffer.concat(chunks)).slice(0, MAX_ERROR_TEXT_BYTES);
|
|
913
|
+
};
|
|
914
|
+
const toTransferFetchError = (error) => {
|
|
915
|
+
if (error instanceof TransferError) return error;
|
|
916
|
+
if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) return new TransferError("timeout", "the storage did not respond in time");
|
|
917
|
+
return new TransferError("network", error instanceof Error ? error.message : "network failure");
|
|
918
|
+
};
|
|
919
|
+
/** 署名付き URL へのアップロード。Authorization は絶対に付けない(署名が認証そのもの)。 */
|
|
920
|
+
const uploadFileToPresignedUrl = async (input) => {
|
|
921
|
+
if (objectKeys(input.headers).some((name) => name.toLowerCase() === "authorization")) throw new TransferError("invalid_headers", "transfer headers must not include authorization");
|
|
922
|
+
let bytes;
|
|
923
|
+
try {
|
|
924
|
+
bytes = await readRegularFileBounded(input.filePath, input.maxBytes);
|
|
925
|
+
} catch (err) {
|
|
926
|
+
if (err instanceof LocalFileError && (err.kind === "not_a_file" || err.kind === "too_large")) throw new TransferError(err.kind, err.kind === "not_a_file" ? "only regular files can be uploaded" : err.message);
|
|
927
|
+
throw err;
|
|
928
|
+
}
|
|
929
|
+
let response;
|
|
930
|
+
try {
|
|
931
|
+
response = await input.fetch(input.url, {
|
|
932
|
+
method: "PUT",
|
|
933
|
+
headers: input.headers,
|
|
934
|
+
body: bytes,
|
|
935
|
+
redirect: "manual",
|
|
936
|
+
signal: AbortSignal.timeout(input.timeoutMs)
|
|
937
|
+
});
|
|
938
|
+
} catch (err) {
|
|
939
|
+
throw toTransferFetchError(err);
|
|
940
|
+
}
|
|
941
|
+
if (response.status >= 300 && response.status < 400) {
|
|
942
|
+
await response.body?.cancel().catch(ignoreCleanupError);
|
|
943
|
+
throw new TransferError("redirect_not_followed", "redirect responses are not followed", response.status);
|
|
944
|
+
}
|
|
945
|
+
if (!response.ok) throw new TransferError("http_error", `upload failed: ${(await readErrorTextBounded(response)).slice(0, 300)}`, response.status);
|
|
946
|
+
await response.body?.cancel().catch(ignoreCleanupError);
|
|
947
|
+
return {
|
|
948
|
+
status: response.status,
|
|
949
|
+
etag: response.headers.get("etag"),
|
|
950
|
+
bytes: bytes.byteLength
|
|
951
|
+
};
|
|
952
|
+
};
|
|
953
|
+
const SAFE_FILE_NAME_PATTERN = /[^A-Za-z0-9._-]/g;
|
|
954
|
+
const MAX_FILE_NAME_LENGTH = 120;
|
|
955
|
+
const sanitizeFileName = (name, fallback) => {
|
|
956
|
+
const trimmed = path.basename(name ?? "").replace(SAFE_FILE_NAME_PATTERN, "_").replace(/^_+/, "").slice(0, MAX_FILE_NAME_LENGTH);
|
|
957
|
+
return trimmed === "" || /^\.+$/.test(trimmed) ? fallback : trimmed;
|
|
958
|
+
};
|
|
959
|
+
const buildUniqueFileName = (fileName) => {
|
|
960
|
+
return `${(/* @__PURE__ */ new Date()).toISOString().replaceAll(/[:.]/g, "-")}-${randomBytes(3).toString("hex")}-${fileName}`;
|
|
961
|
+
};
|
|
962
|
+
const readWithDeadline = async (reader, remainingMs) => {
|
|
963
|
+
if (remainingMs <= 0) return "timeout";
|
|
964
|
+
let timer = null;
|
|
965
|
+
const deadline = new Promise((resolve) => {
|
|
966
|
+
timer = setTimeout(() => resolve("timeout"), remainingMs);
|
|
967
|
+
});
|
|
968
|
+
try {
|
|
969
|
+
return await Promise.race([reader.read(), deadline]);
|
|
970
|
+
} finally {
|
|
971
|
+
if (timer != null) clearTimeout(timer);
|
|
972
|
+
}
|
|
973
|
+
};
|
|
974
|
+
/**
|
|
975
|
+
* ストリームを出力ディレクトリに書く。上限超過・期限切れは書きかけを消して失敗にする。
|
|
976
|
+
* 応答ヘッダ後に送信が止まる相手でも `timeoutMs` で必ず戻る。
|
|
977
|
+
*/
|
|
978
|
+
const saveStreamToFile = async (stream, options) => {
|
|
979
|
+
await mkdir(options.outputDir, { recursive: true });
|
|
980
|
+
const target = path.join(options.outputDir, buildUniqueFileName(options.fileName));
|
|
981
|
+
const handle = await open(target, "wx");
|
|
982
|
+
const hash = createHash("sha256");
|
|
983
|
+
const reader = stream.getReader();
|
|
984
|
+
const deadlineAt = Date.now() + options.timeoutMs;
|
|
985
|
+
let bytes = 0;
|
|
986
|
+
try {
|
|
987
|
+
for (;;) {
|
|
988
|
+
const result = await readWithDeadline(reader, deadlineAt - Date.now());
|
|
989
|
+
if (result === "timeout") {
|
|
990
|
+
await reader.cancel().catch(ignoreCleanupError);
|
|
991
|
+
throw new TransferError("timeout", `the download did not finish within ${options.timeoutMs} ms`);
|
|
992
|
+
}
|
|
993
|
+
const { done, value } = result;
|
|
994
|
+
if (done) break;
|
|
995
|
+
bytes += value.byteLength;
|
|
996
|
+
if (bytes > options.maxBytes) {
|
|
997
|
+
await reader.cancel().catch(ignoreCleanupError);
|
|
998
|
+
throw new TransferError("too_large", `the download exceeds ${options.maxBytes} bytes`);
|
|
999
|
+
}
|
|
1000
|
+
hash.update(value);
|
|
1001
|
+
await handle.write(value);
|
|
1002
|
+
}
|
|
1003
|
+
} catch (err) {
|
|
1004
|
+
await handle.close();
|
|
1005
|
+
await unlink(target).catch(ignoreCleanupError);
|
|
1006
|
+
throw toTransferFetchError(err);
|
|
1007
|
+
}
|
|
1008
|
+
await handle.close();
|
|
1009
|
+
return {
|
|
1010
|
+
path: target,
|
|
1011
|
+
bytes,
|
|
1012
|
+
sha256Hex: hash.digest("hex")
|
|
1013
|
+
};
|
|
1014
|
+
};
|
|
1015
|
+
/** 署名付き URL からのダウンロード。Authorization を付けず、リダイレクトも追跡しない。 */
|
|
1016
|
+
const downloadPresignedUrlToFile = async (input) => {
|
|
1017
|
+
let response;
|
|
1018
|
+
try {
|
|
1019
|
+
response = await input.fetch(input.url, {
|
|
1020
|
+
method: "GET",
|
|
1021
|
+
redirect: "manual",
|
|
1022
|
+
signal: AbortSignal.timeout(input.timeoutMs)
|
|
1023
|
+
});
|
|
1024
|
+
} catch (err) {
|
|
1025
|
+
throw toTransferFetchError(err);
|
|
1026
|
+
}
|
|
1027
|
+
if (response.status >= 300 && response.status < 400) {
|
|
1028
|
+
await response.body?.cancel().catch(ignoreCleanupError);
|
|
1029
|
+
throw new TransferError("redirect_not_followed", "redirect responses are not followed", response.status);
|
|
1030
|
+
}
|
|
1031
|
+
if (!response.ok) throw new TransferError("http_error", `download failed: ${(await readErrorTextBounded(response)).slice(0, 300)}`, response.status);
|
|
1032
|
+
if (response.body == null) throw new TransferError("network", "the download has no body", response.status);
|
|
1033
|
+
return {
|
|
1034
|
+
...await saveStreamToFile(response.body, {
|
|
1035
|
+
outputDir: input.outputDir,
|
|
1036
|
+
fileName: input.fileName,
|
|
1037
|
+
maxBytes: input.maxBytes,
|
|
1038
|
+
timeoutMs: input.timeoutMs
|
|
1039
|
+
}),
|
|
1040
|
+
status: response.status,
|
|
1041
|
+
contentType: response.headers.get("content-type")
|
|
1042
|
+
};
|
|
1043
|
+
};
|
|
1044
|
+
//#endregion
|
|
1045
|
+
//#region src/log.ts
|
|
1046
|
+
/** 秘密値(API キー)をどの経路でも出さないための置換。空文字は置換対象にしない。 */
|
|
1047
|
+
const redactSecrets = (text, secrets) => {
|
|
1048
|
+
let result = text;
|
|
1049
|
+
for (const secret of secrets) {
|
|
1050
|
+
if (secret.length === 0) continue;
|
|
1051
|
+
result = result.split(secret).join(REDACTED_PLACEHOLDER);
|
|
1052
|
+
}
|
|
1053
|
+
return result;
|
|
1054
|
+
};
|
|
1055
|
+
const formatLine = (level, message, fields, secrets) => {
|
|
1056
|
+
const payload = {
|
|
1057
|
+
level,
|
|
1058
|
+
message,
|
|
1059
|
+
...fields,
|
|
1060
|
+
time: (/* @__PURE__ */ new Date()).toISOString()
|
|
1061
|
+
};
|
|
1062
|
+
return redactSecrets(JSON.stringify(payload), secrets);
|
|
1063
|
+
};
|
|
1064
|
+
/**
|
|
1065
|
+
* stdout は MCP の JSON-RPC 専用チャネルなので、ログは stderr にだけ書く。
|
|
1066
|
+
* `secrets` に渡した値は出力前に置換する。
|
|
1067
|
+
*/
|
|
1068
|
+
const createLogger = (secrets, sink = (line) => console.error(line)) => ({
|
|
1069
|
+
info: (message, fields) => sink(formatLine("info", message, fields, secrets)),
|
|
1070
|
+
warn: (message, fields) => sink(formatLine("warn", message, fields, secrets)),
|
|
1071
|
+
error: (message, fields) => sink(formatLine("error", message, fields, secrets))
|
|
1072
|
+
});
|
|
1073
|
+
//#endregion
|
|
1074
|
+
//#region src/manifest/types.ts
|
|
1075
|
+
const HTTP_METHODS = [
|
|
1076
|
+
"GET",
|
|
1077
|
+
"POST",
|
|
1078
|
+
"PUT",
|
|
1079
|
+
"PATCH",
|
|
1080
|
+
"DELETE"
|
|
1081
|
+
];
|
|
1082
|
+
const RESPONSE_KINDS = [
|
|
1083
|
+
"json",
|
|
1084
|
+
"empty",
|
|
1085
|
+
"sse",
|
|
1086
|
+
"binary"
|
|
1087
|
+
];
|
|
1088
|
+
const TRANSFER_KINDS = ["upload", "download"];
|
|
1089
|
+
/** MCP に載せる操作の効果。live は複数顧客を扱う CS の誤操作が直ちに本番へ出るため、MCP からは呼べない。 */
|
|
1090
|
+
const MCP_OPERATION_EFFECTS = ["read", "draft"];
|
|
1091
|
+
const INLINE_FILE_INPUT_FORMATS = ["wav-pcm16", "base64"];
|
|
1092
|
+
const INLINE_FILE_OUTPUT_FORMATS = ["pcm16le", "base64"];
|
|
1093
|
+
//#endregion
|
|
1094
|
+
//#region src/manifest/load.ts
|
|
1095
|
+
const GENERATED_MANIFEST_URL = new URL("../generated/manifest.json", import.meta.url);
|
|
1096
|
+
const fail = (detail) => {
|
|
1097
|
+
throw new ManifestLoadError(detail);
|
|
1098
|
+
};
|
|
1099
|
+
const isExplicitNull = (value) => value === null;
|
|
1100
|
+
const isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
1101
|
+
/** 生成器と同じ「2xx を昇順・重複なしで 1 つ以上」だけを受け付ける。 */
|
|
1102
|
+
const isSuccessStatusList = (value) => {
|
|
1103
|
+
if (!Array.isArray(value) || value.length === 0) return false;
|
|
1104
|
+
let previous = 0;
|
|
1105
|
+
for (const status of value) {
|
|
1106
|
+
if (typeof status !== "number" || !Number.isInteger(status)) return false;
|
|
1107
|
+
if (status < 200 || status >= 300 || status <= previous) return false;
|
|
1108
|
+
previous = status;
|
|
1109
|
+
}
|
|
1110
|
+
return true;
|
|
1111
|
+
};
|
|
1112
|
+
const isHttpMethod = (value) => typeof value === "string" && HTTP_METHODS.some((method) => method === value);
|
|
1113
|
+
const isResponseKind = (value) => typeof value === "string" && RESPONSE_KINDS.some((kind) => kind === value);
|
|
1114
|
+
const isTransferKind = (value) => typeof value === "string" && TRANSFER_KINDS.some((kind) => kind === value);
|
|
1115
|
+
const isMcpOperationEffect = (value) => typeof value === "string" && MCP_OPERATION_EFFECTS.some((effect) => effect === value);
|
|
1116
|
+
const parseTransfer = (value, where) => {
|
|
1117
|
+
if (isExplicitNull(value)) return null;
|
|
1118
|
+
if (!isPlainObject(value)) return fail(`${where}.transfer must be an object or null`);
|
|
1119
|
+
const { kind, urlPointer, headersPointer } = value;
|
|
1120
|
+
if (!isTransferKind(kind)) return fail(`${where}.transfer.kind is invalid`);
|
|
1121
|
+
if (typeof urlPointer !== "string") return fail(`${where}.transfer.urlPointer is invalid`);
|
|
1122
|
+
if (!isExplicitNull(headersPointer) && typeof headersPointer !== "string") return fail(`${where}.transfer.headersPointer is invalid`);
|
|
1123
|
+
return {
|
|
1124
|
+
kind,
|
|
1125
|
+
urlPointer,
|
|
1126
|
+
headersPointer
|
|
1127
|
+
};
|
|
1128
|
+
};
|
|
1129
|
+
const isInlineFileInputFormat = (value) => typeof value === "string" && INLINE_FILE_INPUT_FORMATS.some((format) => format === value);
|
|
1130
|
+
const isInlineFileOutputFormat = (value) => typeof value === "string" && INLINE_FILE_OUTPUT_FORMATS.some((format) => format === value);
|
|
1131
|
+
const isPositiveInteger = (value) => typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
1132
|
+
const parsePcm = (value, where) => {
|
|
1133
|
+
if (isExplicitNull(value)) return null;
|
|
1134
|
+
if (!isPlainObject(value)) return fail(`${where} must be an object or null`);
|
|
1135
|
+
const { sampleRateHz, channels } = value;
|
|
1136
|
+
if (!isPositiveInteger(sampleRateHz)) return fail(`${where}.sampleRateHz is invalid`);
|
|
1137
|
+
if (!isPositiveInteger(channels)) return fail(`${where}.channels is invalid`);
|
|
1138
|
+
return {
|
|
1139
|
+
sampleRateHz,
|
|
1140
|
+
channels
|
|
1141
|
+
};
|
|
1142
|
+
};
|
|
1143
|
+
const parseInlineFileInput = (value, where) => {
|
|
1144
|
+
if (!isPlainObject(value)) return fail(`${where} must be an object`);
|
|
1145
|
+
const { pointer, format, maxBytes, pcm } = value;
|
|
1146
|
+
if (typeof pointer !== "string" || !pointer.startsWith("/")) return fail(`${where}.pointer is invalid`);
|
|
1147
|
+
if (!isInlineFileInputFormat(format)) return fail(`${where}.format is invalid`);
|
|
1148
|
+
if (!isPositiveInteger(maxBytes)) return fail(`${where}.maxBytes is invalid`);
|
|
1149
|
+
const parsedPcm = parsePcm(pcm, `${where}.pcm`);
|
|
1150
|
+
if (format === "wav-pcm16" !== (parsedPcm != null)) return fail(`${where}.pcm does not match the format`);
|
|
1151
|
+
return {
|
|
1152
|
+
pointer,
|
|
1153
|
+
format,
|
|
1154
|
+
maxBytes,
|
|
1155
|
+
pcm: parsedPcm
|
|
1156
|
+
};
|
|
1157
|
+
};
|
|
1158
|
+
const parseInlineFileOutput = (value, where) => {
|
|
1159
|
+
if (!isPlainObject(value)) return fail(`${where} must be an object`);
|
|
1160
|
+
const { event, pointer, format, pcm, mediaType } = value;
|
|
1161
|
+
if (!isExplicitNull(event) && typeof event !== "string") return fail(`${where}.event is invalid`);
|
|
1162
|
+
if (typeof pointer !== "string" || !pointer.startsWith("/")) return fail(`${where}.pointer is invalid`);
|
|
1163
|
+
if (!isInlineFileOutputFormat(format)) return fail(`${where}.format is invalid`);
|
|
1164
|
+
if (!isExplicitNull(mediaType) && typeof mediaType !== "string") return fail(`${where}.mediaType is invalid`);
|
|
1165
|
+
const parsedPcm = parsePcm(pcm, `${where}.pcm`);
|
|
1166
|
+
if (format === "pcm16le" !== (parsedPcm != null)) return fail(`${where}.pcm does not match the format`);
|
|
1167
|
+
if (format === "base64" !== (mediaType != null)) return fail(`${where}.mediaType does not match the format`);
|
|
1168
|
+
return {
|
|
1169
|
+
event,
|
|
1170
|
+
pointer,
|
|
1171
|
+
format,
|
|
1172
|
+
pcm: parsedPcm,
|
|
1173
|
+
mediaType
|
|
1174
|
+
};
|
|
1175
|
+
};
|
|
1176
|
+
const parseInlineFiles = (value, where) => {
|
|
1177
|
+
if (isExplicitNull(value)) return null;
|
|
1178
|
+
if (!isPlainObject(value)) return fail(`${where}.inlineFiles must be an object or null`);
|
|
1179
|
+
const { input, output } = value;
|
|
1180
|
+
if (!Array.isArray(input) || !Array.isArray(output)) return fail(`${where}.inlineFiles is invalid`);
|
|
1181
|
+
if (input.length === 0 && output.length === 0) return fail(`${where}.inlineFiles must declare input or output`);
|
|
1182
|
+
return {
|
|
1183
|
+
input: input.map((item, index) => parseInlineFileInput(item, `${where}.inlineFiles.input[${String(index)}]`)),
|
|
1184
|
+
output: output.map((item, index) => parseInlineFileOutput(item, `${where}.inlineFiles.output[${String(index)}]`))
|
|
1185
|
+
};
|
|
1186
|
+
};
|
|
1187
|
+
const parseSseOutcome = (value, where) => {
|
|
1188
|
+
if (isExplicitNull(value)) return null;
|
|
1189
|
+
if (!isPlainObject(value)) return fail(`${where}.sseOutcome must be an object or null`);
|
|
1190
|
+
const { requiredEvents, terminalEvent, successPointer, successValue, failureEvents } = value;
|
|
1191
|
+
if (!isStringArray(requiredEvents)) return fail(`${where}.sseOutcome.requiredEvents is invalid`);
|
|
1192
|
+
if (typeof terminalEvent !== "string") return fail(`${where}.sseOutcome.terminalEvent is invalid`);
|
|
1193
|
+
if (isExplicitNull(successPointer) && isExplicitNull(successValue)) {
|
|
1194
|
+
if (!isStringArray(failureEvents)) return fail(`${where}.sseOutcome.failureEvents is invalid`);
|
|
1195
|
+
return {
|
|
1196
|
+
requiredEvents,
|
|
1197
|
+
terminalEvent,
|
|
1198
|
+
successPointer,
|
|
1199
|
+
successValue,
|
|
1200
|
+
failureEvents
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
if (typeof successPointer !== "string" || !successPointer.startsWith("/")) return fail(`${where}.sseOutcome.successPointer is invalid`);
|
|
1204
|
+
if (typeof successValue !== "string" && typeof successValue !== "number" && typeof successValue !== "boolean") return fail(`${where}.sseOutcome.successValue is invalid`);
|
|
1205
|
+
if (!isStringArray(failureEvents)) return fail(`${where}.sseOutcome.failureEvents is invalid`);
|
|
1206
|
+
return {
|
|
1207
|
+
requiredEvents,
|
|
1208
|
+
terminalEvent,
|
|
1209
|
+
successPointer,
|
|
1210
|
+
successValue,
|
|
1211
|
+
failureEvents
|
|
1212
|
+
};
|
|
1213
|
+
};
|
|
1214
|
+
const parseOperation = (value, index) => {
|
|
1215
|
+
const where = `operations[${index}]`;
|
|
1216
|
+
if (!isPlainObject(value)) return fail(`${where} must be an object`);
|
|
1217
|
+
const { operationId, method, pathTemplate, summary, description, permission, effect, responseKind, successStatuses, pathParams, queryParams, bodyFields, wholeBodyField, inputSchema, transfer, inlineFiles, sseOutcome } = value;
|
|
1218
|
+
if (typeof operationId !== "string" || operationId === "") return fail(`${where}.operationId is invalid`);
|
|
1219
|
+
if (!isHttpMethod(method)) return fail(`${where}.method is invalid`);
|
|
1220
|
+
if (typeof pathTemplate !== "string" || !pathTemplate.startsWith("/")) return fail(`${where}.pathTemplate is invalid`);
|
|
1221
|
+
if (typeof summary !== "string") return fail(`${where}.summary is invalid`);
|
|
1222
|
+
if (!isExplicitNull(description) && typeof description !== "string") return fail(`${where}.description is invalid`);
|
|
1223
|
+
if (!isExplicitNull(effect) && !isMcpOperationEffect(effect)) return fail(`${where}.effect is invalid`);
|
|
1224
|
+
if (!isResponseKind(responseKind)) return fail(`${where}.responseKind is invalid`);
|
|
1225
|
+
if (!isSuccessStatusList(successStatuses)) return fail(`${where}.successStatuses is invalid`);
|
|
1226
|
+
if (!isStringArray(pathParams)) return fail(`${where}.pathParams is invalid`);
|
|
1227
|
+
if (!isStringArray(queryParams)) return fail(`${where}.queryParams is invalid`);
|
|
1228
|
+
if (!isExplicitNull(bodyFields) && !isStringArray(bodyFields)) return fail(`${where}.bodyFields is invalid`);
|
|
1229
|
+
if (!isExplicitNull(wholeBodyField) && typeof wholeBodyField !== "string") return fail(`${where}.wholeBodyField is invalid`);
|
|
1230
|
+
if (bodyFields != null && wholeBodyField != null) return fail(`${where} declares both bodyFields and wholeBodyField`);
|
|
1231
|
+
if (!isPlainObject(inputSchema)) return fail(`${where}.inputSchema is invalid`);
|
|
1232
|
+
return {
|
|
1233
|
+
operationId,
|
|
1234
|
+
method,
|
|
1235
|
+
pathTemplate,
|
|
1236
|
+
summary,
|
|
1237
|
+
description,
|
|
1238
|
+
permission: permission ?? null,
|
|
1239
|
+
effect,
|
|
1240
|
+
responseKind,
|
|
1241
|
+
successStatuses,
|
|
1242
|
+
pathParams,
|
|
1243
|
+
queryParams,
|
|
1244
|
+
bodyFields,
|
|
1245
|
+
wholeBodyField,
|
|
1246
|
+
inputSchema,
|
|
1247
|
+
transfer: parseTransfer(transfer, where),
|
|
1248
|
+
inlineFiles: parseInlineFiles(inlineFiles, where),
|
|
1249
|
+
sseOutcome: parseSseOutcome(sseOutcome, where)
|
|
1250
|
+
};
|
|
1251
|
+
};
|
|
1252
|
+
/** 生成物(JSON)を実行時の型に戻す。生成器と同じ形しか受け付けない。 */
|
|
1253
|
+
const parseManifest = (value) => {
|
|
1254
|
+
if (!isPlainObject(value)) return fail("manifest must be an object");
|
|
1255
|
+
const source = value["source"];
|
|
1256
|
+
if (!isPlainObject(source) || typeof source["title"] !== "string" || typeof source["version"] !== "string") return fail("manifest.source is invalid");
|
|
1257
|
+
const operations = value["operations"];
|
|
1258
|
+
if (!Array.isArray(operations)) return fail("manifest.operations must be an array");
|
|
1259
|
+
const parsed = operations.map((operation, index) => parseOperation(operation, index));
|
|
1260
|
+
if (new Set(parsed.map((operation) => operation.operationId)).size !== parsed.length) return fail("manifest.operations has duplicate operationId");
|
|
1261
|
+
return {
|
|
1262
|
+
source: {
|
|
1263
|
+
title: source["title"],
|
|
1264
|
+
version: source["version"]
|
|
1265
|
+
},
|
|
1266
|
+
operations: parsed
|
|
1267
|
+
};
|
|
1268
|
+
};
|
|
1269
|
+
const loadManifestFile = (url = GENERATED_MANIFEST_URL) => {
|
|
1270
|
+
const text = readFileSync(url, "utf8");
|
|
1271
|
+
let json;
|
|
1272
|
+
try {
|
|
1273
|
+
json = JSON.parse(text);
|
|
1274
|
+
} catch {
|
|
1275
|
+
return fail(`manifest at ${url.pathname} is not valid JSON`);
|
|
1276
|
+
}
|
|
1277
|
+
return parseManifest(json);
|
|
1278
|
+
};
|
|
1279
|
+
//#endregion
|
|
1280
|
+
//#region src/tools/inline-files.ts
|
|
1281
|
+
const PCM_ENCODING = "pcm16le";
|
|
1282
|
+
const BYTES_PER_SAMPLE = 2;
|
|
1283
|
+
const WAV_HEADER_BYTES = 44;
|
|
1284
|
+
const WAV_FORMAT_PCM = 1;
|
|
1285
|
+
const WAV_FORMAT_EXTENSIBLE = 65534;
|
|
1286
|
+
const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
1287
|
+
const readChunkHeader = (bytes, offset) => offset + 8 > bytes.length ? null : {
|
|
1288
|
+
id: bytes.toString("latin1", offset, offset + 4),
|
|
1289
|
+
size: bytes.readUInt32LE(offset + 4)
|
|
1290
|
+
};
|
|
1291
|
+
/** RIFF/WAVE を読み、PCM 16bit の生データと形式を返す。圧縮形式・浮動小数・8/24/32bit は受けない。 */
|
|
1292
|
+
const parseWavPcm16 = (bytes) => {
|
|
1293
|
+
if (bytes.length < 12 || bytes.toString("latin1", 0, 4) !== "RIFF" || bytes.toString("latin1", 8, 12) !== "WAVE") throw new InlineFileError("not_wav", "the file is not a RIFF/WAVE file");
|
|
1294
|
+
let format = null;
|
|
1295
|
+
let pcm = null;
|
|
1296
|
+
let offset = 12;
|
|
1297
|
+
for (;;) {
|
|
1298
|
+
const chunk = readChunkHeader(bytes, offset);
|
|
1299
|
+
if (chunk == null) break;
|
|
1300
|
+
const dataStart = offset + 8;
|
|
1301
|
+
const dataEnd = dataStart + chunk.size;
|
|
1302
|
+
if (dataEnd > bytes.length) throw new InlineFileError("not_wav", `the "${chunk.id}" chunk claims more bytes than the file holds`);
|
|
1303
|
+
if (chunk.id === "fmt ") {
|
|
1304
|
+
if (dataEnd - dataStart < 16) throw new InlineFileError("not_wav", "the fmt chunk is truncated");
|
|
1305
|
+
const audioFormat = bytes.readUInt16LE(dataStart);
|
|
1306
|
+
if ((audioFormat === WAV_FORMAT_EXTENSIBLE && dataEnd - dataStart >= 26 ? bytes.readUInt16LE(dataStart + 24) : audioFormat) !== WAV_FORMAT_PCM) throw new InlineFileError("unsupported_format", `unsupported WAV format code ${String(audioFormat)} (only PCM is accepted)`);
|
|
1307
|
+
format = {
|
|
1308
|
+
channels: bytes.readUInt16LE(dataStart + 2),
|
|
1309
|
+
sampleRateHz: bytes.readUInt32LE(dataStart + 4),
|
|
1310
|
+
bitsPerSample: bytes.readUInt16LE(dataStart + 14)
|
|
1311
|
+
};
|
|
1312
|
+
} else if (chunk.id === "data") pcm = bytes.subarray(dataStart, dataEnd);
|
|
1313
|
+
offset = dataStart + chunk.size + chunk.size % 2;
|
|
1314
|
+
}
|
|
1315
|
+
if (format == null || pcm == null) throw new InlineFileError("not_wav", "the WAV file lacks a fmt or data chunk");
|
|
1316
|
+
if (format.bitsPerSample !== 16) throw new InlineFileError("unsupported_format", `unsupported bit depth ${String(format.bitsPerSample)} (only 16-bit PCM is accepted)`);
|
|
1317
|
+
if (pcm.length === 0) throw new InlineFileError("empty", "the WAV file has no audio samples");
|
|
1318
|
+
if (pcm.length % (BYTES_PER_SAMPLE * format.channels) !== 0) throw new InlineFileError("not_wav", "the data chunk is not aligned to whole samples");
|
|
1319
|
+
return {
|
|
1320
|
+
sampleRateHz: format.sampleRateHz,
|
|
1321
|
+
channels: format.channels,
|
|
1322
|
+
pcm
|
|
1323
|
+
};
|
|
1324
|
+
};
|
|
1325
|
+
/** PCM 16bit を 44 バイトヘッダの WAV に包む。 */
|
|
1326
|
+
const buildWavPcm16 = (pcm, format) => {
|
|
1327
|
+
const header = Buffer.alloc(WAV_HEADER_BYTES);
|
|
1328
|
+
const blockAlign = BYTES_PER_SAMPLE * format.channels;
|
|
1329
|
+
header.write("RIFF", 0, "latin1");
|
|
1330
|
+
header.writeUInt32LE(36 + pcm.length, 4);
|
|
1331
|
+
header.write("WAVE", 8, "latin1");
|
|
1332
|
+
header.write("fmt ", 12, "latin1");
|
|
1333
|
+
header.writeUInt32LE(16, 16);
|
|
1334
|
+
header.writeUInt16LE(WAV_FORMAT_PCM, 20);
|
|
1335
|
+
header.writeUInt16LE(format.channels, 22);
|
|
1336
|
+
header.writeUInt32LE(format.sampleRateHz, 24);
|
|
1337
|
+
header.writeUInt32LE(format.sampleRateHz * blockAlign, 28);
|
|
1338
|
+
header.writeUInt16LE(blockAlign, 32);
|
|
1339
|
+
header.writeUInt16LE(16, 34);
|
|
1340
|
+
header.write("data", 36, "latin1");
|
|
1341
|
+
header.writeUInt32LE(pcm.length, 40);
|
|
1342
|
+
return Buffer.concat([header, pcm]);
|
|
1343
|
+
};
|
|
1344
|
+
const durationMs = (pcmBytes, format) => Math.round(pcmBytes / (BYTES_PER_SAMPLE * format.channels * format.sampleRateHz) * 1e3);
|
|
1345
|
+
/** 根ディレクトリ配下の通常ファイルを上限付きで読む(FIFO・リンク差し替え・読中の伸長は local-files 側で拒む)。 */
|
|
1346
|
+
const readBoundedFile = async (rawPath, maxBytes, at, deps) => {
|
|
1347
|
+
const real = await resolveWithinRoots(rawPath, deps.fileRoots, deps.cwd, deps.workspace);
|
|
1348
|
+
try {
|
|
1349
|
+
return await readRegularFileBounded(real, maxBytes);
|
|
1350
|
+
} catch (err) {
|
|
1351
|
+
if (err instanceof LocalFileError && err.kind === "too_large") throw new InlineFileError("too_large", `"${at}" exceeds ${String(maxBytes)} bytes`);
|
|
1352
|
+
throw err;
|
|
1353
|
+
}
|
|
1354
|
+
};
|
|
1355
|
+
/** 入力 1 件をローカルファイルから API 本文の値に組む。 */
|
|
1356
|
+
const encodeInlineInput = async (input, rawPath, at, deps) => {
|
|
1357
|
+
if (typeof rawPath !== "string" || rawPath === "") throw new InlineFileError("not_a_path", `"${at}" must be a local file path`);
|
|
1358
|
+
const bytes = await readBoundedFile(rawPath, input.format === "wav-pcm16" ? input.maxBytes + WAV_HEADER_BYTES : input.maxBytes, at, deps);
|
|
1359
|
+
if (input.format === "base64") {
|
|
1360
|
+
if (bytes.length === 0) throw new InlineFileError("empty", `"${at}" points at an empty file`);
|
|
1361
|
+
return bytes.toString("base64");
|
|
1362
|
+
}
|
|
1363
|
+
const wav = parseWavPcm16(bytes);
|
|
1364
|
+
const expected = input.pcm;
|
|
1365
|
+
if (expected != null && (wav.sampleRateHz !== expected.sampleRateHz || wav.channels !== expected.channels)) throw new InlineFileError("unsupported_format", `"${at}" must be ${String(expected.sampleRateHz)} Hz / ${String(expected.channels)} channel PCM 16-bit WAV (got ${String(wav.sampleRateHz)} Hz / ${String(wav.channels)} channel)`);
|
|
1366
|
+
if (wav.pcm.length > input.maxBytes) throw new InlineFileError("too_large", `"${at}" holds ${String(wav.pcm.length)} bytes of audio; at most ${String(input.maxBytes)} are accepted`);
|
|
1367
|
+
return {
|
|
1368
|
+
encoding: PCM_ENCODING,
|
|
1369
|
+
sampleRateHz: wav.sampleRateHz,
|
|
1370
|
+
channels: wav.channels,
|
|
1371
|
+
data: wav.pcm.toString("base64")
|
|
1372
|
+
};
|
|
1373
|
+
};
|
|
1374
|
+
const unescapePointerToken = (token) => token.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
1375
|
+
/** pointer が指す値を置き換えた複製を返す。`*` は配列全要素。無い経路はそのまま残す(任意フィールド)。 */
|
|
1376
|
+
const replaceAtPointer = async (root, pointer, replace) => {
|
|
1377
|
+
const walk = async (node, tokens, at) => {
|
|
1378
|
+
const [token, ...rest] = tokens;
|
|
1379
|
+
if (token == null) return replace(node, at);
|
|
1380
|
+
if (token === "*") {
|
|
1381
|
+
if (!Array.isArray(node)) return node;
|
|
1382
|
+
const items = [];
|
|
1383
|
+
for (const [index, item] of node.entries()) items.push(await walk(item, rest, `${at}/${String(index)}`));
|
|
1384
|
+
return items;
|
|
1385
|
+
}
|
|
1386
|
+
if (Array.isArray(node)) {
|
|
1387
|
+
const index = Number(token);
|
|
1388
|
+
if (!Number.isInteger(index) || index < 0 || index >= node.length) return node;
|
|
1389
|
+
const items = [...node];
|
|
1390
|
+
items[index] = await walk(node[index], rest, `${at}/${token}`);
|
|
1391
|
+
return items;
|
|
1392
|
+
}
|
|
1393
|
+
if (isPlainObject(node) && hasOwn(node, token)) return {
|
|
1394
|
+
...node,
|
|
1395
|
+
[token]: await walk(node[token], rest, `${at}/${token}`)
|
|
1396
|
+
};
|
|
1397
|
+
return node;
|
|
1398
|
+
};
|
|
1399
|
+
return walk(root, pointer.split("/").slice(1).map((token) => unescapePointerToken(token)), "");
|
|
1400
|
+
};
|
|
1401
|
+
/** ツール引数中のファイルパスを、宣言された pointer だけ base64 に置き換える。 */
|
|
1402
|
+
const applyInlineInputs = async (annotation, args, deps) => {
|
|
1403
|
+
let current = args;
|
|
1404
|
+
for (const input of annotation.input) current = await replaceAtPointer(current, input.pointer, (value, at) => encodeInlineInput(input, value, at, deps));
|
|
1405
|
+
return isPlainObject(current) ? current : args;
|
|
1406
|
+
};
|
|
1407
|
+
const decodeBase64Chunk = (value, at) => {
|
|
1408
|
+
if (typeof value !== "string" || value.length % 4 !== 0 || !BASE64_PATTERN.test(value)) throw new InlineFileError("invalid_base64", `"${at}" is not valid base64`);
|
|
1409
|
+
return Buffer.from(value, "base64");
|
|
1410
|
+
};
|
|
1411
|
+
const EXTENSION_BY_MEDIA_TYPE = /* @__PURE__ */ new Map([
|
|
1412
|
+
["audio/mpeg", ".mp3"],
|
|
1413
|
+
["audio/mp4", ".m4a"],
|
|
1414
|
+
["audio/ogg", ".ogg"],
|
|
1415
|
+
["audio/wav", ".wav"],
|
|
1416
|
+
["audio/webm", ".webm"],
|
|
1417
|
+
["image/png", ".png"],
|
|
1418
|
+
["image/jpeg", ".jpg"],
|
|
1419
|
+
["image/webp", ".webp"],
|
|
1420
|
+
["application/pdf", ".pdf"]
|
|
1421
|
+
]);
|
|
1422
|
+
/** 応答から集めた base64 チャンクを 1 ファイルに書く。pcm16le は WAV に包む。 */
|
|
1423
|
+
const saveChunks = async (output, chunks, options) => {
|
|
1424
|
+
const buffers = [];
|
|
1425
|
+
let total = 0;
|
|
1426
|
+
for (const [index, chunk] of chunks.entries()) {
|
|
1427
|
+
const decoded = decodeBase64Chunk(chunk, `${output.event ?? "body"}${output.pointer}[${String(index)}]`);
|
|
1428
|
+
if (output.format === "pcm16le" && decoded.length % BYTES_PER_SAMPLE !== 0) throw new InlineFileError("unsupported_format", `${output.event ?? "body"}${output.pointer} chunk ${String(index)} is not whole PCM 16-bit samples`);
|
|
1429
|
+
total += decoded.length;
|
|
1430
|
+
if (total > options.maxBytes) throw new InlineFileError("too_large", `inline output exceeds ${String(options.maxBytes)} bytes`);
|
|
1431
|
+
buffers.push(decoded);
|
|
1432
|
+
}
|
|
1433
|
+
const joined = Buffer.concat(buffers);
|
|
1434
|
+
const pcm = output.pcm;
|
|
1435
|
+
const isWav = output.format === "pcm16le" && pcm != null;
|
|
1436
|
+
const bytes = isWav ? buildWavPcm16(joined, pcm) : joined;
|
|
1437
|
+
const mediaType = isWav ? "audio/wav" : output.mediaType ?? "application/octet-stream";
|
|
1438
|
+
const extension = isWav ? ".wav" : EXTENSION_BY_MEDIA_TYPE.get(mediaType) ?? ".bin";
|
|
1439
|
+
return {
|
|
1440
|
+
...await saveStreamToFile(new Blob([new Uint8Array(bytes)]).stream(), {
|
|
1441
|
+
outputDir: options.outputDir,
|
|
1442
|
+
fileName: sanitizeFileName(`${options.fileName}${extension}`, `inline${extension}`),
|
|
1443
|
+
maxBytes: options.maxBytes + WAV_HEADER_BYTES,
|
|
1444
|
+
timeoutMs: options.timeoutMs
|
|
1445
|
+
}),
|
|
1446
|
+
event: output.event,
|
|
1447
|
+
pointer: output.pointer,
|
|
1448
|
+
mediaType,
|
|
1449
|
+
chunks: chunks.length,
|
|
1450
|
+
durationMs: isWav ? durationMs(joined.length, pcm) : null
|
|
1451
|
+
};
|
|
1452
|
+
};
|
|
1453
|
+
const tryParseJson$1 = (text) => {
|
|
1454
|
+
try {
|
|
1455
|
+
return JSON.parse(text);
|
|
1456
|
+
} catch {
|
|
1457
|
+
return text;
|
|
1458
|
+
}
|
|
1459
|
+
};
|
|
1460
|
+
/** SSE のうち宣言された event の base64 をファイルに落とし、そのイベントは結果から除く(usage 等の他イベントは残す)。 */
|
|
1461
|
+
const collectSseInlineOutputs = async (annotation, events, options) => {
|
|
1462
|
+
const consumed = /* @__PURE__ */ new Set();
|
|
1463
|
+
const files = [];
|
|
1464
|
+
for (const output of annotation.output) {
|
|
1465
|
+
if (output.event == null) continue;
|
|
1466
|
+
consumed.add(output.event);
|
|
1467
|
+
const chunks = events.filter((event) => event.event === output.event).flatMap((event) => resolvePointer(tryParseJson$1(event.data), output.pointer));
|
|
1468
|
+
if (chunks.length === 0) continue;
|
|
1469
|
+
files.push(await saveChunks(output, chunks, options));
|
|
1470
|
+
}
|
|
1471
|
+
return {
|
|
1472
|
+
events: events.filter((event) => event.event == null || !consumed.has(event.event)),
|
|
1473
|
+
files
|
|
1474
|
+
};
|
|
1475
|
+
};
|
|
1476
|
+
/** JSON 応答の宣言位置の base64 をファイルに落とし、本文側は保存先の案内文に置き換える。 */
|
|
1477
|
+
const collectJsonInlineOutputs = async (annotation, body, options) => {
|
|
1478
|
+
let current = body;
|
|
1479
|
+
const files = [];
|
|
1480
|
+
for (const output of annotation.output) {
|
|
1481
|
+
if (output.event != null) continue;
|
|
1482
|
+
const chunks = resolvePointer(current, output.pointer);
|
|
1483
|
+
if (chunks.length === 0) continue;
|
|
1484
|
+
const saved = await saveChunks(output, chunks, options);
|
|
1485
|
+
files.push(saved);
|
|
1486
|
+
current = await replaceAtPointer(current, output.pointer, () => Promise.resolve(`[saved to ${saved.path}]`));
|
|
1487
|
+
}
|
|
1488
|
+
return {
|
|
1489
|
+
body: current,
|
|
1490
|
+
files
|
|
1491
|
+
};
|
|
1492
|
+
};
|
|
1493
|
+
/**
|
|
1494
|
+
* HTTP 200 と EOF だけでは成功にしない。最初の終端イベントで判定を確定し、それより前に必須イベントが揃い、
|
|
1495
|
+
* 失敗イベントが無く、終端の値が成功値のときだけ success。終端の後ろに何が来ても判定は変えない(吸収状態)。
|
|
1496
|
+
* 終端欠落や収集打ち切りは結果不明(unknown)で、いずれも呼び手は再送しない。
|
|
1497
|
+
*/
|
|
1498
|
+
const classifySseOutcome = (annotation, events, truncated) => {
|
|
1499
|
+
const terminalIndex = events.findIndex((event) => event.event === annotation.terminalEvent);
|
|
1500
|
+
const terminalEvent = terminalIndex === -1 ? null : events[terminalIndex];
|
|
1501
|
+
const preceding = terminalIndex === -1 ? events : events.slice(0, terminalIndex);
|
|
1502
|
+
const terminal = terminalEvent == null ? null : tryParseJson$1(terminalEvent.data);
|
|
1503
|
+
const names = new Set(preceding.map((event) => event.event));
|
|
1504
|
+
const failure = annotation.failureEvents.find((name) => names.has(name));
|
|
1505
|
+
if (failure != null) return {
|
|
1506
|
+
outcome: "failed",
|
|
1507
|
+
reason: `received failure event "${failure}"`,
|
|
1508
|
+
terminal
|
|
1509
|
+
};
|
|
1510
|
+
if (terminalEvent == null) {
|
|
1511
|
+
if (truncated.truncated) return {
|
|
1512
|
+
outcome: "unknown",
|
|
1513
|
+
reason: `the stream was cut off by the client limit (${truncated.reason ?? "unknown"})`,
|
|
1514
|
+
terminal
|
|
1515
|
+
};
|
|
1516
|
+
return {
|
|
1517
|
+
outcome: "unknown",
|
|
1518
|
+
reason: `the stream ended without the terminal event "${annotation.terminalEvent}"`,
|
|
1519
|
+
terminal
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
const value = annotation.successPointer == null ? null : resolvePointer(terminal, annotation.successPointer)[0];
|
|
1523
|
+
if (annotation.successPointer != null && value !== annotation.successValue) return {
|
|
1524
|
+
outcome: "failed",
|
|
1525
|
+
reason: `"${annotation.terminalEvent}" ended with ${annotation.successPointer}=${JSON.stringify(value)} (expected ${JSON.stringify(annotation.successValue)})`,
|
|
1526
|
+
terminal
|
|
1527
|
+
};
|
|
1528
|
+
const missing = annotation.requiredEvents.filter((name) => !names.has(name));
|
|
1529
|
+
if (missing.length > 0) return {
|
|
1530
|
+
outcome: "failed",
|
|
1531
|
+
reason: `required event(s) missing before "${annotation.terminalEvent}": ${missing.join(", ")}`,
|
|
1532
|
+
terminal
|
|
1533
|
+
};
|
|
1534
|
+
return {
|
|
1535
|
+
outcome: "success",
|
|
1536
|
+
reason: null,
|
|
1537
|
+
terminal
|
|
1538
|
+
};
|
|
1539
|
+
};
|
|
1540
|
+
//#endregion
|
|
1541
|
+
//#region src/tools/invoke.ts
|
|
1542
|
+
const DEFAULT_TOOL_LIMITS = {
|
|
1543
|
+
transferTimeoutMs: BINARY_TOTAL_TIMEOUT_MS,
|
|
1544
|
+
maxDownloadBytes: MAX_DOWNLOAD_BYTES,
|
|
1545
|
+
maxAutoDownloadsPerCall: 20,
|
|
1546
|
+
maxInlineOutputBytes: MAX_INLINE_FILE_OUTPUT_BYTES
|
|
1547
|
+
};
|
|
1548
|
+
/** `{name}` を左から順に置き換える。空の `{}` はそのまま残す(正規表現の後退再試行を避けて手で走査する)。 */
|
|
1549
|
+
const fillPathTemplate = (template, resolve) => {
|
|
1550
|
+
let result = "";
|
|
1551
|
+
let cursor = 0;
|
|
1552
|
+
let open = template.indexOf("{");
|
|
1553
|
+
while (open !== -1) {
|
|
1554
|
+
const close = template.indexOf("}", open + 1);
|
|
1555
|
+
if (close === -1) break;
|
|
1556
|
+
result += close === open + 1 ? template.slice(cursor, close + 1) : template.slice(cursor, open) + resolve(template.slice(open + 1, close));
|
|
1557
|
+
cursor = close + 1;
|
|
1558
|
+
open = template.indexOf("{", cursor);
|
|
1559
|
+
}
|
|
1560
|
+
return result + template.slice(cursor);
|
|
1561
|
+
};
|
|
1562
|
+
const stringifyParam = (name, value) => {
|
|
1563
|
+
if (typeof value === "string") return value;
|
|
1564
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
1565
|
+
throw new InvalidArgumentsError(`argument "${name}" must be a string, number, or boolean`);
|
|
1566
|
+
};
|
|
1567
|
+
/** ツール引数を公開 API の要求に組む。`tenantName` は起動設定から埋め、引数からは受けない。 */
|
|
1568
|
+
const buildApiRequest = (config, entry, args) => {
|
|
1569
|
+
if (hasOwn(args, "tenantName")) throw new InvalidArgumentsError(`"${TENANT_PATH_PARAM}" is fixed by the server configuration`);
|
|
1570
|
+
const path = fillPathTemplate(entry.pathTemplate, (name) => {
|
|
1571
|
+
if (name === "tenantName") return encodeURIComponent(config.tenantName);
|
|
1572
|
+
const value = args[name];
|
|
1573
|
+
if (value == null) throw new InvalidArgumentsError(`path parameter "${name}" is required`);
|
|
1574
|
+
return encodeURIComponent(stringifyParam(name, value));
|
|
1575
|
+
});
|
|
1576
|
+
const query = [];
|
|
1577
|
+
for (const name of entry.queryParams) {
|
|
1578
|
+
const value = args[name];
|
|
1579
|
+
if (value == null) continue;
|
|
1580
|
+
if (Array.isArray(value)) for (const item of value) query.push([name, stringifyParam(name, item)]);
|
|
1581
|
+
else query.push([name, stringifyParam(name, value)]);
|
|
1582
|
+
}
|
|
1583
|
+
let body = null;
|
|
1584
|
+
let hasBody = false;
|
|
1585
|
+
if (entry.wholeBodyField != null) {
|
|
1586
|
+
if (!hasOwn(args, entry.wholeBodyField)) throw new InvalidArgumentsError(`argument "${entry.wholeBodyField}" is required`);
|
|
1587
|
+
body = args[entry.wholeBodyField];
|
|
1588
|
+
hasBody = true;
|
|
1589
|
+
} else if (entry.bodyFields != null) {
|
|
1590
|
+
const picked = {};
|
|
1591
|
+
for (const name of entry.bodyFields) if (hasOwn(args, name)) picked[name] = args[name];
|
|
1592
|
+
body = picked;
|
|
1593
|
+
hasBody = true;
|
|
1594
|
+
}
|
|
1595
|
+
return {
|
|
1596
|
+
method: entry.method,
|
|
1597
|
+
path,
|
|
1598
|
+
query,
|
|
1599
|
+
hasBody,
|
|
1600
|
+
body,
|
|
1601
|
+
responseKind: entry.responseKind
|
|
1602
|
+
};
|
|
1603
|
+
};
|
|
1604
|
+
const tryParseJson = (text) => {
|
|
1605
|
+
try {
|
|
1606
|
+
return JSON.parse(text);
|
|
1607
|
+
} catch {
|
|
1608
|
+
return text;
|
|
1609
|
+
}
|
|
1610
|
+
};
|
|
1611
|
+
const presentSseEvent = (event) => ({
|
|
1612
|
+
event: event.event,
|
|
1613
|
+
data: tryParseJson(event.data),
|
|
1614
|
+
...event.id == null ? {} : { id: event.id }
|
|
1615
|
+
});
|
|
1616
|
+
const errorPayload = (error, secrets) => {
|
|
1617
|
+
if (error instanceof ApiError) return {
|
|
1618
|
+
isError: true,
|
|
1619
|
+
payload: { error: {
|
|
1620
|
+
kind: "api",
|
|
1621
|
+
...error.info
|
|
1622
|
+
} }
|
|
1623
|
+
};
|
|
1624
|
+
if (error instanceof TransportError) return {
|
|
1625
|
+
isError: true,
|
|
1626
|
+
payload: { error: {
|
|
1627
|
+
kind: "transport",
|
|
1628
|
+
failure: error.kind,
|
|
1629
|
+
status: error.status,
|
|
1630
|
+
message: error.message
|
|
1631
|
+
} }
|
|
1632
|
+
};
|
|
1633
|
+
if (error instanceof TransferError) return {
|
|
1634
|
+
isError: true,
|
|
1635
|
+
payload: { error: {
|
|
1636
|
+
kind: "transfer",
|
|
1637
|
+
failure: error.kind,
|
|
1638
|
+
status: error.status,
|
|
1639
|
+
message: error.message
|
|
1640
|
+
} }
|
|
1641
|
+
};
|
|
1642
|
+
if (error instanceof InvalidArgumentsError) return {
|
|
1643
|
+
isError: true,
|
|
1644
|
+
payload: { error: {
|
|
1645
|
+
kind: "invalid_arguments",
|
|
1646
|
+
message: error.message
|
|
1647
|
+
} }
|
|
1648
|
+
};
|
|
1649
|
+
if (error instanceof LocalFileError) return {
|
|
1650
|
+
isError: true,
|
|
1651
|
+
payload: { error: {
|
|
1652
|
+
kind: "local_file",
|
|
1653
|
+
failure: error.kind,
|
|
1654
|
+
message: error.message
|
|
1655
|
+
} }
|
|
1656
|
+
};
|
|
1657
|
+
if (error instanceof InlineFileError) return {
|
|
1658
|
+
isError: true,
|
|
1659
|
+
payload: { error: {
|
|
1660
|
+
kind: "inline_file",
|
|
1661
|
+
failure: error.kind,
|
|
1662
|
+
message: error.message
|
|
1663
|
+
} }
|
|
1664
|
+
};
|
|
1665
|
+
if (error instanceof WorkspaceBindingError) return {
|
|
1666
|
+
isError: true,
|
|
1667
|
+
payload: { error: {
|
|
1668
|
+
kind: "workspace",
|
|
1669
|
+
failure: error.kind,
|
|
1670
|
+
message: error.message
|
|
1671
|
+
} }
|
|
1672
|
+
};
|
|
1673
|
+
if (error instanceof OperationFailedError) return {
|
|
1674
|
+
isError: true,
|
|
1675
|
+
payload: error.payload
|
|
1676
|
+
};
|
|
1677
|
+
if (error instanceof Error) return {
|
|
1678
|
+
isError: true,
|
|
1679
|
+
payload: { error: {
|
|
1680
|
+
kind: "unexpected",
|
|
1681
|
+
message: redactSecrets(error.message, secrets)
|
|
1682
|
+
} }
|
|
1683
|
+
};
|
|
1684
|
+
throw error;
|
|
1685
|
+
};
|
|
1686
|
+
/** ツール実装の共通の受け皿。既知の失敗は `isError` 付きの構造化された payload に写す。 */
|
|
1687
|
+
const runTool = async (deps, operationId, body) => {
|
|
1688
|
+
try {
|
|
1689
|
+
await assertWorkspaceBinding(deps.workspace, new Date(deps.now()));
|
|
1690
|
+
return {
|
|
1691
|
+
isError: false,
|
|
1692
|
+
payload: await body()
|
|
1693
|
+
};
|
|
1694
|
+
} catch (err) {
|
|
1695
|
+
const secrets = [deps.config.apiKey];
|
|
1696
|
+
const outcome = errorPayload(err, secrets);
|
|
1697
|
+
const redacted = JSON.parse(redactSecrets(JSON.stringify(outcome.payload), secrets));
|
|
1698
|
+
deps.logger.warn("tool call failed", {
|
|
1699
|
+
operationId,
|
|
1700
|
+
payload: redacted
|
|
1701
|
+
});
|
|
1702
|
+
return {
|
|
1703
|
+
isError: true,
|
|
1704
|
+
payload: redacted
|
|
1705
|
+
};
|
|
1706
|
+
}
|
|
1707
|
+
};
|
|
1708
|
+
const FAILED_DOWNLOAD_HINT = "The API response above is complete; only the automatic download failed. Retry it with download_file and the same URL.";
|
|
1709
|
+
/**
|
|
1710
|
+
* `x-transfer: download` の操作は署名付き URL を返すだけで終わらせず、その場でダウンロードして
|
|
1711
|
+
* ファイルパスを返す(手作業を要求しない)。upload は URL を登録し `upload_file` に委ねる。
|
|
1712
|
+
* ダウンロードの失敗は API 応答(原本テキスト等)を道連れにせず、結果に失敗種別として残す。
|
|
1713
|
+
*/
|
|
1714
|
+
const handleTransfers = async (deps, entry, body, limits) => {
|
|
1715
|
+
const transfers = extractTransferUrls(body, entry.transfer);
|
|
1716
|
+
if (transfers.length === 0) return {};
|
|
1717
|
+
const uploads = [];
|
|
1718
|
+
const downloads = [];
|
|
1719
|
+
const failedDownloads = [];
|
|
1720
|
+
const rejected = [];
|
|
1721
|
+
for (const transfer of transfers) {
|
|
1722
|
+
let url;
|
|
1723
|
+
try {
|
|
1724
|
+
url = assertAllowedStorageUrl(transfer.url, deps.config);
|
|
1725
|
+
} catch (err) {
|
|
1726
|
+
rejected.push({
|
|
1727
|
+
url: transfer.url,
|
|
1728
|
+
reason: err instanceof Error ? err.message : "rejected"
|
|
1729
|
+
});
|
|
1730
|
+
continue;
|
|
1731
|
+
}
|
|
1732
|
+
if (transfer.kind === "upload") {
|
|
1733
|
+
deps.registry.register(transfer.url, "upload", transfer.headers);
|
|
1734
|
+
uploads.push(transfer.url);
|
|
1735
|
+
continue;
|
|
1736
|
+
}
|
|
1737
|
+
if (downloads.length >= limits.maxAutoDownloadsPerCall) {
|
|
1738
|
+
deps.registry.register(transfer.url, "download", {});
|
|
1739
|
+
continue;
|
|
1740
|
+
}
|
|
1741
|
+
const fileName = sanitizeFileName(url.pathname.split("/").at(-1) ?? null, `${entry.operationId}.bin`);
|
|
1742
|
+
let saved;
|
|
1743
|
+
try {
|
|
1744
|
+
saved = await downloadPresignedUrlToFile({
|
|
1745
|
+
url,
|
|
1746
|
+
outputDir: deps.config.outputDir,
|
|
1747
|
+
fileName,
|
|
1748
|
+
fetch: deps.fetch,
|
|
1749
|
+
maxBytes: limits.maxDownloadBytes,
|
|
1750
|
+
timeoutMs: limits.transferTimeoutMs
|
|
1751
|
+
});
|
|
1752
|
+
} catch (err) {
|
|
1753
|
+
if (!(err instanceof TransferError)) throw err;
|
|
1754
|
+
deps.logger.warn("automatic download failed", {
|
|
1755
|
+
operationId: entry.operationId,
|
|
1756
|
+
host: url.hostname,
|
|
1757
|
+
failure: err.kind
|
|
1758
|
+
});
|
|
1759
|
+
deps.registry.register(transfer.url, "download", {});
|
|
1760
|
+
failedDownloads.push({
|
|
1761
|
+
url: transfer.url,
|
|
1762
|
+
failure: err.kind
|
|
1763
|
+
});
|
|
1764
|
+
continue;
|
|
1765
|
+
}
|
|
1766
|
+
downloads.push({
|
|
1767
|
+
url: transfer.url,
|
|
1768
|
+
path: saved.path,
|
|
1769
|
+
bytes: saved.bytes,
|
|
1770
|
+
sha256Hex: saved.sha256Hex,
|
|
1771
|
+
contentType: saved.contentType
|
|
1772
|
+
});
|
|
1773
|
+
}
|
|
1774
|
+
return {
|
|
1775
|
+
...uploads.length === 0 ? {} : {
|
|
1776
|
+
uploadUrls: uploads,
|
|
1777
|
+
uploadHint: "Call upload_file with one of these URLs and a local file path."
|
|
1778
|
+
},
|
|
1779
|
+
...downloads.length === 0 ? {} : { downloads },
|
|
1780
|
+
...failedDownloads.length === 0 ? {} : {
|
|
1781
|
+
failedDownloads,
|
|
1782
|
+
downloadHint: FAILED_DOWNLOAD_HINT
|
|
1783
|
+
},
|
|
1784
|
+
...rejected.length === 0 ? {} : { rejectedTransferUrls: rejected }
|
|
1785
|
+
};
|
|
1786
|
+
};
|
|
1787
|
+
const inlineOutputOptions = (deps, entry, limits) => ({
|
|
1788
|
+
outputDir: deps.config.outputDir,
|
|
1789
|
+
fileName: entry.operationId,
|
|
1790
|
+
maxBytes: limits.maxInlineOutputBytes,
|
|
1791
|
+
timeoutMs: limits.transferTimeoutMs
|
|
1792
|
+
});
|
|
1793
|
+
const withFiles = (files) => files.length === 0 ? {} : { files };
|
|
1794
|
+
const invokeOperation = async (deps, entry, args, limits = DEFAULT_TOOL_LIMITS) => runTool(deps, entry.operationId, async () => {
|
|
1795
|
+
const inlineFiles = entry.inlineFiles;
|
|
1796
|
+
const preparedArgs = inlineFiles == null ? args : await applyInlineInputs(inlineFiles, args, {
|
|
1797
|
+
fileRoots: deps.config.fileRoots,
|
|
1798
|
+
cwd: deps.cwd,
|
|
1799
|
+
workspace: deps.workspace
|
|
1800
|
+
});
|
|
1801
|
+
const request = buildApiRequest(deps.config, entry, preparedArgs);
|
|
1802
|
+
const response = await deps.client.request(request);
|
|
1803
|
+
switch (response.kind) {
|
|
1804
|
+
case "json": {
|
|
1805
|
+
const extra = await handleTransfers(deps, entry, response.body, limits);
|
|
1806
|
+
const inline = inlineFiles == null ? {
|
|
1807
|
+
body: response.body,
|
|
1808
|
+
files: []
|
|
1809
|
+
} : await collectJsonInlineOutputs(inlineFiles, response.body, inlineOutputOptions(deps, entry, limits));
|
|
1810
|
+
return {
|
|
1811
|
+
status: response.status,
|
|
1812
|
+
requestId: response.requestId,
|
|
1813
|
+
body: inline.body,
|
|
1814
|
+
...extra,
|
|
1815
|
+
...withFiles(inline.files)
|
|
1816
|
+
};
|
|
1817
|
+
}
|
|
1818
|
+
case "empty": return {
|
|
1819
|
+
status: response.status,
|
|
1820
|
+
requestId: response.requestId
|
|
1821
|
+
};
|
|
1822
|
+
case "sse": {
|
|
1823
|
+
const inline = inlineFiles == null ? {
|
|
1824
|
+
events: response.events,
|
|
1825
|
+
files: []
|
|
1826
|
+
} : await collectSseInlineOutputs(inlineFiles, response.events, inlineOutputOptions(deps, entry, limits));
|
|
1827
|
+
const payload = {
|
|
1828
|
+
status: response.status,
|
|
1829
|
+
requestId: response.requestId,
|
|
1830
|
+
events: inline.events.map((event) => presentSseEvent(event)),
|
|
1831
|
+
truncated: response.truncated,
|
|
1832
|
+
stopReason: response.reason,
|
|
1833
|
+
discardedIncomplete: response.discardedIncomplete,
|
|
1834
|
+
...withFiles(inline.files)
|
|
1835
|
+
};
|
|
1836
|
+
if (entry.sseOutcome == null) return payload;
|
|
1837
|
+
const verdict = classifySseOutcome(entry.sseOutcome, response.events, {
|
|
1838
|
+
truncated: response.truncated,
|
|
1839
|
+
reason: response.reason
|
|
1840
|
+
});
|
|
1841
|
+
const judged = {
|
|
1842
|
+
...payload,
|
|
1843
|
+
outcome: verdict.outcome,
|
|
1844
|
+
outcomeReason: verdict.reason,
|
|
1845
|
+
terminal: verdict.terminal
|
|
1846
|
+
};
|
|
1847
|
+
if (verdict.outcome !== "success") throw new OperationFailedError(verdict.reason ?? "operation failed", {
|
|
1848
|
+
...judged,
|
|
1849
|
+
retryHint: "Not retried automatically. Check the affected resource or execution history before running this operation again."
|
|
1850
|
+
});
|
|
1851
|
+
return judged;
|
|
1852
|
+
}
|
|
1853
|
+
case "binary": {
|
|
1854
|
+
const saved = await saveStreamToFile(response.body, {
|
|
1855
|
+
outputDir: deps.config.outputDir,
|
|
1856
|
+
fileName: sanitizeFileName(response.fileName, `${entry.operationId}.bin`),
|
|
1857
|
+
maxBytes: limits.maxDownloadBytes,
|
|
1858
|
+
timeoutMs: limits.transferTimeoutMs
|
|
1859
|
+
});
|
|
1860
|
+
return {
|
|
1861
|
+
status: response.status,
|
|
1862
|
+
requestId: response.requestId,
|
|
1863
|
+
file: {
|
|
1864
|
+
...saved,
|
|
1865
|
+
contentType: response.contentType,
|
|
1866
|
+
fileName: response.fileName
|
|
1867
|
+
}
|
|
1868
|
+
};
|
|
1869
|
+
}
|
|
1870
|
+
default: throw new Error("unknown response kind");
|
|
1871
|
+
}
|
|
1872
|
+
});
|
|
1873
|
+
//#endregion
|
|
1874
|
+
//#region src/server.ts
|
|
1875
|
+
const serverInstructions = (tenantName) => `Flyle AIコンタクトセンターの公開 API を呼ぶツール群です。
|
|
1876
|
+
- 接続先テナントは「${tenantName}」です。テナントと API キーはサーバー起動時に固定されており、ツール引数でテナントを指定することはできません。
|
|
1877
|
+
- この作業ディレクトリは「${tenantName}」専用です。別の顧客(テナント)の情報・ファイル・記憶をこのテナントへの書き込みに使わないでください。別テナントの作業は別ディレクトリの別セッションで行います。
|
|
1878
|
+
- 各ツール結果の tenantName は、その操作を実行したテナントです。
|
|
1879
|
+
- 書き込み系ツールは自動再送しません。タイムアウトや transport エラーの後は、対象を再取得して状態を確認してください。
|
|
1880
|
+
- 一覧は各ツールの入力スキーマと応答に従って取得してください。cursor / limit+offset / ページングなしの全件取得があります。
|
|
1881
|
+
- 大きな JSON 結果は出力ディレクトリに全文を保存し、保存先と先頭部分を返します。全文は保存ファイルを参照してください。
|
|
1882
|
+
- 改善要望は、現在の設定・既存ケースの取得 → create_eval_case / import_eval_cases でケースを保存 → 変更前の検証 → 修正 → プレビュー作成完了の確認 → 全ケースの再検証の順に進めます。create_improvement_analysis は分析結果を返すだけで、検証ケースや設定を保存しません。
|
|
1883
|
+
- 改善提案は内容と get_improvement_proposal_plan を確認し、根拠のある actionIndexes だけを resolve_improvement_proposal で適用してください。検証の受付だけで合格と判断せず、get_eval_run と list_eval_run_case_results で完了・ケース別結果を確認してください。
|
|
1884
|
+
- ファイル取込は inspect_local_file → create_knowledge_upload_urls → upload_file → create_knowledge_import の順に進め、取込と文書の処理完了を確認します。API の byteSize には sizeBytes、checksumSha256 には sha256Hex を渡します(sha256Base64 ではありません)。URL の発行と upload_file は同じ MCP 接続で行ってください。ダウンロード系はファイルパスを返します。`;
|
|
1885
|
+
/** JSON Schema らしさの最小検査。`fromJsonSchema` に渡す前に object スキーマであることを確かめる。 */
|
|
1886
|
+
const isObjectJsonSchema = (value) => isPlainObject(value) && value["type"] === "object" && isPlainObject(value["properties"]);
|
|
1887
|
+
const toJsonSchema = (schema, where) => {
|
|
1888
|
+
if (!isObjectJsonSchema(schema)) throw new Error(`${where}: input schema must be an object schema`);
|
|
1889
|
+
return schema;
|
|
1890
|
+
};
|
|
1891
|
+
const formatActions = (value) => {
|
|
1892
|
+
if (typeof value === "string") return value;
|
|
1893
|
+
if (Array.isArray(value) && value.every((item) => typeof item === "string")) return value.join(", ");
|
|
1894
|
+
return null;
|
|
1895
|
+
};
|
|
1896
|
+
const formatPermission = (permission) => {
|
|
1897
|
+
if (!isPlainObject(permission)) return null;
|
|
1898
|
+
const gate = permission["gate"];
|
|
1899
|
+
if (gate === "none") return "認証のみ(ロール不要)";
|
|
1900
|
+
const actions = formatActions(permission["action"] ?? permission["actions"]);
|
|
1901
|
+
if (actions == null || typeof gate !== "string") return null;
|
|
1902
|
+
if (gate === "any-resource") return `必要な権限: ${actions}(いずれかのエージェントで保持)`;
|
|
1903
|
+
if (gate === "per-resource") return `必要な権限: ${actions}(対象エージェントに対して)`;
|
|
1904
|
+
if (gate === "per-resource-all") return `必要な権限: ${actions} のすべて(対象エージェントに対して)`;
|
|
1905
|
+
if (gate === "per-resource-any") return `必要な権限: ${actions} のいずれか(対象エージェントに対して)`;
|
|
1906
|
+
return `必要な権限: ${actions}(${gate})`;
|
|
1907
|
+
};
|
|
1908
|
+
const EFFECT_NOTES = {
|
|
1909
|
+
read: null,
|
|
1910
|
+
draft: "下書きへの変更です。公開版(利用者への配信)には publish まで反映されません。"
|
|
1911
|
+
};
|
|
1912
|
+
const effectNoteFor = (entry) => entry.effect == null ? null : EFFECT_NOTES[entry.effect];
|
|
1913
|
+
const describeOperation = (entry) => {
|
|
1914
|
+
const lines = [entry.summary];
|
|
1915
|
+
if (entry.description != null) lines.push("", entry.description);
|
|
1916
|
+
lines.push("", `HTTP: ${entry.method} ${entry.pathTemplate}`);
|
|
1917
|
+
const permission = formatPermission(entry.permission);
|
|
1918
|
+
if (permission != null) lines.push(permission);
|
|
1919
|
+
const effectNote = effectNoteFor(entry);
|
|
1920
|
+
if (effectNote != null) lines.push("", effectNote);
|
|
1921
|
+
if (entry.responseKind === "sse") lines.push("", "ストリーミング応答を終端まで収集してから返します(数分かかることがあります)。");
|
|
1922
|
+
if (entry.responseKind === "binary") lines.push("", "ファイルを出力ディレクトリに保存し、パスと sha256 を返します。");
|
|
1923
|
+
if (entry.transfer?.kind === "download") lines.push("", "応答に含まれる署名付き URL からその場でダウンロードし、ファイルパスを返します。");
|
|
1924
|
+
if (entry.transfer?.kind === "upload") lines.push("", "応答のアップロード URL は upload_file ツールで使えます(15 分・1 回限り)。");
|
|
1925
|
+
if (entry.wholeBodyField != null) lines.push("", `リクエストボディは "${entry.wholeBodyField}" 引数にそのまま渡します(判別共用体)。`);
|
|
1926
|
+
for (const input of entry.inlineFiles?.input ?? []) lines.push("", input.format === "wav-pcm16" && input.pcm != null ? `"${input.pointer}" にはローカル WAV ファイルのパス(PCM 16bit / ${String(input.pcm.sampleRateHz)} Hz / ${String(input.pcm.channels)}ch、音声 ${String(input.maxBytes)} バイト以内)を渡します。base64 化はこのサーバーが行います。` : `"${input.pointer}" にはローカルファイルのパス(${String(input.maxBytes)} バイト以内)を渡します。base64 化はこのサーバーが行います。`);
|
|
1927
|
+
if ((entry.inlineFiles?.output.length ?? 0) > 0) lines.push("", "応答に埋め込まれた音声 / ファイルは出力ディレクトリに保存し、パスだけを返します(base64 は結果に含めません)。");
|
|
1928
|
+
if (entry.sseOutcome != null) {
|
|
1929
|
+
const required = [...entry.sseOutcome.requiredEvents, entry.sseOutcome.successPointer == null ? entry.sseOutcome.terminalEvent : `${entry.sseOutcome.terminalEvent}{${entry.sseOutcome.successPointer}=${JSON.stringify(entry.sseOutcome.successValue)}}`];
|
|
1930
|
+
lines.push("", `成功判定: ${required.join(" と ")} が揃ったときだけ outcome=success。欠落・異常終了・打ち切りは isError で返し、自動再送しません。`);
|
|
1931
|
+
}
|
|
1932
|
+
if (entry.method !== "GET") lines.push("", "この操作は自動再送しません。タイムアウト時は状態を再取得して確認してください。");
|
|
1933
|
+
return lines.join("\n");
|
|
1934
|
+
};
|
|
1935
|
+
const annotationsFor = (entry) => ({
|
|
1936
|
+
readOnlyHint: entry.method === "GET",
|
|
1937
|
+
destructiveHint: entry.method === "DELETE",
|
|
1938
|
+
idempotentHint: entry.method === "GET" || entry.method === "PUT" || entry.method === "DELETE",
|
|
1939
|
+
openWorldHint: false
|
|
1940
|
+
});
|
|
1941
|
+
const TOOL_TEXT_PREVIEW_CHARS = 4096;
|
|
1942
|
+
const UTF16_HIGH_SURROGATE_FIRST = 55296;
|
|
1943
|
+
const UTF16_HIGH_SURROGATE_LAST = 56319;
|
|
1944
|
+
/** 先頭 4096 文字。UTF-16 の高サロゲートで終わったら 1 単位落とす(絵文字等の途中で切らない)。 */
|
|
1945
|
+
const previewOf = (text) => {
|
|
1946
|
+
const sliced = text.slice(0, TOOL_TEXT_PREVIEW_CHARS);
|
|
1947
|
+
const last = sliced.codePointAt(sliced.length - 1) ?? 0;
|
|
1948
|
+
return last >= UTF16_HIGH_SURROGATE_FIRST && last <= UTF16_HIGH_SURROGATE_LAST ? sliced.slice(0, -1) : sliced;
|
|
1949
|
+
};
|
|
1950
|
+
const bytesToStream = (bytes) => new ReadableStream({ start(controller) {
|
|
1951
|
+
controller.enqueue(bytes);
|
|
1952
|
+
controller.close();
|
|
1953
|
+
} });
|
|
1954
|
+
const textResult = (tenantName, text, isError) => ({
|
|
1955
|
+
content: [{
|
|
1956
|
+
type: "text",
|
|
1957
|
+
text: JSON.stringify({ tenantName })
|
|
1958
|
+
}, {
|
|
1959
|
+
type: "text",
|
|
1960
|
+
text
|
|
1961
|
+
}],
|
|
1962
|
+
isError
|
|
1963
|
+
});
|
|
1964
|
+
/**
|
|
1965
|
+
* 応答テキストが上限を超えたら compact JSON をファイルに保存し、保存先と整形表記の先頭だけを返す
|
|
1966
|
+
* (整形は 2〜3 倍に膨らむのでファイルには書かない)。保存に失敗しても先頭と総文字数は返す。
|
|
1967
|
+
*/
|
|
1968
|
+
const toCallToolResult = async (outcome, options) => {
|
|
1969
|
+
const { secrets } = options;
|
|
1970
|
+
const pretty = redactSecrets(JSON.stringify(outcome.payload, null, 2) ?? "null", secrets);
|
|
1971
|
+
if (pretty.length <= 1048576) return textResult(options.tenantName, pretty, outcome.isError);
|
|
1972
|
+
const compact = Buffer.from(redactSecrets(JSON.stringify(outcome.payload) ?? "null", secrets), "utf8");
|
|
1973
|
+
const summary = {
|
|
1974
|
+
totalChars: pretty.length,
|
|
1975
|
+
preview: previewOf(pretty)
|
|
1976
|
+
};
|
|
1977
|
+
try {
|
|
1978
|
+
const file = await saveStreamToFile(bytesToStream(compact), {
|
|
1979
|
+
outputDir: options.outputDir,
|
|
1980
|
+
fileName: options.fileName,
|
|
1981
|
+
maxBytes: options.maxFileBytes,
|
|
1982
|
+
timeoutMs: BINARY_TOTAL_TIMEOUT_MS
|
|
1983
|
+
});
|
|
1984
|
+
return textResult(options.tenantName, JSON.stringify({
|
|
1985
|
+
file: {
|
|
1986
|
+
...file,
|
|
1987
|
+
format: "json"
|
|
1988
|
+
},
|
|
1989
|
+
...summary
|
|
1990
|
+
}, null, 2), outcome.isError);
|
|
1991
|
+
} catch (err) {
|
|
1992
|
+
const failure = err instanceof TransferError ? err.kind : "unexpected";
|
|
1993
|
+
options.logger.warn("tool result could not be saved", {
|
|
1994
|
+
fileName: options.fileName,
|
|
1995
|
+
failure,
|
|
1996
|
+
bytes: compact.byteLength
|
|
1997
|
+
});
|
|
1998
|
+
return textResult(options.tenantName, JSON.stringify({
|
|
1999
|
+
error: {
|
|
2000
|
+
kind: "result_not_saved",
|
|
2001
|
+
failure,
|
|
2002
|
+
message: "The full result was not saved; only the preview below is available. The operation may have completed; check its state before retrying."
|
|
2003
|
+
},
|
|
2004
|
+
...summary
|
|
2005
|
+
}, null, 2), true);
|
|
2006
|
+
}
|
|
2007
|
+
};
|
|
2008
|
+
const resultOptionsFor = (deps, name) => ({
|
|
2009
|
+
tenantName: deps.config.tenantName,
|
|
2010
|
+
outputDir: deps.config.outputDir,
|
|
2011
|
+
fileName: `${name}.result.json`,
|
|
2012
|
+
logger: deps.logger,
|
|
2013
|
+
secrets: [deps.config.apiKey],
|
|
2014
|
+
maxFileBytes: MAX_TOOL_RESULT_FILE_BYTES
|
|
2015
|
+
});
|
|
2016
|
+
const argumentsOf = (args) => isPlainObject(args) ? args : {};
|
|
2017
|
+
const requireStringArgument = (args, name) => {
|
|
2018
|
+
const value = args[name];
|
|
2019
|
+
if (typeof value !== "string" || value === "") throw new InvalidArgumentsError(`argument "${name}" is required`);
|
|
2020
|
+
return value;
|
|
2021
|
+
};
|
|
2022
|
+
const LOCAL_TOOL_SCHEMAS = {
|
|
2023
|
+
inspectLocalFile: {
|
|
2024
|
+
type: "object",
|
|
2025
|
+
properties: { path: {
|
|
2026
|
+
type: "string",
|
|
2027
|
+
description: "ローカルファイルのパス(相対パスは作業ディレクトリ基準)。"
|
|
2028
|
+
} },
|
|
2029
|
+
required: ["path"],
|
|
2030
|
+
additionalProperties: false
|
|
2031
|
+
},
|
|
2032
|
+
uploadFile: {
|
|
2033
|
+
type: "object",
|
|
2034
|
+
properties: {
|
|
2035
|
+
url: {
|
|
2036
|
+
type: "string",
|
|
2037
|
+
description: "直前の API 応答で返されたアップロード URL。"
|
|
2038
|
+
},
|
|
2039
|
+
path: {
|
|
2040
|
+
type: "string",
|
|
2041
|
+
description: "アップロードするローカルファイルのパス。"
|
|
2042
|
+
}
|
|
2043
|
+
},
|
|
2044
|
+
required: ["url", "path"],
|
|
2045
|
+
additionalProperties: false
|
|
2046
|
+
},
|
|
2047
|
+
downloadFile: {
|
|
2048
|
+
type: "object",
|
|
2049
|
+
properties: {
|
|
2050
|
+
url: {
|
|
2051
|
+
type: "string",
|
|
2052
|
+
description: "直前の API 応答で返されたダウンロード URL。"
|
|
2053
|
+
},
|
|
2054
|
+
fileName: {
|
|
2055
|
+
type: "string",
|
|
2056
|
+
description: "保存名(省略時は URL の末尾から決める)。"
|
|
2057
|
+
}
|
|
2058
|
+
},
|
|
2059
|
+
required: ["url"],
|
|
2060
|
+
additionalProperties: false
|
|
2061
|
+
}
|
|
2062
|
+
};
|
|
2063
|
+
const registerLocalTools = (server, deps) => {
|
|
2064
|
+
server.registerTool("inspect_local_file", {
|
|
2065
|
+
title: "ローカルファイルの検査",
|
|
2066
|
+
description: "アップロード前にローカルファイルの名前・サイズ・sha256(hex / base64)・MIME を返します。API の byteSize には sizeBytes、checksumSha256 には sha256Hex を渡してください。読めるのは起動設定の根ディレクトリ配下だけです。",
|
|
2067
|
+
inputSchema: fromJsonSchema(toJsonSchema(LOCAL_TOOL_SCHEMAS.inspectLocalFile, "inspect_local_file")),
|
|
2068
|
+
annotations: {
|
|
2069
|
+
readOnlyHint: true,
|
|
2070
|
+
destructiveHint: false,
|
|
2071
|
+
idempotentHint: true,
|
|
2072
|
+
openWorldHint: false
|
|
2073
|
+
}
|
|
2074
|
+
}, async (rawArgs) => {
|
|
2075
|
+
const args = argumentsOf(rawArgs);
|
|
2076
|
+
return toCallToolResult(await runTool(deps, "inspect_local_file", () => inspectLocalFile(requireStringArgument(args, "path"), deps.config.fileRoots, deps.cwd, deps.workspace)), resultOptionsFor(deps, "inspect_local_file"));
|
|
2077
|
+
});
|
|
2078
|
+
server.registerTool("upload_file", {
|
|
2079
|
+
title: "署名付き URL へアップロード",
|
|
2080
|
+
description: "直前の API 応答で返されたアップロード URL へローカルファイルを PUT します。URL は応答由来のものだけを 15 分・1 回限りで受け付け、認証ヘッダは付けません。",
|
|
2081
|
+
inputSchema: fromJsonSchema(toJsonSchema(LOCAL_TOOL_SCHEMAS.uploadFile, "upload_file")),
|
|
2082
|
+
annotations: {
|
|
2083
|
+
readOnlyHint: false,
|
|
2084
|
+
destructiveHint: false,
|
|
2085
|
+
idempotentHint: false,
|
|
2086
|
+
openWorldHint: false
|
|
2087
|
+
}
|
|
2088
|
+
}, async (rawArgs) => {
|
|
2089
|
+
const args = argumentsOf(rawArgs);
|
|
2090
|
+
return toCallToolResult(await runTool(deps, "upload_file", async () => {
|
|
2091
|
+
const rawUrl = requireStringArgument(args, "url");
|
|
2092
|
+
const filePath = await resolveWithinRoots(requireStringArgument(args, "path"), deps.config.fileRoots, deps.cwd, deps.workspace);
|
|
2093
|
+
const registered = deps.registry.take(rawUrl, "upload");
|
|
2094
|
+
return await uploadFileToPresignedUrl({
|
|
2095
|
+
url: new URL(rawUrl),
|
|
2096
|
+
headers: registered.headers,
|
|
2097
|
+
filePath,
|
|
2098
|
+
fetch: deps.fetch,
|
|
2099
|
+
maxBytes: MAX_UPLOAD_BYTES,
|
|
2100
|
+
timeoutMs: BINARY_TOTAL_TIMEOUT_MS
|
|
2101
|
+
});
|
|
2102
|
+
}), resultOptionsFor(deps, "upload_file"));
|
|
2103
|
+
});
|
|
2104
|
+
server.registerTool("download_file", {
|
|
2105
|
+
title: "署名付き URL からダウンロード",
|
|
2106
|
+
description: "直前の API 応答で返されたダウンロード URL からファイルを取得し、出力ディレクトリに保存してパスを返します。URL は応答由来のものだけを受け付けます。",
|
|
2107
|
+
inputSchema: fromJsonSchema(toJsonSchema(LOCAL_TOOL_SCHEMAS.downloadFile, "download_file")),
|
|
2108
|
+
annotations: {
|
|
2109
|
+
readOnlyHint: true,
|
|
2110
|
+
destructiveHint: false,
|
|
2111
|
+
idempotentHint: true,
|
|
2112
|
+
openWorldHint: false
|
|
2113
|
+
}
|
|
2114
|
+
}, async (rawArgs) => {
|
|
2115
|
+
const args = argumentsOf(rawArgs);
|
|
2116
|
+
return toCallToolResult(await runTool(deps, "download_file", async () => {
|
|
2117
|
+
const rawUrl = requireStringArgument(args, "url");
|
|
2118
|
+
deps.registry.take(rawUrl, "download");
|
|
2119
|
+
const url = new URL(rawUrl);
|
|
2120
|
+
const requestedName = typeof args["fileName"] === "string" ? args["fileName"] : null;
|
|
2121
|
+
return await downloadPresignedUrlToFile({
|
|
2122
|
+
url,
|
|
2123
|
+
outputDir: deps.config.outputDir,
|
|
2124
|
+
fileName: sanitizeFileName(requestedName ?? url.pathname.split("/").at(-1) ?? null, "download.bin"),
|
|
2125
|
+
fetch: deps.fetch,
|
|
2126
|
+
maxBytes: MAX_DOWNLOAD_BYTES,
|
|
2127
|
+
timeoutMs: BINARY_TOTAL_TIMEOUT_MS
|
|
2128
|
+
});
|
|
2129
|
+
}), resultOptionsFor(deps, "download_file"));
|
|
2130
|
+
});
|
|
2131
|
+
};
|
|
2132
|
+
/** 操作表の 1 行 = 1 ツール。表に無い操作は登録されない(任意 URL のプロキシを持たない)。 */
|
|
2133
|
+
const createFlygateMcpServer = (deps, manifest) => {
|
|
2134
|
+
const server = new McpServer({
|
|
2135
|
+
name: MCP_SERVER_NAME,
|
|
2136
|
+
version: MCP_SERVER_VERSION
|
|
2137
|
+
}, {
|
|
2138
|
+
capabilities: { tools: {} },
|
|
2139
|
+
instructions: serverInstructions(deps.config.tenantName)
|
|
2140
|
+
});
|
|
2141
|
+
for (const entry of manifest.operations) server.registerTool(entry.operationId, {
|
|
2142
|
+
title: entry.summary,
|
|
2143
|
+
description: describeOperation(entry),
|
|
2144
|
+
inputSchema: fromJsonSchema(toJsonSchema(entry.inputSchema, entry.operationId)),
|
|
2145
|
+
annotations: annotationsFor(entry)
|
|
2146
|
+
}, async (rawArgs) => toCallToolResult(await invokeOperation(deps, entry, argumentsOf(rawArgs)), resultOptionsFor(deps, entry.operationId)));
|
|
2147
|
+
registerLocalTools(server, deps);
|
|
2148
|
+
return server;
|
|
2149
|
+
};
|
|
2150
|
+
//#endregion
|
|
2151
|
+
//#region src/main.ts
|
|
2152
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
2153
|
+
const bootstrap = async () => {
|
|
2154
|
+
const cwd = process.cwd();
|
|
2155
|
+
let config;
|
|
2156
|
+
let workspace;
|
|
2157
|
+
try {
|
|
2158
|
+
config = loadConfig(process.env, cwd);
|
|
2159
|
+
assertConfiguredPathInsideWorkspace(cwd, config.outputDir, ENV_KEYS.outputDir);
|
|
2160
|
+
for (const root of config.fileRoots) assertConfiguredPathInsideWorkspace(cwd, root, ENV_KEYS.fileRoots);
|
|
2161
|
+
workspace = await bindWorkspace({
|
|
2162
|
+
cwd,
|
|
2163
|
+
homeDir: homedir(),
|
|
2164
|
+
hostname: hostname(),
|
|
2165
|
+
username: userInfo().username,
|
|
2166
|
+
now: () => /* @__PURE__ */ new Date()
|
|
2167
|
+
}, config.tenantName);
|
|
2168
|
+
} catch (err) {
|
|
2169
|
+
console.error(err instanceof ConfigError || err instanceof WorkspaceBindingError ? err.message : String(err));
|
|
2170
|
+
process.exit(1);
|
|
2171
|
+
}
|
|
2172
|
+
const logger = createLogger([config.apiKey]);
|
|
2173
|
+
const manifest = loadManifestFile();
|
|
2174
|
+
const now = () => Date.now();
|
|
2175
|
+
const deps = {
|
|
2176
|
+
config,
|
|
2177
|
+
client: createApiClient({
|
|
2178
|
+
config,
|
|
2179
|
+
workspaceId: workspace.workspaceId,
|
|
2180
|
+
fetch: globalThis.fetch,
|
|
2181
|
+
now,
|
|
2182
|
+
sleep,
|
|
2183
|
+
logger
|
|
2184
|
+
}),
|
|
2185
|
+
registry: createPresignedUrlRegistry(now),
|
|
2186
|
+
fetch: globalThis.fetch,
|
|
2187
|
+
logger,
|
|
2188
|
+
now,
|
|
2189
|
+
cwd,
|
|
2190
|
+
workspace
|
|
2191
|
+
};
|
|
2192
|
+
const handle = serveStdio(() => createFlygateMcpServer(deps, manifest));
|
|
2193
|
+
logger.info("flygate mcp server started", {
|
|
2194
|
+
baseUrl: config.baseUrl.origin,
|
|
2195
|
+
tenantName: config.tenantName,
|
|
2196
|
+
workspace: workspace.root,
|
|
2197
|
+
operations: manifest.operations.length
|
|
2198
|
+
});
|
|
2199
|
+
const shutdown = () => {
|
|
2200
|
+
handle.close().catch(ignoreCleanupError).finally(() => process.exit(0));
|
|
2201
|
+
};
|
|
2202
|
+
process.on("SIGINT", shutdown);
|
|
2203
|
+
process.on("SIGTERM", shutdown);
|
|
2204
|
+
};
|
|
2205
|
+
await bootstrap();
|
|
2206
|
+
//#endregion
|
|
2207
|
+
export {};
|