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