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