@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/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 nanoid2 } from "nanoid";
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
- // src/request.ts
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 = nanoid();
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
- clearTimeout(call.timer);
79
- call.resolve(payload);
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
- clearTimeout(call.timer);
89
- call.reject(error);
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
- if (call.extensionId === extensionId) {
98
- clearTimeout(call.timer);
99
- call.reject(new Error("Extension disconnected before providing a result."));
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
- clearTimeout(call.timer);
108
- call.reject(new Error("Hub is shutting down."));
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 RegisteredMessageSchema = z2.object({
130
- type: z2.literal("registered"),
131
- id: z2.string()
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 StateMessageSchema = z2.object({
134
- type: z2.literal("state"),
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 ToolCallPayloadSchema = z2.object({
140
- name: z2.string(),
141
- args: z2.unknown()
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 ToolCallMessageSchema = z2.object({
144
- type: z2.literal("toolCall"),
145
- id: z2.string(),
146
- payload: ToolCallPayloadSchema
613
+ var GetScreenshotParametersSchema = z2.object({
614
+ nodeId: z2.string().optional()
147
615
  });
148
- var MessageToExtensionSchema = z2.discriminatedUnion("type", [
149
- RegisteredMessageSchema,
150
- StateMessageSchema,
151
- ToolCallMessageSchema
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 ToolResultMessageSchema = z2.object({
157
- type: z2.literal("toolResult"),
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 MessageFromExtensionSchema = z2.discriminatedUnion("type", [
163
- ActivateMessageSchema,
164
- ToolResultMessageSchema
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 AUTO_ACTIVATE_GRACE_MS = parsePositiveInt(process.env.TEMPAD_MCP_AUTO_ACTIVATE_GRACE, 1500);
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
- for (const tool of TOOLS) {
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, TOOL_CALL_TIMEOUT);
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 unknownPayload = await promise;
206
- const textContent = typeof unknownPayload === "string" ? unknownPayload : JSON.stringify(unknownPayload, null, 2);
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
- log.info({ tools: TOOLS.map((t) => t.name) }, "Registered tools.");
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
- }, AUTO_ACTIVATE_GRACE_MS);
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: `![Screenshot](${payload.asset.url})`
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.unref();
1043
+ unrefTimer(timer);
262
1044
  }
263
1045
  try {
264
1046
  ensureDir(RUNTIME_DIR);
265
- if (process.platform !== "win32" && existsSync(SOCK_PATH)) {
1047
+ if (process.platform !== "win32" && existsSync3(SOCK_PATH)) {
266
1048
  log.warn({ sock: SOCK_PATH }, "Removing stale socket file.");
267
- rmSync(SOCK_PATH);
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 = createServer((sock) => {
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 WS_PORT_CANDIDATES) {
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: MAX_PAYLOAD_SIZE
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: WS_PORT_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: nanoid2(), ws, active: false };
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
- let messageBuffer;
363
- if (Buffer.isBuffer(raw)) {
364
- messageBuffer = raw;
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"));