@tempad-dev/mcp 0.1.0 → 0.2.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/dist/cli.js +7 -1
- package/dist/cli.js.map +2 -2
- package/dist/hub.js +884 -91
- package/dist/hub.js.map +4 -4
- package/package.json +1 -1
package/dist/hub.js
CHANGED
|
@@ -1,13 +1,60 @@
|
|
|
1
1
|
// src/hub.ts
|
|
2
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
-
import { nanoid as
|
|
5
|
-
import { existsSync, rmSync, chmodSync } from "node:fs";
|
|
6
|
-
import { createServer } from "node:net";
|
|
4
|
+
import { nanoid as nanoid3 } from "nanoid";
|
|
5
|
+
import { existsSync as existsSync3, rmSync as rmSync2, chmodSync, readFileSync as readFileSync2, statSync as statSync3 } from "node:fs";
|
|
6
|
+
import { createServer as createServer2 } from "node:net";
|
|
7
7
|
import { WebSocketServer } from "ws";
|
|
8
8
|
|
|
9
|
-
//
|
|
9
|
+
// ../mcp/shared/constants.ts
|
|
10
|
+
var MCP_PORT_CANDIDATES = [6220, 7431, 8127];
|
|
11
|
+
var MCP_MAX_PAYLOAD_BYTES = 4 * 1024 * 1024;
|
|
12
|
+
var MCP_TOOL_TIMEOUT_MS = 15e3;
|
|
13
|
+
var MCP_AUTO_ACTIVATE_GRACE_MS = 1500;
|
|
14
|
+
var MCP_MAX_ASSET_BYTES = 8 * 1024 * 1024;
|
|
15
|
+
var MCP_ASSET_RESOURCE_NAME = "tempad-assets";
|
|
16
|
+
var MCP_ASSET_URI_PREFIX = "asset://tempad/";
|
|
17
|
+
var MCP_ASSET_URI_TEMPLATE = `${MCP_ASSET_URI_PREFIX}{hash}`;
|
|
18
|
+
var MCP_HASH_PATTERN = /^[a-f0-9]{64}$/i;
|
|
19
|
+
|
|
20
|
+
// src/asset-http-server.ts
|
|
10
21
|
import { nanoid } from "nanoid";
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
23
|
+
import {
|
|
24
|
+
createReadStream,
|
|
25
|
+
createWriteStream,
|
|
26
|
+
existsSync,
|
|
27
|
+
renameSync,
|
|
28
|
+
statSync,
|
|
29
|
+
unlinkSync
|
|
30
|
+
} from "node:fs";
|
|
31
|
+
import { createServer } from "node:http";
|
|
32
|
+
import { join as join2 } from "node:path";
|
|
33
|
+
import { URL } from "node:url";
|
|
34
|
+
|
|
35
|
+
// src/config.ts
|
|
36
|
+
function parsePositiveInt(envValue, fallback) {
|
|
37
|
+
const parsed = envValue ? Number.parseInt(envValue, 10) : Number.NaN;
|
|
38
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
39
|
+
}
|
|
40
|
+
function resolveToolTimeoutMs() {
|
|
41
|
+
return parsePositiveInt(process.env.TEMPAD_MCP_TOOL_TIMEOUT, MCP_TOOL_TIMEOUT_MS);
|
|
42
|
+
}
|
|
43
|
+
function resolveAutoActivateGraceMs() {
|
|
44
|
+
return parsePositiveInt(process.env.TEMPAD_MCP_AUTO_ACTIVATE_GRACE, MCP_AUTO_ACTIVATE_GRACE_MS);
|
|
45
|
+
}
|
|
46
|
+
function resolveMaxAssetSizeBytes() {
|
|
47
|
+
return parsePositiveInt(process.env.TEMPAD_MCP_MAX_ASSET_BYTES, MCP_MAX_ASSET_BYTES);
|
|
48
|
+
}
|
|
49
|
+
function getMcpServerConfig() {
|
|
50
|
+
return {
|
|
51
|
+
wsPortCandidates: [...MCP_PORT_CANDIDATES],
|
|
52
|
+
toolTimeoutMs: resolveToolTimeoutMs(),
|
|
53
|
+
maxPayloadBytes: MCP_MAX_PAYLOAD_BYTES,
|
|
54
|
+
autoActivateGraceMs: resolveAutoActivateGraceMs(),
|
|
55
|
+
maxAssetSizeBytes: resolveMaxAssetSizeBytes()
|
|
56
|
+
};
|
|
57
|
+
}
|
|
11
58
|
|
|
12
59
|
// src/shared.ts
|
|
13
60
|
import { closeSync, mkdirSync, openSync } from "node:fs";
|
|
@@ -25,10 +72,16 @@ function resolveLogDir() {
|
|
|
25
72
|
if (process.env.TEMPAD_MCP_LOG_DIR) return process.env.TEMPAD_MCP_LOG_DIR;
|
|
26
73
|
return join(tmpdir(), "tempad-dev", "log");
|
|
27
74
|
}
|
|
75
|
+
function resolveAssetDir() {
|
|
76
|
+
if (process.env.TEMPAD_MCP_ASSET_DIR) return process.env.TEMPAD_MCP_ASSET_DIR;
|
|
77
|
+
return join(tmpdir(), "tempad-dev", "assets");
|
|
78
|
+
}
|
|
28
79
|
var RUNTIME_DIR = resolveRuntimeDir();
|
|
29
80
|
var LOG_DIR = resolveLogDir();
|
|
81
|
+
var ASSET_DIR = resolveAssetDir();
|
|
30
82
|
ensureDir(RUNTIME_DIR);
|
|
31
83
|
ensureDir(LOG_DIR);
|
|
84
|
+
ensureDir(ASSET_DIR);
|
|
32
85
|
function ensureFile(filePath) {
|
|
33
86
|
const fd = openSync(filePath, "a");
|
|
34
87
|
closeSync(fd);
|
|
@@ -54,10 +107,447 @@ var log = pino(
|
|
|
54
107
|
);
|
|
55
108
|
var SOCK_PATH = process.platform === "win32" ? "\\\\.\\pipe\\tempad-mcp" : join(RUNTIME_DIR, "mcp.sock");
|
|
56
109
|
|
|
110
|
+
// src/asset-http-server.ts
|
|
111
|
+
var LOOPBACK_HOST = "127.0.0.1";
|
|
112
|
+
var { maxAssetSizeBytes } = getMcpServerConfig();
|
|
113
|
+
function createAssetHttpServer(store) {
|
|
114
|
+
const server = createServer(handleRequest);
|
|
115
|
+
let port2 = null;
|
|
116
|
+
async function start() {
|
|
117
|
+
if (port2 !== null) return;
|
|
118
|
+
await new Promise((resolve2, reject2) => {
|
|
119
|
+
const onError = (error) => {
|
|
120
|
+
server.off("listening", onListening);
|
|
121
|
+
reject2(error);
|
|
122
|
+
};
|
|
123
|
+
const onListening = () => {
|
|
124
|
+
server.off("error", onError);
|
|
125
|
+
const address = server.address();
|
|
126
|
+
if (address && typeof address === "object") {
|
|
127
|
+
port2 = address.port;
|
|
128
|
+
resolve2();
|
|
129
|
+
} else {
|
|
130
|
+
reject2(new Error("Failed to determine HTTP server port."));
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
server.once("error", onError);
|
|
134
|
+
server.once("listening", onListening);
|
|
135
|
+
server.listen(0, LOOPBACK_HOST);
|
|
136
|
+
});
|
|
137
|
+
log.info({ port: port2 }, "Asset HTTP server ready.");
|
|
138
|
+
}
|
|
139
|
+
function stop() {
|
|
140
|
+
if (port2 === null) return;
|
|
141
|
+
server.close();
|
|
142
|
+
port2 = null;
|
|
143
|
+
}
|
|
144
|
+
function getBaseUrl() {
|
|
145
|
+
if (port2 === null) throw new Error("Asset HTTP server is not running.");
|
|
146
|
+
return `http://${LOOPBACK_HOST}:${port2}`;
|
|
147
|
+
}
|
|
148
|
+
function handleRequest(req, res) {
|
|
149
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
150
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
151
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-Asset-Width, X-Asset-Height");
|
|
152
|
+
if (req.method === "OPTIONS") {
|
|
153
|
+
res.writeHead(204);
|
|
154
|
+
res.end();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (!req.url) {
|
|
158
|
+
res.writeHead(400);
|
|
159
|
+
res.end("Missing URL");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const url = new URL(req.url, getBaseUrl());
|
|
163
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
164
|
+
if (segments.length !== 2 || segments[0] !== "assets") {
|
|
165
|
+
res.writeHead(404);
|
|
166
|
+
res.end("Not Found");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const hash = segments[1];
|
|
170
|
+
if (req.method === "POST") {
|
|
171
|
+
handleUpload(req, res, hash);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (req.method === "GET") {
|
|
175
|
+
handleDownload(req, res, hash);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
res.writeHead(405);
|
|
179
|
+
res.end("Method Not Allowed");
|
|
180
|
+
}
|
|
181
|
+
function handleDownload(req, res, hash) {
|
|
182
|
+
const record = store.get(hash);
|
|
183
|
+
if (!record) {
|
|
184
|
+
res.writeHead(404);
|
|
185
|
+
res.end("Not Found");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
let stat;
|
|
189
|
+
try {
|
|
190
|
+
stat = statSync(record.filePath);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
const err = error;
|
|
193
|
+
if (err.code === "ENOENT") {
|
|
194
|
+
store.remove(hash, { removeFile: false });
|
|
195
|
+
res.writeHead(404);
|
|
196
|
+
res.end("Not Found");
|
|
197
|
+
} else {
|
|
198
|
+
log.error({ error, hash }, "Failed to stat asset file.");
|
|
199
|
+
res.writeHead(500);
|
|
200
|
+
res.end("Internal Server Error");
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
res.writeHead(200, {
|
|
205
|
+
"Content-Type": record.mimeType,
|
|
206
|
+
"Content-Length": stat.size.toString(),
|
|
207
|
+
"Cache-Control": "public, max-age=31536000, immutable"
|
|
208
|
+
});
|
|
209
|
+
const stream = createReadStream(record.filePath);
|
|
210
|
+
stream.on("error", (error) => {
|
|
211
|
+
log.warn({ error, hash }, "Failed to stream asset file.");
|
|
212
|
+
if (!res.headersSent) {
|
|
213
|
+
res.writeHead(500);
|
|
214
|
+
}
|
|
215
|
+
res.end("Internal Server Error");
|
|
216
|
+
});
|
|
217
|
+
stream.on("open", () => {
|
|
218
|
+
store.touch(hash);
|
|
219
|
+
});
|
|
220
|
+
stream.pipe(res);
|
|
221
|
+
}
|
|
222
|
+
function handleUpload(req, res, hash) {
|
|
223
|
+
if (!MCP_HASH_PATTERN.test(hash)) {
|
|
224
|
+
res.writeHead(400);
|
|
225
|
+
res.end("Invalid Hash Format");
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const mimeType = req.headers["content-type"] || "application/octet-stream";
|
|
229
|
+
const filePath = join2(ASSET_DIR, hash);
|
|
230
|
+
const width = parseInt(req.headers["x-asset-width"], 10);
|
|
231
|
+
const height = parseInt(req.headers["x-asset-height"], 10);
|
|
232
|
+
const metadata = !isNaN(width) && !isNaN(height) && width > 0 && height > 0 ? { width, height } : void 0;
|
|
233
|
+
if (store.has(hash) && existsSync(filePath)) {
|
|
234
|
+
req.resume();
|
|
235
|
+
const existing = store.get(hash);
|
|
236
|
+
let changed = false;
|
|
237
|
+
if (metadata) {
|
|
238
|
+
existing.metadata = metadata;
|
|
239
|
+
changed = true;
|
|
240
|
+
}
|
|
241
|
+
if (existing.mimeType !== mimeType) {
|
|
242
|
+
existing.mimeType = mimeType;
|
|
243
|
+
changed = true;
|
|
244
|
+
}
|
|
245
|
+
if (changed) {
|
|
246
|
+
store.upsert(existing);
|
|
247
|
+
}
|
|
248
|
+
store.touch(hash);
|
|
249
|
+
res.writeHead(200);
|
|
250
|
+
res.end("OK");
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const tmpPath = `${filePath}.tmp.${nanoid()}`;
|
|
254
|
+
const writeStream = createWriteStream(tmpPath);
|
|
255
|
+
const hasher = createHash("sha256");
|
|
256
|
+
let size = 0;
|
|
257
|
+
let aborted = false;
|
|
258
|
+
const cleanup = () => {
|
|
259
|
+
if (existsSync(tmpPath)) {
|
|
260
|
+
try {
|
|
261
|
+
unlinkSync(tmpPath);
|
|
262
|
+
} catch (e) {
|
|
263
|
+
log.warn({ error: e, tmpPath }, "Failed to cleanup temp file.");
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
req.on("data", (chunk) => {
|
|
268
|
+
if (aborted) return;
|
|
269
|
+
size += chunk.length;
|
|
270
|
+
if (size > maxAssetSizeBytes) {
|
|
271
|
+
aborted = true;
|
|
272
|
+
req.destroy();
|
|
273
|
+
writeStream.destroy();
|
|
274
|
+
cleanup();
|
|
275
|
+
res.writeHead(413);
|
|
276
|
+
res.end("Payload Too Large");
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
hasher.update(chunk);
|
|
280
|
+
});
|
|
281
|
+
req.pipe(writeStream);
|
|
282
|
+
req.on("aborted", () => {
|
|
283
|
+
aborted = true;
|
|
284
|
+
writeStream.destroy();
|
|
285
|
+
cleanup();
|
|
286
|
+
});
|
|
287
|
+
req.on("error", (err) => {
|
|
288
|
+
log.warn({ err, hash }, "Upload request error.");
|
|
289
|
+
aborted = true;
|
|
290
|
+
writeStream.destroy();
|
|
291
|
+
cleanup();
|
|
292
|
+
});
|
|
293
|
+
req.on("close", () => {
|
|
294
|
+
if (!res.writableEnded && !aborted) {
|
|
295
|
+
log.warn({ hash }, "Upload request closed prematurely.");
|
|
296
|
+
aborted = true;
|
|
297
|
+
writeStream.destroy();
|
|
298
|
+
cleanup();
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
writeStream.on("error", (error) => {
|
|
302
|
+
if (aborted) return;
|
|
303
|
+
log.error({ error, hash }, "Failed to write uploaded asset.");
|
|
304
|
+
cleanup();
|
|
305
|
+
res.writeHead(500);
|
|
306
|
+
res.end("Internal Server Error");
|
|
307
|
+
});
|
|
308
|
+
writeStream.on("finish", () => {
|
|
309
|
+
if (aborted) return;
|
|
310
|
+
const computedHash = hasher.digest("hex");
|
|
311
|
+
if (computedHash !== hash) {
|
|
312
|
+
cleanup();
|
|
313
|
+
res.writeHead(400);
|
|
314
|
+
res.end("Hash Mismatch");
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
renameSync(tmpPath, filePath);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
log.error({ error, hash }, "Failed to rename temp file to asset.");
|
|
321
|
+
cleanup();
|
|
322
|
+
res.writeHead(500);
|
|
323
|
+
res.end("Internal Server Error");
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
store.upsert({
|
|
327
|
+
hash,
|
|
328
|
+
filePath,
|
|
329
|
+
mimeType,
|
|
330
|
+
size,
|
|
331
|
+
metadata
|
|
332
|
+
});
|
|
333
|
+
log.info({ hash, size }, "Stored uploaded asset via HTTP.");
|
|
334
|
+
res.writeHead(201);
|
|
335
|
+
res.end("Created");
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
start,
|
|
340
|
+
stop,
|
|
341
|
+
getBaseUrl
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// src/asset-store.ts
|
|
346
|
+
import { existsSync as existsSync2, readFileSync, rmSync, writeFileSync, readdirSync, statSync as statSync2 } from "node:fs";
|
|
347
|
+
import { join as join3 } from "node:path";
|
|
348
|
+
var INDEX_FILENAME = "assets.json";
|
|
349
|
+
var DEFAULT_INDEX_PATH = join3(ASSET_DIR, INDEX_FILENAME);
|
|
350
|
+
function readIndex(indexPath) {
|
|
351
|
+
if (!existsSync2(indexPath)) return [];
|
|
352
|
+
try {
|
|
353
|
+
const raw = readFileSync(indexPath, "utf8").trim();
|
|
354
|
+
if (!raw) return [];
|
|
355
|
+
const parsed = JSON.parse(raw);
|
|
356
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
357
|
+
} catch (error) {
|
|
358
|
+
log.warn({ error, indexPath }, "Failed to read asset catalog; starting fresh.");
|
|
359
|
+
return [];
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
function writeIndex(indexPath, values) {
|
|
363
|
+
const payload = JSON.stringify(values, null, 2);
|
|
364
|
+
writeFileSync(indexPath, payload, "utf8");
|
|
365
|
+
}
|
|
366
|
+
function createAssetStore(options = {}) {
|
|
367
|
+
ensureDir(ASSET_DIR);
|
|
368
|
+
const indexPath = options.indexPath ?? DEFAULT_INDEX_PATH;
|
|
369
|
+
ensureFile(indexPath);
|
|
370
|
+
const records = /* @__PURE__ */ new Map();
|
|
371
|
+
let persistTimer = null;
|
|
372
|
+
function loadExisting() {
|
|
373
|
+
const list2 = readIndex(indexPath);
|
|
374
|
+
for (const record of list2) {
|
|
375
|
+
if (record?.hash && record?.filePath) {
|
|
376
|
+
records.set(record.hash, record);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
function persist() {
|
|
381
|
+
if (persistTimer) return;
|
|
382
|
+
persistTimer = setTimeout(() => {
|
|
383
|
+
persistTimer = null;
|
|
384
|
+
writeIndex(indexPath, [...records.values()]);
|
|
385
|
+
}, 5e3);
|
|
386
|
+
if (typeof persistTimer.unref === "function") {
|
|
387
|
+
persistTimer.unref();
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
function flush() {
|
|
391
|
+
if (persistTimer) {
|
|
392
|
+
clearTimeout(persistTimer);
|
|
393
|
+
persistTimer = null;
|
|
394
|
+
}
|
|
395
|
+
writeIndex(indexPath, [...records.values()]);
|
|
396
|
+
}
|
|
397
|
+
function list() {
|
|
398
|
+
return [...records.values()];
|
|
399
|
+
}
|
|
400
|
+
function has(hash) {
|
|
401
|
+
return records.has(hash);
|
|
402
|
+
}
|
|
403
|
+
function get(hash) {
|
|
404
|
+
return records.get(hash);
|
|
405
|
+
}
|
|
406
|
+
function getMany(hashes) {
|
|
407
|
+
return hashes.map((hash) => records.get(hash)).filter((record) => !!record);
|
|
408
|
+
}
|
|
409
|
+
function upsert(input) {
|
|
410
|
+
const now = Date.now();
|
|
411
|
+
const record = {
|
|
412
|
+
...input,
|
|
413
|
+
uploadedAt: input.uploadedAt ?? now,
|
|
414
|
+
lastAccess: input.lastAccess ?? now
|
|
415
|
+
};
|
|
416
|
+
records.set(record.hash, record);
|
|
417
|
+
persist();
|
|
418
|
+
return record;
|
|
419
|
+
}
|
|
420
|
+
function touch(hash) {
|
|
421
|
+
const existing = records.get(hash);
|
|
422
|
+
if (!existing) return void 0;
|
|
423
|
+
existing.lastAccess = Date.now();
|
|
424
|
+
persist();
|
|
425
|
+
return existing;
|
|
426
|
+
}
|
|
427
|
+
function remove(hash, { removeFile = true } = {}) {
|
|
428
|
+
const record = records.get(hash);
|
|
429
|
+
if (!record) return;
|
|
430
|
+
records.delete(hash);
|
|
431
|
+
persist();
|
|
432
|
+
if (removeFile) {
|
|
433
|
+
try {
|
|
434
|
+
rmSync(record.filePath, { force: true });
|
|
435
|
+
} catch (error) {
|
|
436
|
+
log.warn({ hash, error }, "Failed to remove asset file on delete.");
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
function reconcile() {
|
|
441
|
+
let changed = false;
|
|
442
|
+
for (const [hash, record] of records) {
|
|
443
|
+
if (!existsSync2(record.filePath)) {
|
|
444
|
+
records.delete(hash);
|
|
445
|
+
changed = true;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
try {
|
|
449
|
+
const files = readdirSync(ASSET_DIR);
|
|
450
|
+
const now = Date.now();
|
|
451
|
+
for (const file of files) {
|
|
452
|
+
if (file === INDEX_FILENAME) continue;
|
|
453
|
+
if (file.includes(".tmp.")) {
|
|
454
|
+
try {
|
|
455
|
+
const filePath = join3(ASSET_DIR, file);
|
|
456
|
+
const stat = statSync2(filePath);
|
|
457
|
+
if (now - stat.mtimeMs > 3600 * 1e3) {
|
|
458
|
+
rmSync(filePath, { force: true });
|
|
459
|
+
log.info({ file }, "Cleaned up stale temp file.");
|
|
460
|
+
}
|
|
461
|
+
} catch {
|
|
462
|
+
}
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
if (!/^[a-f0-9]{64}$/i.test(file)) continue;
|
|
466
|
+
if (!records.has(file)) {
|
|
467
|
+
const filePath = join3(ASSET_DIR, file);
|
|
468
|
+
try {
|
|
469
|
+
const stat = statSync2(filePath);
|
|
470
|
+
records.set(file, {
|
|
471
|
+
hash: file,
|
|
472
|
+
filePath,
|
|
473
|
+
mimeType: "application/octet-stream",
|
|
474
|
+
size: stat.size,
|
|
475
|
+
uploadedAt: stat.birthtimeMs,
|
|
476
|
+
lastAccess: stat.atimeMs
|
|
477
|
+
});
|
|
478
|
+
changed = true;
|
|
479
|
+
log.info({ hash: file }, "Recovered orphan asset file.");
|
|
480
|
+
} catch (e) {
|
|
481
|
+
log.warn({ error: e, file }, "Failed to stat orphan file.");
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
} catch (error) {
|
|
486
|
+
log.warn({ error }, "Failed to scan asset directory for orphans.");
|
|
487
|
+
}
|
|
488
|
+
if (changed) flush();
|
|
489
|
+
}
|
|
490
|
+
loadExisting();
|
|
491
|
+
reconcile();
|
|
492
|
+
return {
|
|
493
|
+
list,
|
|
494
|
+
has,
|
|
495
|
+
get,
|
|
496
|
+
getMany,
|
|
497
|
+
upsert,
|
|
498
|
+
touch,
|
|
499
|
+
remove,
|
|
500
|
+
reconcile,
|
|
501
|
+
flush
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// src/protocol.ts
|
|
506
|
+
import { z } from "zod";
|
|
507
|
+
var RegisteredMessageSchema = z.object({
|
|
508
|
+
type: z.literal("registered"),
|
|
509
|
+
id: z.string()
|
|
510
|
+
});
|
|
511
|
+
var StateMessageSchema = z.object({
|
|
512
|
+
type: z.literal("state"),
|
|
513
|
+
activeId: z.string().nullable(),
|
|
514
|
+
count: z.number().nonnegative(),
|
|
515
|
+
port: z.number().positive(),
|
|
516
|
+
assetServerUrl: z.string().url()
|
|
517
|
+
});
|
|
518
|
+
var ToolCallPayloadSchema = z.object({
|
|
519
|
+
name: z.string(),
|
|
520
|
+
args: z.unknown()
|
|
521
|
+
});
|
|
522
|
+
var ToolCallMessageSchema = z.object({
|
|
523
|
+
type: z.literal("toolCall"),
|
|
524
|
+
id: z.string(),
|
|
525
|
+
payload: ToolCallPayloadSchema
|
|
526
|
+
});
|
|
527
|
+
var MessageToExtensionSchema = z.discriminatedUnion("type", [
|
|
528
|
+
RegisteredMessageSchema,
|
|
529
|
+
StateMessageSchema,
|
|
530
|
+
ToolCallMessageSchema
|
|
531
|
+
]);
|
|
532
|
+
var ActivateMessageSchema = z.object({
|
|
533
|
+
type: z.literal("activate")
|
|
534
|
+
});
|
|
535
|
+
var ToolResultMessageSchema = z.object({
|
|
536
|
+
type: z.literal("toolResult"),
|
|
537
|
+
id: z.string(),
|
|
538
|
+
payload: z.unknown().optional(),
|
|
539
|
+
error: z.unknown().optional()
|
|
540
|
+
});
|
|
541
|
+
var MessageFromExtensionSchema = z.discriminatedUnion("type", [
|
|
542
|
+
ActivateMessageSchema,
|
|
543
|
+
ToolResultMessageSchema
|
|
544
|
+
]);
|
|
545
|
+
|
|
57
546
|
// src/request.ts
|
|
547
|
+
import { nanoid as nanoid2 } from "nanoid";
|
|
58
548
|
var pendingCalls = /* @__PURE__ */ new Map();
|
|
59
549
|
function register(extensionId, timeout) {
|
|
60
|
-
const requestId =
|
|
550
|
+
const requestId = nanoid2();
|
|
61
551
|
const promise = new Promise((resolve2, reject2) => {
|
|
62
552
|
const timer = setTimeout(() => {
|
|
63
553
|
pendingCalls.delete(requestId);
|
|
@@ -75,8 +565,9 @@ function register(extensionId, timeout) {
|
|
|
75
565
|
function resolve(requestId, payload) {
|
|
76
566
|
const call = pendingCalls.get(requestId);
|
|
77
567
|
if (call) {
|
|
78
|
-
|
|
79
|
-
|
|
568
|
+
const { timer, resolve: finish } = call;
|
|
569
|
+
clearTimeout(timer);
|
|
570
|
+
finish(payload);
|
|
80
571
|
pendingCalls.delete(requestId);
|
|
81
572
|
} else {
|
|
82
573
|
log.warn({ reqId: requestId }, "Received result for unknown/timed-out call.");
|
|
@@ -85,8 +576,9 @@ function resolve(requestId, payload) {
|
|
|
85
576
|
function reject(requestId, error) {
|
|
86
577
|
const call = pendingCalls.get(requestId);
|
|
87
578
|
if (call) {
|
|
88
|
-
|
|
89
|
-
|
|
579
|
+
const { timer, reject: fail } = call;
|
|
580
|
+
clearTimeout(timer);
|
|
581
|
+
fail(error);
|
|
90
582
|
pendingCalls.delete(requestId);
|
|
91
583
|
} else {
|
|
92
584
|
log.warn({ reqId: requestId }, "Received error for unknown/timed-out call.");
|
|
@@ -94,9 +586,10 @@ function reject(requestId, error) {
|
|
|
94
586
|
}
|
|
95
587
|
function cleanupForExtension(extensionId) {
|
|
96
588
|
for (const [reqId, call] of pendingCalls.entries()) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
589
|
+
const { timer, reject: fail, extensionId: extId } = call;
|
|
590
|
+
if (extId === extensionId) {
|
|
591
|
+
clearTimeout(timer);
|
|
592
|
+
fail(new Error("Extension disconnected before providing a result."));
|
|
100
593
|
pendingCalls.delete(reqId);
|
|
101
594
|
log.warn({ reqId, extId: extensionId }, "Rejected pending call from disconnected extension.");
|
|
102
595
|
}
|
|
@@ -104,82 +597,236 @@ function cleanupForExtension(extensionId) {
|
|
|
104
597
|
}
|
|
105
598
|
function cleanupAll() {
|
|
106
599
|
pendingCalls.forEach((call, reqId) => {
|
|
107
|
-
|
|
108
|
-
|
|
600
|
+
const { timer, reject: fail } = call;
|
|
601
|
+
clearTimeout(timer);
|
|
602
|
+
fail(new Error("Hub is shutting down."));
|
|
109
603
|
log.debug({ reqId }, "Rejected pending tool call due to shutdown.");
|
|
110
604
|
});
|
|
111
605
|
pendingCalls.clear();
|
|
112
606
|
}
|
|
113
607
|
|
|
114
608
|
// src/tools.ts
|
|
115
|
-
import { z } from "zod";
|
|
116
|
-
var GetCodeParametersSchema = z.object({
|
|
117
|
-
output: z.enum(["css", "js"]).optional().default("css")
|
|
118
|
-
});
|
|
119
|
-
var TOOLS = [
|
|
120
|
-
{
|
|
121
|
-
name: "get_code",
|
|
122
|
-
description: "Returns generated code for the currently selected node.",
|
|
123
|
-
parameters: GetCodeParametersSchema
|
|
124
|
-
}
|
|
125
|
-
];
|
|
126
|
-
|
|
127
|
-
// src/protocol.ts
|
|
128
609
|
import { z as z2 } from "zod";
|
|
129
|
-
var
|
|
130
|
-
|
|
131
|
-
|
|
610
|
+
var GetCodeParametersSchema = z2.object({
|
|
611
|
+
nodeId: z2.string().optional(),
|
|
612
|
+
preferredLang: z2.enum(["jsx", "vue"]).optional(),
|
|
613
|
+
resolveTokens: z2.boolean().optional()
|
|
132
614
|
});
|
|
133
|
-
var
|
|
134
|
-
|
|
135
|
-
activeId: z2.string().nullable(),
|
|
136
|
-
count: z2.number().nonnegative(),
|
|
137
|
-
port: z2.number().positive()
|
|
615
|
+
var GetTokenDefsParametersSchema = z2.object({
|
|
616
|
+
nodeId: z2.string().optional()
|
|
138
617
|
});
|
|
139
|
-
var
|
|
140
|
-
|
|
141
|
-
|
|
618
|
+
var AssetDescriptorSchema = z2.object({
|
|
619
|
+
hash: z2.string().min(1),
|
|
620
|
+
url: z2.string().url(),
|
|
621
|
+
mimeType: z2.string().min(1),
|
|
622
|
+
size: z2.number().int().nonnegative(),
|
|
623
|
+
resourceUri: z2.string().regex(/^asset:\/\/tempad\/[a-f0-9]{64}$/i),
|
|
624
|
+
width: z2.number().int().positive().optional(),
|
|
625
|
+
height: z2.number().int().positive().optional()
|
|
142
626
|
});
|
|
143
|
-
var
|
|
144
|
-
|
|
145
|
-
id: z2.string(),
|
|
146
|
-
payload: ToolCallPayloadSchema
|
|
627
|
+
var GetScreenshotParametersSchema = z2.object({
|
|
628
|
+
nodeId: z2.string().optional()
|
|
147
629
|
});
|
|
148
|
-
var
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
var ActivateMessageSchema = z2.object({
|
|
154
|
-
type: z2.literal("activate")
|
|
630
|
+
var GetStructureParametersSchema = z2.object({
|
|
631
|
+
nodeId: z2.string().optional(),
|
|
632
|
+
options: z2.object({
|
|
633
|
+
depth: z2.number().int().positive().optional()
|
|
634
|
+
}).optional()
|
|
155
635
|
});
|
|
156
|
-
var
|
|
157
|
-
|
|
158
|
-
id: z2.string(),
|
|
159
|
-
payload: z2.unknown().optional(),
|
|
160
|
-
error: z2.unknown().optional()
|
|
636
|
+
var GetAssetsParametersSchema = z2.object({
|
|
637
|
+
hashes: z2.array(z2.string().regex(MCP_HASH_PATTERN)).min(1)
|
|
161
638
|
});
|
|
162
|
-
var
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
639
|
+
var GetAssetsResultSchema = z2.object({
|
|
640
|
+
assets: z2.array(AssetDescriptorSchema),
|
|
641
|
+
missing: z2.array(z2.string().min(1))
|
|
642
|
+
});
|
|
643
|
+
var TOOLS = [
|
|
644
|
+
{
|
|
645
|
+
name: "get_code",
|
|
646
|
+
description: "High fidelity code snapshot for the current selection or provided node ids.",
|
|
647
|
+
parameters: GetCodeParametersSchema
|
|
648
|
+
},
|
|
649
|
+
{
|
|
650
|
+
name: "get_token_defs",
|
|
651
|
+
description: "Token definitions referenced by the current selection or provided node ids.",
|
|
652
|
+
parameters: GetTokenDefsParametersSchema
|
|
653
|
+
},
|
|
654
|
+
{
|
|
655
|
+
name: "get_screenshot",
|
|
656
|
+
description: "Rendered screenshot for the requested node.",
|
|
657
|
+
parameters: GetScreenshotParametersSchema
|
|
658
|
+
},
|
|
659
|
+
{
|
|
660
|
+
name: "get_structure",
|
|
661
|
+
description: "Structural outline of the current selection or provided node ids.",
|
|
662
|
+
parameters: GetStructureParametersSchema
|
|
663
|
+
}
|
|
664
|
+
];
|
|
166
665
|
|
|
167
666
|
// src/hub.ts
|
|
168
|
-
function parsePositiveInt(env, fallback) {
|
|
169
|
-
const parsed = env ? Number.parseInt(env, 10) : Number.NaN;
|
|
170
|
-
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
171
|
-
}
|
|
172
|
-
var WS_PORT_CANDIDATES = [6220, 7431, 8127];
|
|
173
|
-
var TOOL_CALL_TIMEOUT = parsePositiveInt(process.env.TEMPAD_MCP_TOOL_TIMEOUT, 15e3);
|
|
174
|
-
var MAX_PAYLOAD_SIZE = 4 * 1024 * 1024;
|
|
175
667
|
var SHUTDOWN_TIMEOUT = 2e3;
|
|
176
|
-
var
|
|
668
|
+
var { wsPortCandidates, toolTimeoutMs, maxPayloadBytes, autoActivateGraceMs } = getMcpServerConfig();
|
|
177
669
|
var extensions = [];
|
|
178
670
|
var consumerCount = 0;
|
|
179
671
|
var autoActivateTimer = null;
|
|
180
672
|
var selectedWsPort = 0;
|
|
181
673
|
var mcp = new McpServer({ name: "tempad-dev-mcp", version: "0.1.0" });
|
|
182
|
-
|
|
674
|
+
var assetStore = createAssetStore();
|
|
675
|
+
var assetHttpServer = createAssetHttpServer(assetStore);
|
|
676
|
+
await assetHttpServer.start();
|
|
677
|
+
registerAssetResources();
|
|
678
|
+
function registerAssetResources() {
|
|
679
|
+
const template = new ResourceTemplate(MCP_ASSET_URI_TEMPLATE, {
|
|
680
|
+
list: async () => ({
|
|
681
|
+
resources: assetStore.list().filter((record) => existsSync3(record.filePath)).map((record) => ({
|
|
682
|
+
uri: buildAssetResourceUri(record.hash),
|
|
683
|
+
name: formatAssetResourceName(record.hash),
|
|
684
|
+
description: `${record.mimeType} (${formatBytes(record.size)})`,
|
|
685
|
+
mimeType: record.mimeType
|
|
686
|
+
}))
|
|
687
|
+
})
|
|
688
|
+
});
|
|
689
|
+
mcp.registerResource(
|
|
690
|
+
MCP_ASSET_RESOURCE_NAME,
|
|
691
|
+
template,
|
|
692
|
+
{
|
|
693
|
+
description: "Binary assets captured by the TemPad Dev hub."
|
|
694
|
+
},
|
|
695
|
+
async (_uri, variables) => {
|
|
696
|
+
const hash = typeof variables.hash === "string" ? variables.hash : "";
|
|
697
|
+
return readAssetResource(hash);
|
|
698
|
+
}
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
async function readAssetResource(hash) {
|
|
702
|
+
if (!hash) {
|
|
703
|
+
throw new Error("Missing asset hash in resource URI.");
|
|
704
|
+
}
|
|
705
|
+
const record = assetStore.get(hash);
|
|
706
|
+
if (!record) {
|
|
707
|
+
throw new Error(`Asset ${hash} not found.`);
|
|
708
|
+
}
|
|
709
|
+
if (!existsSync3(record.filePath)) {
|
|
710
|
+
assetStore.remove(hash, { removeFile: false });
|
|
711
|
+
throw new Error(`Asset ${hash} file is missing.`);
|
|
712
|
+
}
|
|
713
|
+
const stat = statSync3(record.filePath);
|
|
714
|
+
const estimatedSize = Math.ceil(stat.size / 3) * 4;
|
|
715
|
+
if (estimatedSize > maxPayloadBytes) {
|
|
716
|
+
throw new Error(
|
|
717
|
+
`Asset ${hash} is too large (${formatBytes(stat.size)}, encoded: ${formatBytes(estimatedSize)}) to read via MCP protocol. Use HTTP download.`
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
assetStore.touch(hash);
|
|
721
|
+
const buffer = readFileSync2(record.filePath);
|
|
722
|
+
const resourceUri = buildAssetResourceUri(hash);
|
|
723
|
+
if (isTextualMime(record.mimeType)) {
|
|
724
|
+
return {
|
|
725
|
+
contents: [
|
|
726
|
+
{
|
|
727
|
+
uri: resourceUri,
|
|
728
|
+
mimeType: record.mimeType,
|
|
729
|
+
text: buffer.toString("utf8")
|
|
730
|
+
}
|
|
731
|
+
]
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
return {
|
|
735
|
+
contents: [
|
|
736
|
+
{
|
|
737
|
+
uri: resourceUri,
|
|
738
|
+
mimeType: record.mimeType,
|
|
739
|
+
blob: buffer.toString("base64")
|
|
740
|
+
}
|
|
741
|
+
]
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
function isTextualMime(mimeType) {
|
|
745
|
+
return mimeType === "image/svg+xml" || mimeType.startsWith("text/");
|
|
746
|
+
}
|
|
747
|
+
function buildAssetResourceUri(hash) {
|
|
748
|
+
return `${MCP_ASSET_URI_PREFIX}${hash}`;
|
|
749
|
+
}
|
|
750
|
+
function formatAssetResourceName(hash) {
|
|
751
|
+
return `asset:${hash.slice(0, 8)}`;
|
|
752
|
+
}
|
|
753
|
+
function buildAssetDescriptor(record) {
|
|
754
|
+
return {
|
|
755
|
+
hash: record.hash,
|
|
756
|
+
url: `${assetHttpServer.getBaseUrl()}/assets/${record.hash}`,
|
|
757
|
+
mimeType: record.mimeType,
|
|
758
|
+
size: record.size,
|
|
759
|
+
resourceUri: buildAssetResourceUri(record.hash),
|
|
760
|
+
width: record.metadata?.width,
|
|
761
|
+
height: record.metadata?.height
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
function createAssetResourceLinkBlock(asset) {
|
|
765
|
+
return {
|
|
766
|
+
type: "resource_link",
|
|
767
|
+
name: formatAssetResourceName(asset.hash),
|
|
768
|
+
uri: asset.resourceUri,
|
|
769
|
+
mimeType: asset.mimeType,
|
|
770
|
+
description: `${describeAsset(asset)} - Download: ${asset.url}`
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
function describeAsset(asset) {
|
|
774
|
+
return `${asset.mimeType} (${formatBytes(asset.size)})`;
|
|
775
|
+
}
|
|
776
|
+
function registerHubTools() {
|
|
777
|
+
for (const tool of TOOLS) {
|
|
778
|
+
registerExtensionTool(tool);
|
|
779
|
+
}
|
|
780
|
+
mcp.registerTool(
|
|
781
|
+
"get_assets",
|
|
782
|
+
{
|
|
783
|
+
description: "Resolve uploaded asset hashes to downloadable URLs and resource URIs for resources/read calls.",
|
|
784
|
+
inputSchema: GetAssetsParametersSchema,
|
|
785
|
+
outputSchema: GetAssetsResultSchema
|
|
786
|
+
},
|
|
787
|
+
async (args) => {
|
|
788
|
+
const { hashes } = GetAssetsParametersSchema.parse(args);
|
|
789
|
+
if (hashes.length > 100) {
|
|
790
|
+
throw new Error("Too many hashes requested. Limit is 100.");
|
|
791
|
+
}
|
|
792
|
+
const unique = Array.from(new Set(hashes));
|
|
793
|
+
const records = assetStore.getMany(unique).filter((record) => {
|
|
794
|
+
if (existsSync3(record.filePath)) return true;
|
|
795
|
+
assetStore.remove(record.hash, { removeFile: false });
|
|
796
|
+
return false;
|
|
797
|
+
});
|
|
798
|
+
const found = new Set(records.map((record) => record.hash));
|
|
799
|
+
const payload = GetAssetsResultSchema.parse({
|
|
800
|
+
assets: records.map((record) => buildAssetDescriptor(record)),
|
|
801
|
+
missing: unique.filter((hash) => !found.has(hash))
|
|
802
|
+
});
|
|
803
|
+
const summary = [];
|
|
804
|
+
summary.push(
|
|
805
|
+
payload.assets.length ? `Resolved ${payload.assets.length} asset${payload.assets.length === 1 ? "" : "s"}.` : "No assets were resolved for the requested hashes."
|
|
806
|
+
);
|
|
807
|
+
if (payload.missing.length) {
|
|
808
|
+
summary.push(`Missing: ${payload.missing.join(", ")}`);
|
|
809
|
+
}
|
|
810
|
+
summary.push(
|
|
811
|
+
"Use resources/read with each resourceUri or fetch the fallback URL to download bytes."
|
|
812
|
+
);
|
|
813
|
+
const content = [
|
|
814
|
+
{
|
|
815
|
+
type: "text",
|
|
816
|
+
text: summary.join("\n")
|
|
817
|
+
},
|
|
818
|
+
...payload.assets.map((asset) => createAssetResourceLinkBlock(asset))
|
|
819
|
+
];
|
|
820
|
+
return {
|
|
821
|
+
content,
|
|
822
|
+
structuredContent: payload
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
registerHubTools();
|
|
828
|
+
log.info({ tools: TOOLS.map((t) => t.name) }, "Registered tools.");
|
|
829
|
+
function registerExtensionTool(tool) {
|
|
183
830
|
const schema = tool.parameters;
|
|
184
831
|
mcp.registerTool(
|
|
185
832
|
tool.name,
|
|
@@ -191,7 +838,7 @@ for (const tool of TOOLS) {
|
|
|
191
838
|
const parsedArgs = schema.parse(args);
|
|
192
839
|
const activeExt = extensions.find((e) => e.active);
|
|
193
840
|
if (!activeExt) throw new Error("No active TemPad Dev extension available.");
|
|
194
|
-
const { promise, requestId } = register(activeExt.id,
|
|
841
|
+
const { promise, requestId } = register(activeExt.id, toolTimeoutMs);
|
|
195
842
|
const message = {
|
|
196
843
|
type: "toolCall",
|
|
197
844
|
id: requestId,
|
|
@@ -202,13 +849,103 @@ for (const tool of TOOLS) {
|
|
|
202
849
|
};
|
|
203
850
|
activeExt.ws.send(JSON.stringify(message));
|
|
204
851
|
log.info({ tool: tool.name, req: requestId, extId: activeExt.id }, "Forwarded tool call.");
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
return { content: [{ type: "text", text: textContent }] };
|
|
852
|
+
const payload = await promise;
|
|
853
|
+
return createToolResponse(tool.name, payload);
|
|
208
854
|
}
|
|
209
855
|
);
|
|
210
856
|
}
|
|
211
|
-
|
|
857
|
+
function createToolResponse(toolName, payload) {
|
|
858
|
+
if (toolName === "get_screenshot") {
|
|
859
|
+
try {
|
|
860
|
+
return createScreenshotToolResponse(payload);
|
|
861
|
+
} catch (error) {
|
|
862
|
+
log.warn({ error }, "Failed to format get_screenshot result; returning raw payload.");
|
|
863
|
+
return coercePayloadToToolResponse(payload);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
if (toolName === "get_code") {
|
|
867
|
+
try {
|
|
868
|
+
return createCodeToolResponse(payload);
|
|
869
|
+
} catch (error) {
|
|
870
|
+
log.warn({ error }, "Failed to format get_code result; returning raw payload.");
|
|
871
|
+
return coercePayloadToToolResponse(payload);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
return coercePayloadToToolResponse(payload);
|
|
875
|
+
}
|
|
876
|
+
function coercePayloadToToolResponse(payload) {
|
|
877
|
+
if (payload && typeof payload === "object" && Array.isArray(payload.content)) {
|
|
878
|
+
return payload;
|
|
879
|
+
}
|
|
880
|
+
return {
|
|
881
|
+
content: [
|
|
882
|
+
{
|
|
883
|
+
type: "text",
|
|
884
|
+
text: typeof payload === "string" ? payload : JSON.stringify(payload, null, 2)
|
|
885
|
+
}
|
|
886
|
+
]
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
function createCodeToolResponse(payload) {
|
|
890
|
+
if (!isCodeResult(payload)) {
|
|
891
|
+
throw new Error("Invalid get_code payload received from extension.");
|
|
892
|
+
}
|
|
893
|
+
const normalized = normalizeCodeResult(payload);
|
|
894
|
+
const summary = [];
|
|
895
|
+
const codeSize = Buffer.byteLength(normalized.code, "utf8");
|
|
896
|
+
summary.push(`Generated ${normalized.lang.toUpperCase()} snippet (${formatBytes(codeSize)}).`);
|
|
897
|
+
if (normalized.message) {
|
|
898
|
+
summary.push(normalized.message);
|
|
899
|
+
}
|
|
900
|
+
summary.push(
|
|
901
|
+
normalized.assets.length ? `Assets attached: ${normalized.assets.length}. Fetch bytes via resources/read using resourceUri or call get_assets.` : "No binary assets were attached to this response."
|
|
902
|
+
);
|
|
903
|
+
if (normalized.usedTokens?.length) {
|
|
904
|
+
summary.push(`Token references included: ${normalized.usedTokens.length}.`);
|
|
905
|
+
}
|
|
906
|
+
summary.push("Read structuredContent for the full code string and asset metadata.");
|
|
907
|
+
return {
|
|
908
|
+
content: [
|
|
909
|
+
{
|
|
910
|
+
type: "text",
|
|
911
|
+
text: summary.join("\n")
|
|
912
|
+
}
|
|
913
|
+
],
|
|
914
|
+
structuredContent: normalized
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
function isCodeResult(payload) {
|
|
918
|
+
if (typeof payload !== "object" || !payload) return false;
|
|
919
|
+
const candidate = payload;
|
|
920
|
+
return typeof candidate.code === "string" && typeof candidate.lang === "string" && Array.isArray(candidate.assets);
|
|
921
|
+
}
|
|
922
|
+
function normalizeCodeResult(result) {
|
|
923
|
+
const updatedAssets = result.assets.map((asset) => enrichAssetDescriptor(asset));
|
|
924
|
+
const rewrittenCode = rewriteCodeAssetUrls(result.code, updatedAssets);
|
|
925
|
+
return {
|
|
926
|
+
...result,
|
|
927
|
+
code: rewrittenCode,
|
|
928
|
+
assets: updatedAssets
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
function rewriteCodeAssetUrls(code, assets) {
|
|
932
|
+
let updatedCode = code;
|
|
933
|
+
for (const asset of assets) {
|
|
934
|
+
const uriPattern = new RegExp(escapeRegExp(asset.resourceUri), "g");
|
|
935
|
+
updatedCode = updatedCode.replace(uriPattern, asset.url);
|
|
936
|
+
}
|
|
937
|
+
return updatedCode;
|
|
938
|
+
}
|
|
939
|
+
function enrichAssetDescriptor(asset) {
|
|
940
|
+
const record = assetStore.get(asset.hash);
|
|
941
|
+
if (!record) {
|
|
942
|
+
return asset;
|
|
943
|
+
}
|
|
944
|
+
return buildAssetDescriptor(record);
|
|
945
|
+
}
|
|
946
|
+
function escapeRegExp(value) {
|
|
947
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
948
|
+
}
|
|
212
949
|
function getActiveId() {
|
|
213
950
|
return extensions.find((e) => e.active)?.id ?? null;
|
|
214
951
|
}
|
|
@@ -236,7 +973,15 @@ function scheduleAutoActivate() {
|
|
|
236
973
|
log.info({ id: target.id }, "Auto-activated sole extension after grace period.");
|
|
237
974
|
broadcastState();
|
|
238
975
|
}
|
|
239
|
-
},
|
|
976
|
+
}, autoActivateGraceMs);
|
|
977
|
+
}
|
|
978
|
+
function unrefTimer(timer) {
|
|
979
|
+
if (typeof timer === "object" && timer !== null) {
|
|
980
|
+
const handle = timer;
|
|
981
|
+
if (typeof handle.unref === "function") {
|
|
982
|
+
handle.unref();
|
|
983
|
+
}
|
|
984
|
+
}
|
|
240
985
|
}
|
|
241
986
|
function broadcastState() {
|
|
242
987
|
const activeId = getActiveId();
|
|
@@ -244,13 +989,64 @@ function broadcastState() {
|
|
|
244
989
|
type: "state",
|
|
245
990
|
activeId,
|
|
246
991
|
count: extensions.length,
|
|
247
|
-
port: selectedWsPort
|
|
992
|
+
port: selectedWsPort,
|
|
993
|
+
assetServerUrl: assetHttpServer.getBaseUrl()
|
|
248
994
|
};
|
|
249
995
|
extensions.forEach((ext) => ext.ws.send(JSON.stringify(message)));
|
|
250
996
|
log.debug({ activeId, count: extensions.length }, "Broadcasted state.");
|
|
251
997
|
}
|
|
998
|
+
function rawDataToBuffer(raw) {
|
|
999
|
+
if (typeof raw === "string") return Buffer.from(raw);
|
|
1000
|
+
if (Buffer.isBuffer(raw)) return raw;
|
|
1001
|
+
if (raw instanceof ArrayBuffer) return Buffer.from(raw);
|
|
1002
|
+
return Buffer.concat(raw);
|
|
1003
|
+
}
|
|
1004
|
+
function createScreenshotToolResponse(payload) {
|
|
1005
|
+
if (!isScreenshotResult(payload)) {
|
|
1006
|
+
throw new Error("Invalid get_screenshot payload received from extension.");
|
|
1007
|
+
}
|
|
1008
|
+
const descriptionBlock = {
|
|
1009
|
+
type: "text",
|
|
1010
|
+
text: describeScreenshot(payload)
|
|
1011
|
+
};
|
|
1012
|
+
return {
|
|
1013
|
+
content: [
|
|
1014
|
+
descriptionBlock,
|
|
1015
|
+
{
|
|
1016
|
+
type: "text",
|
|
1017
|
+
text: ``
|
|
1018
|
+
},
|
|
1019
|
+
createResourceLinkBlock(payload.asset, payload)
|
|
1020
|
+
],
|
|
1021
|
+
structuredContent: payload
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
function createResourceLinkBlock(asset, result) {
|
|
1025
|
+
return {
|
|
1026
|
+
type: "resource_link",
|
|
1027
|
+
name: "Screenshot",
|
|
1028
|
+
uri: asset.resourceUri,
|
|
1029
|
+
mimeType: asset.mimeType,
|
|
1030
|
+
description: `Screenshot ${result.width}x${result.height} @${result.scale}x - Download: ${asset.url}`
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
function describeScreenshot(result) {
|
|
1034
|
+
return `Screenshot ${result.width}x${result.height} @${result.scale}x (${formatBytes(result.bytes)})`;
|
|
1035
|
+
}
|
|
1036
|
+
function formatBytes(bytes) {
|
|
1037
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
1038
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
1039
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
1040
|
+
}
|
|
1041
|
+
function isScreenshotResult(payload) {
|
|
1042
|
+
if (typeof payload !== "object" || !payload) return false;
|
|
1043
|
+
const candidate = payload;
|
|
1044
|
+
return typeof candidate.asset === "object" && candidate.asset !== null && typeof candidate.width === "number" && typeof candidate.height === "number" && typeof candidate.scale === "number" && typeof candidate.bytes === "number" && typeof candidate.format === "string";
|
|
1045
|
+
}
|
|
252
1046
|
function shutdown() {
|
|
253
1047
|
log.info("Hub is shutting down...");
|
|
1048
|
+
assetStore.flush();
|
|
1049
|
+
assetHttpServer.stop();
|
|
254
1050
|
netServer.close(() => log.info("Net server closed."));
|
|
255
1051
|
wss?.close(() => log.info("WebSocket server closed."));
|
|
256
1052
|
cleanupAll();
|
|
@@ -258,19 +1054,19 @@ function shutdown() {
|
|
|
258
1054
|
log.warn("Shutdown timed out. Forcing exit.");
|
|
259
1055
|
process.exit(1);
|
|
260
1056
|
}, SHUTDOWN_TIMEOUT);
|
|
261
|
-
timer
|
|
1057
|
+
unrefTimer(timer);
|
|
262
1058
|
}
|
|
263
1059
|
try {
|
|
264
1060
|
ensureDir(RUNTIME_DIR);
|
|
265
|
-
if (process.platform !== "win32" &&
|
|
1061
|
+
if (process.platform !== "win32" && existsSync3(SOCK_PATH)) {
|
|
266
1062
|
log.warn({ sock: SOCK_PATH }, "Removing stale socket file.");
|
|
267
|
-
|
|
1063
|
+
rmSync2(SOCK_PATH);
|
|
268
1064
|
}
|
|
269
1065
|
} catch (error) {
|
|
270
1066
|
log.error({ err: error }, "Failed to initialize runtime environment.");
|
|
271
1067
|
process.exit(1);
|
|
272
1068
|
}
|
|
273
|
-
var netServer =
|
|
1069
|
+
var netServer = createServer2((sock) => {
|
|
274
1070
|
consumerCount++;
|
|
275
1071
|
log.info(`Consumer connected. Total: ${consumerCount}`);
|
|
276
1072
|
const transport = new StdioServerTransport(sock, sock);
|
|
@@ -307,11 +1103,11 @@ netServer.listen(SOCK_PATH, () => {
|
|
|
307
1103
|
log.info({ sock: SOCK_PATH }, "Hub socket ready.");
|
|
308
1104
|
});
|
|
309
1105
|
async function startWebSocketServer() {
|
|
310
|
-
for (const candidate of
|
|
1106
|
+
for (const candidate of wsPortCandidates) {
|
|
311
1107
|
const server = new WebSocketServer({
|
|
312
1108
|
host: "127.0.0.1",
|
|
313
1109
|
port: candidate,
|
|
314
|
-
maxPayload:
|
|
1110
|
+
maxPayload: maxPayloadBytes
|
|
315
1111
|
});
|
|
316
1112
|
try {
|
|
317
1113
|
await new Promise((resolve2, reject2) => {
|
|
@@ -339,7 +1135,7 @@ async function startWebSocketServer() {
|
|
|
339
1135
|
}
|
|
340
1136
|
}
|
|
341
1137
|
log.error(
|
|
342
|
-
{ candidates:
|
|
1138
|
+
{ candidates: wsPortCandidates },
|
|
343
1139
|
"Unable to start WebSocket server on any candidate port."
|
|
344
1140
|
);
|
|
345
1141
|
process.exit(1);
|
|
@@ -351,22 +1147,19 @@ wss.on("error", (err) => {
|
|
|
351
1147
|
process.exit(1);
|
|
352
1148
|
});
|
|
353
1149
|
wss.on("connection", (ws) => {
|
|
354
|
-
const ext = { id:
|
|
1150
|
+
const ext = { id: nanoid3(), ws, active: false };
|
|
355
1151
|
extensions.push(ext);
|
|
356
1152
|
log.info({ id: ext.id }, `Extension connected. Total: ${extensions.length}`);
|
|
357
1153
|
const message = { type: "registered", id: ext.id };
|
|
358
1154
|
ws.send(JSON.stringify(message));
|
|
359
1155
|
broadcastState();
|
|
360
1156
|
scheduleAutoActivate();
|
|
361
|
-
ws.on("message", (raw) => {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
} else if (raw instanceof ArrayBuffer) {
|
|
366
|
-
messageBuffer = Buffer.from(raw);
|
|
367
|
-
} else {
|
|
368
|
-
messageBuffer = Buffer.concat(raw);
|
|
1157
|
+
ws.on("message", (raw, isBinary) => {
|
|
1158
|
+
if (isBinary) {
|
|
1159
|
+
log.warn({ extId: ext.id }, "Unexpected binary message received.");
|
|
1160
|
+
return;
|
|
369
1161
|
}
|
|
1162
|
+
const messageBuffer = rawDataToBuffer(raw);
|
|
370
1163
|
let parsedJson;
|
|
371
1164
|
try {
|
|
372
1165
|
parsedJson = JSON.parse(messageBuffer.toString("utf-8"));
|