@topline-build/harness-mcp 0.5.2 → 0.6.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/index.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
3
  import { existsSync } from "node:fs";
4
- import { mkdir, readdir, readFile, realpath, lstat, writeFile } from "node:fs/promises";
4
+ import { cp, mkdir, mkdtemp, readdir, readFile, realpath, lstat, writeFile, rm, rename } from "node:fs/promises";
5
5
  import { createServer } from "node:http";
6
6
  import os from "node:os";
7
7
  import path from "node:path";
8
8
  import { gzipSync } from "node:zlib";
9
+ import { externalBuildHelperTools, EXTERNAL_BUILD_FORWARD_TOOLS } from "./build-session-tools.js";
10
+ import { boundedTransfer, extractArtifactSourceArchive, MAX_SOURCE_ARCHIVE_BYTES, safeSourcePath } from "./source-checkout.js";
9
11
  import { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js";
10
12
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
13
  import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
@@ -13,7 +15,7 @@ import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } fr
13
15
  export const DEFAULT_MCP_PROTOCOL_VERSION = "2025-11-25";
14
16
  const DEFAULT_TIMEOUT_MS = 120_000;
15
17
  export const BUILD_MCP_SERVER_NAME = "topline-build-stdio";
16
- export const HARNESS_MCP_VERSION = "0.5.2";
18
+ export const HARNESS_MCP_VERSION = "0.6.0";
17
19
  export const DEFAULT_OAUTH_SCOPES = Object.freeze(["harness:artifact:read", "harness:artifact:write", "harness:artifact:deploy", "harness:artifact:delete"]);
18
20
  export const BUILD_MCP_SETUP_OPTIONS = Object.freeze([
19
21
  { flag: "--url", env: "TOPLINE_API_URL", aliases: ["PLATFORM_API_URL", "OVERSITE_API_URL"], required: true, description: "Topline tenant base URL. HTTPS is required except for loopback development." },
@@ -146,6 +148,9 @@ export async function callHarnessTool(opts, name, args, hooks) {
146
148
  let result;
147
149
  switch (name) {
148
150
  case "platform_connection_check":
151
+ case "platform_get_design_system_state":
152
+ case "platform_edit_design_system":
153
+ case "platform_publish_design_system":
149
154
  case "platform_list_build_workspaces":
150
155
  case "platform_list_artifact_drafts":
151
156
  case "platform_deploy_operation":
@@ -155,6 +160,18 @@ export async function callHarnessTool(opts, name, args, hooks) {
155
160
  case "platform_delete_artifact_draft":
156
161
  result = toolResult(await callHostedBuildTool(opts, name, input));
157
162
  break;
163
+ case "platform_checkout_design_system":
164
+ result = toolResult(await checkoutDesignSystem(opts, input));
165
+ break;
166
+ case "platform_checkout_artifact_app":
167
+ result = toolResult(await checkoutArtifactApp(opts, input));
168
+ break;
169
+ case "platform_materialize_assets":
170
+ result = toolResult(await materializeBuildAssets(opts, input));
171
+ break;
172
+ case "platform_submit_design_system_draft":
173
+ result = toolResult(await submitDesignSystemDraft(opts, input));
174
+ break;
158
175
  case "platform_deploy_artifact_app":
159
176
  result = toolResult(await deployArtifactApp(opts, input, hooks?.onProgress));
160
177
  break;
@@ -180,6 +197,12 @@ export async function callHarnessTool(opts, name, args, hooks) {
180
197
  result = toolResult(await initArtifactApp(opts, input));
181
198
  break;
182
199
  default:
200
+ if (EXTERNAL_BUILD_FORWARD_TOOLS.has(name)) {
201
+ if (mcpSurface(opts) !== "build")
202
+ throw new Error("external_session_requires_build_surface");
203
+ result = toolResult(await callHostedBuildTool(opts, name, input));
204
+ break;
205
+ }
183
206
  throw new McpError(ErrorCode.InvalidParams, `Unknown tool: ${name}`);
184
207
  }
185
208
  const outputError = tool.outputSchema
@@ -194,7 +217,8 @@ export async function deployArtifactApp(opts, input, onProgress) {
194
217
  await assertAuthFileOutsideAppDir(opts.authFile, absoluteAppDir);
195
218
  const workspaceAppDir = workspaceRelativeAppDir(opts.workspaceRoot, absoluteAppDir);
196
219
  const manifest = await readManifest(absoluteAppDir);
197
- const platformAppDir = logicalArtifactAppDir(workspaceAppDir, manifest);
220
+ const suppliedPlatformAppDir = optionalString(input, "platformAppDir");
221
+ const platformAppDir = suppliedPlatformAppDir ? (suppliedPlatformAppDir === "." ? "." : validateArchivePrefix(suppliedPlatformAppDir)) : logicalArtifactAppDir(workspaceAppDir, manifest);
198
222
  const clientRequestId = optionalString(input, "requestId") ?? createHash("sha256")
199
223
  .update(JSON.stringify({ apiUrl: platformBaseUrl(opts), appDir: platformAppDir, manifestId: manifest.id ?? null }))
200
224
  .digest("hex");
@@ -204,18 +228,39 @@ export async function deployArtifactApp(opts, input, onProgress) {
204
228
  if (expectedBundleSha256 && bundle.sha256 !== expectedBundleSha256) {
205
229
  throw new Error("artifact_app_bundle_changed_after_inspection");
206
230
  }
231
+ const sessionId = optionalString(input, "sessionId");
232
+ const sessionVersion = optionalString(input, "artifactVersionId");
233
+ const idempotencyKey = optionalString(input, "idempotencyKey");
234
+ const inspectedSourceHash = optionalString(input, "inspectedSourceHash");
235
+ let sessionArtifactId;
236
+ if (sessionId) {
237
+ if (mcpSurface(opts) !== "build")
238
+ throw new Error("external_session_requires_build_surface");
239
+ if (!sessionVersion || !idempotencyKey || !inspectedSourceHash || !expectedBundleSha256)
240
+ throw new Error("build_session_deployment_arguments_required");
241
+ const session = asRecord(await callHostedBuildTool(opts, "platform_read_build_session", { sessionId }));
242
+ // The server resolves a repeated operation key before checking current
243
+ // version/source. Do not reject a lost-response retry using the newer read.
244
+ // Fresh mutations still require the caller's exact inspected revision.
245
+ sessionArtifactId = stringValue(session.artifactId, "artifactId");
246
+ if (input.artifactId !== undefined && input.artifactId !== sessionArtifactId)
247
+ throw new Error("build_session_artifact_mismatch");
248
+ }
249
+ else if (sessionVersion || idempotencyKey || inspectedSourceHash || suppliedPlatformAppDir)
250
+ throw new Error("build_session_id_required");
207
251
  await onProgress?.(0.4, "Creating private artifact draft");
208
252
  const created = await fetchJson(opts, artifactAppsPath(opts), {
209
253
  method: "POST",
210
254
  headers: await jsonHeaders(opts),
211
255
  body: JSON.stringify({
212
- artifactId: optionalString(input, "artifactId"),
256
+ artifactId: sessionArtifactId ?? optionalString(input, "artifactId"),
213
257
  title: optionalString(input, "title"),
214
258
  description: optionalString(input, "description"),
215
259
  projectId: optionalString(input, "projectId"),
216
260
  clientRequestId,
217
261
  bundleSha256: bundle.sha256,
218
- manifest
262
+ manifest,
263
+ ...(sessionId ? { sessionId, artifactVersionId: sessionVersion, idempotencyKey, inspectedSourceHash } : {})
219
264
  })
220
265
  });
221
266
  const createdRecord = asRecord(created);
@@ -232,19 +277,22 @@ export async function deployArtifactApp(opts, input, onProgress) {
232
277
  "content-type": "application/gzip",
233
278
  "MCP-Protocol-Version": opts.protocolVersion,
234
279
  "x-platform-artifact-version-id": artifactVersionId,
235
- "x-platform-app-dir": platformAppDir
280
+ "x-platform-app-dir": platformAppDir,
281
+ ...(sessionId ? { "x-platform-build-session-id": sessionId, "x-platform-idempotency-key": idempotencyKey, "x-platform-inspected-source-sha256": inspectedSourceHash, "x-platform-expected-bundle-sha256": bundle.sha256 } : {})
236
282
  },
237
283
  body: bundle.body
238
284
  });
239
285
  await onProgress?.(1, "Completed trusted draft deployment");
240
286
  const deployedRecord = asRecord(deployed);
241
- const operationId = typeof deployedRecord.taskId === "string" ? deployedRecord.taskId : null;
287
+ const operationId = typeof deployedRecord.taskId === "string" ? deployedRecord.taskId
288
+ : typeof deployedRecord.operationId === "string" ? deployedRecord.operationId : null;
242
289
  return {
243
290
  ok: true,
244
291
  artifactId,
245
292
  artifactVersionId,
246
293
  manifestId: typeof manifest.id === "string" ? manifest.id : null,
247
294
  clientRequestId,
295
+ ...(sessionId ? { sessionId } : {}),
248
296
  operationId,
249
297
  bundle: {
250
298
  fileCount: bundle.fileCount,
@@ -256,13 +304,135 @@ export async function deployArtifactApp(opts, input, onProgress) {
256
304
  deploy: deployed
257
305
  };
258
306
  }
307
+ async function fetchBuildTransfer(opts, advertisedPath, expectedPath, sha256, maxBytes, expectedSize, revision) {
308
+ if (advertisedPath !== expectedPath || typeof sha256 !== "string" || !/^[a-f0-9]{64}$/i.test(sha256))
309
+ throw new Error("build_transfer_identity_invalid");
310
+ const base = new URL(platformBaseUrl(opts));
311
+ const url = new URL(expectedPath, base);
312
+ if (url.origin !== base.origin || url.pathname !== expectedPath || url.search || url.hash)
313
+ throw new Error("build_transfer_identity_invalid");
314
+ const controller = new AbortController();
315
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
316
+ try {
317
+ const response = await (opts.fetchImpl ?? fetch)(url.href, { method: "GET", headers: await jsonHeaders(opts), redirect: "error", signal: controller.signal });
318
+ if (!response.ok || (response.url && new URL(response.url).origin !== base.origin))
319
+ throw new Error("build_transfer_unavailable");
320
+ if (response.headers.get("x-topline-content-sha256")?.toLowerCase() !== sha256.toLowerCase())
321
+ throw new Error("build_transfer_hash_mismatch");
322
+ if (revision && (response.headers.get("x-topline-artifact-version") !== revision.artifactVersionId || response.headers.get("x-topline-source-commit") !== revision.commitSha))
323
+ throw new Error("build_transfer_revision_mismatch");
324
+ const body = await boundedTransfer(response, maxBytes, expectedSize);
325
+ if (createHash("sha256").update(body).digest("hex") !== sha256.toLowerCase())
326
+ throw new Error("build_transfer_hash_mismatch");
327
+ return body;
328
+ }
329
+ catch (error) {
330
+ if (controller.signal.aborted)
331
+ throw new Error("platform_harness_timeout");
332
+ throw error;
333
+ }
334
+ finally {
335
+ clearTimeout(timer);
336
+ }
337
+ }
338
+ async function newBuildInputTarget(opts, input) {
339
+ if (mcpSurface(opts) !== "build")
340
+ throw new Error("external_session_requires_build_surface");
341
+ const target = await resolveWorkspacePathSafely(opts.workspaceRoot, stringArg(input, "targetDir"));
342
+ if (existsSync(target))
343
+ throw new Error("build_input_target_exists");
344
+ await assertAuthFileOutsideAppDir(opts.authFile, target);
345
+ await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
346
+ const checked = await resolveWorkspacePathSafely(opts.workspaceRoot, stringArg(input, "targetDir"));
347
+ if (checked !== target)
348
+ throw new Error("build_input_target_changed");
349
+ return target;
350
+ }
351
+ async function installBuildInputs(opts, target, stage) {
352
+ // Reserve the new target exclusively; never rename over a directory another
353
+ // process created. cp refuses existing files rather than overwriting them.
354
+ await resolveWorkspacePathSafely(opts.workspaceRoot, path.relative(opts.workspaceRoot, target));
355
+ await mkdir(target, { mode: 0o700 });
356
+ try {
357
+ for (const entry of await readdir(stage))
358
+ await cp(path.join(stage, entry), path.join(target, entry), { recursive: true, force: false, errorOnExist: true });
359
+ }
360
+ catch (error) {
361
+ await rm(target, { recursive: true, force: true });
362
+ throw error;
363
+ }
364
+ }
365
+ export async function checkoutArtifactApp(opts, input) {
366
+ const target = await newBuildInputTarget(opts, input);
367
+ const sessionId = stringArg(input, "sessionId");
368
+ const metadata = asRecord(await callHostedBuildTool(opts, "platform_checkout_artifact_app", { sessionId }));
369
+ const artifactVersionId = stringValue(metadata.artifactVersionId, "artifactVersionId");
370
+ const commitSha = stringValue(metadata.commitSha, "commitSha");
371
+ if (!/^[a-f0-9]{40}$/.test(commitSha) || !/^[0-9a-f-]{36}$/i.test(artifactVersionId))
372
+ throw new Error("build_transfer_revision_invalid");
373
+ const body = await fetchBuildTransfer(opts, metadata.downloadPath, `/api/harness/build-sessions/${sessionId}/source`, metadata.sha256, MAX_SOURCE_ARCHIVE_BYTES, undefined, { artifactVersionId, commitSha });
374
+ const staging = await mkdtemp(path.join(path.dirname(target), ".topline-app-checkout-"));
375
+ try {
376
+ const source = path.join(staging, "source");
377
+ await mkdir(source, { mode: 0o700 });
378
+ const extracted = await extractArtifactSourceArchive(body, source);
379
+ if (extracted.fileCount !== metadata.fileCount || extracted.byteCount !== metadata.byteCount)
380
+ throw new Error("artifact_source_shape_mismatch");
381
+ await readManifest(source);
382
+ await installBuildInputs(opts, target, source);
383
+ return { ok: true, sessionId, artifactId: metadata.artifactId, artifactVersionId, commitSha, sourceHash: extracted.sha256,
384
+ appDir: path.relative(opts.workspaceRoot, target).replaceAll("\\", "/"), platformAppDir: metadata.appDir,
385
+ fileCount: extracted.fileCount, byteCount: extracted.byteCount };
386
+ }
387
+ finally {
388
+ await rm(staging, { recursive: true, force: true });
389
+ }
390
+ }
391
+ export async function materializeBuildAssets(opts, input) {
392
+ const target = await newBuildInputTarget(opts, input);
393
+ const sessionId = stringArg(input, "sessionId");
394
+ const assetIds = input.assetIds;
395
+ if (!Array.isArray(assetIds) || !assetIds.length || assetIds.length > 50 || assetIds.some(id => typeof id !== "string" || !/^[0-9a-f-]{36}$/i.test(id)))
396
+ throw new Error("asset_selection_required");
397
+ const selected = new Set(assetIds);
398
+ const metadata = asRecord(await callHostedBuildTool(opts, "platform_materialize_assets", { sessionId, assetIds }));
399
+ if (!Array.isArray(metadata.assets) || metadata.assets.length !== selected.size)
400
+ throw new Error("asset_transfer_identity_invalid");
401
+ const assets = metadata.assets.map(asRecord);
402
+ if (assets.some(asset => typeof asset.id !== "string" || !selected.delete(asset.id)) || selected.size)
403
+ throw new Error("asset_transfer_identity_invalid");
404
+ const staging = await mkdtemp(path.join(path.dirname(target), ".topline-assets-"));
405
+ let total = 0;
406
+ try {
407
+ const files = [];
408
+ for (const asset of assets) {
409
+ const size = asset.size;
410
+ if (typeof size !== "number" || !Number.isSafeInteger(size) || size < 0 || size > 16 * 1024 * 1024 || (total += size) > 64 * 1024 * 1024)
411
+ throw new Error("asset_transfer_too_large");
412
+ const name = stringValue(asset.name, "name");
413
+ if (Buffer.byteLength(name, "utf8") > 200 || name.includes("/") || safeSourcePath(name) !== name)
414
+ throw new Error("asset_transfer_name_invalid");
415
+ const id = stringValue(asset.id, "id");
416
+ const body = await fetchBuildTransfer(opts, asset.downloadPath, `/api/harness/build-sessions/${sessionId}/assets/${id}`, asset.sha256, 16 * 1024 * 1024, size);
417
+ const localName = `${id}-${name}`;
418
+ await writeFile(path.join(staging, localName), body, { mode: 0o600, flag: "wx" });
419
+ files.push({ id, file: localName, size, sha256: asset.sha256, mime: asset.mime });
420
+ }
421
+ await installBuildInputs(opts, target, staging);
422
+ return { ok: true, sessionId, directory: path.relative(opts.workspaceRoot, target).replaceAll("\\", "/"), byteCount: total, files };
423
+ }
424
+ finally {
425
+ await rm(staging, { recursive: true, force: true });
426
+ }
427
+ }
259
428
  export async function inspectArtifactApp(opts, input) {
260
429
  const appDir = stringArg(input, "appDir");
261
430
  const absoluteAppDir = await resolveWorkspacePathSafely(opts.workspaceRoot, appDir);
262
431
  await assertAuthFileOutsideAppDir(opts.authFile, absoluteAppDir);
263
432
  const workspaceAppDir = workspaceRelativeAppDir(opts.workspaceRoot, absoluteAppDir);
264
433
  const manifest = await readManifest(absoluteAppDir);
265
- const platformAppDir = logicalArtifactAppDir(workspaceAppDir, manifest);
434
+ const suppliedPlatformAppDir = optionalString(input, "platformAppDir");
435
+ const platformAppDir = suppliedPlatformAppDir ? (suppliedPlatformAppDir === "." ? "." : validateArchivePrefix(suppliedPlatformAppDir)) : logicalArtifactAppDir(workspaceAppDir, manifest);
266
436
  const bundle = await packArtifactAppDirectory(absoluteAppDir, platformAppDir === "." ? "" : platformAppDir);
267
437
  return {
268
438
  ok: true,
@@ -313,6 +483,132 @@ async function callHostedBuildTool(opts, name, input) {
313
483
  return result.structuredContent;
314
484
  throw new Error(`platform_mcp_tool_output_missing:${name}`);
315
485
  }
486
+ async function checkoutDesignSystem(opts, input) {
487
+ if (mcpSurface(opts) !== "build")
488
+ throw new Error("storyboard_requires_build_surface");
489
+ const targetDir = stringArg(input, "targetDir");
490
+ const target = await resolveWorkspacePathSafely(opts.workspaceRoot, targetDir);
491
+ if (existsSync(target))
492
+ throw new Error("storyboard_checkout_target_exists");
493
+ await assertAuthFileOutsideAppDir(opts.authFile, target);
494
+ const controller = new AbortController();
495
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
496
+ let bundle;
497
+ let commitSha;
498
+ try {
499
+ const response = await (opts.fetchImpl ?? fetch)(`${platformBaseUrl(opts)}/api/harness/storyboard/source-bundle`, {
500
+ headers: await jsonHeaders(opts), signal: controller.signal
501
+ });
502
+ if (!response.ok)
503
+ throw new Error(`platform_harness_http_error:${response.status}:storyboard_source_unavailable`);
504
+ commitSha = response.headers.get("x-topline-storyboard-commit") ?? "";
505
+ if (!/^[a-f0-9]{40}$/.test(commitSha))
506
+ throw new Error("storyboard_source_commit_invalid");
507
+ bundle = await readBoundedStoryboardBundle(response);
508
+ }
509
+ catch (error) {
510
+ if (controller.signal.aborted)
511
+ throw new Error("platform_harness_timeout");
512
+ throw error;
513
+ }
514
+ finally {
515
+ clearTimeout(timer);
516
+ }
517
+ const temp = await mkdtemp(path.join(os.tmpdir(), "topline-storyboard-checkout-"));
518
+ try {
519
+ const bundlePath = path.join(temp, "source.bundle");
520
+ await writeFile(bundlePath, bundle);
521
+ await mkdir(path.dirname(target), { recursive: true });
522
+ const staging = await mkdtemp(path.join(path.dirname(target), ".topline-storyboard-checkout-"));
523
+ try {
524
+ const candidate = path.join(staging, "source");
525
+ await runLocalGit(["clone", "--quiet", bundlePath, candidate]);
526
+ await runLocalGit(["-C", candidate, "checkout", "--quiet", "--detach", commitSha]);
527
+ await runLocalGit(["-C", candidate, "remote", "remove", "origin"]);
528
+ const actual = (await runLocalGit(["-C", candidate, "rev-parse", "HEAD"])).trim();
529
+ if (actual !== commitSha)
530
+ throw new Error("storyboard_source_commit_mismatch");
531
+ if (existsSync(target))
532
+ throw new Error("storyboard_checkout_target_exists");
533
+ await rename(candidate, target);
534
+ return { ok: true, directory: path.relative(opts.workspaceRoot, target).replaceAll("\\", "/"), currentCommit: commitSha, localCommands: ["npm ci", "npm run storybook"] };
535
+ }
536
+ finally {
537
+ await rm(staging, { recursive: true, force: true });
538
+ }
539
+ }
540
+ finally {
541
+ await rm(temp, { recursive: true, force: true });
542
+ }
543
+ }
544
+ async function readBoundedStoryboardBundle(response) {
545
+ const declared = Number(response.headers.get("content-length") ?? 0);
546
+ if (!Number.isSafeInteger(declared) || declared < 0 || declared > MAX_BUNDLE_BYTES) {
547
+ throw new Error("storyboard_source_bundle_size_invalid");
548
+ }
549
+ const reader = response.body?.getReader();
550
+ if (!reader)
551
+ throw new Error("storyboard_source_bundle_size_invalid");
552
+ const chunks = [];
553
+ let bytes = 0;
554
+ for (;;) {
555
+ const { done, value } = await reader.read();
556
+ if (done)
557
+ break;
558
+ bytes += value.byteLength;
559
+ if (bytes > MAX_BUNDLE_BYTES) {
560
+ await reader.cancel().catch(() => undefined);
561
+ throw new Error("storyboard_source_bundle_size_invalid");
562
+ }
563
+ chunks.push(Buffer.from(value));
564
+ }
565
+ if (bytes === 0 || (declared > 0 && declared !== bytes))
566
+ throw new Error("storyboard_source_bundle_size_invalid");
567
+ return Buffer.concat(chunks, bytes);
568
+ }
569
+ async function submitDesignSystemDraft(opts, input) {
570
+ if (mcpSurface(opts) !== "build")
571
+ throw new Error("storyboard_requires_build_surface");
572
+ const localDir = await resolveWorkspacePathSafely(opts.workspaceRoot, stringArg(input, "localDir"));
573
+ await assertAuthFileOutsideAppDir(opts.authFile, localDir);
574
+ const expectedCurrentCommit = stringArg(input, "expectedCurrentCommit");
575
+ const expectedDraftRunId = input.expectedDraftRunId === null ? null : stringArg(input, "expectedDraftRunId");
576
+ const bundle = await packStoryboardSourceDirectory(localDir);
577
+ const digest = createHash("sha256").update(`${platformBaseUrl(opts)}:${expectedCurrentCommit}:${expectedDraftRunId ?? "none"}:${bundle.sha256}`).digest();
578
+ digest[6] = (digest[6] & 0x0f) | 0x40;
579
+ digest[8] = (digest[8] & 0x3f) | 0x80;
580
+ const hex = digest.subarray(0, 16).toString("hex");
581
+ const requestId = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
582
+ const payload = await fetchJson(opts, `${platformBaseUrl(opts)}/api/harness/storyboard/draft-bundle`, {
583
+ method: "POST",
584
+ headers: {
585
+ ...await jsonHeaders(opts), "content-type": "application/gzip",
586
+ "x-topline-request-id": requestId,
587
+ "x-topline-expected-current-commit": expectedCurrentCommit,
588
+ "x-topline-expected-draft-run-id": expectedDraftRunId ?? "none"
589
+ },
590
+ body: new Uint8Array(bundle.body)
591
+ }, Math.max(opts.timeoutMs, 120_000));
592
+ return { ...asRecord(payload), requestId, bundleSha256: bundle.sha256 };
593
+ }
594
+ async function runLocalGit(args) {
595
+ return new Promise((resolve, reject) => {
596
+ const child = spawn("git", ["-c", "core.fsmonitor=false", ...args], { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
597
+ let output = "";
598
+ const collect = (chunk) => { output = (output + chunk.toString()).slice(-2000); };
599
+ child.stdout.on("data", collect);
600
+ child.stderr.on("data", collect);
601
+ const timer = setTimeout(() => child.kill(), 60_000);
602
+ child.on("error", reject);
603
+ child.on("close", (code) => {
604
+ clearTimeout(timer);
605
+ if (code === 0)
606
+ resolve(output);
607
+ else
608
+ reject(new Error(`storyboard_git_command_failed:${code ?? "unknown"}`));
609
+ });
610
+ });
611
+ }
316
612
  export async function waitForArtifactDeploy(opts, input, onProgress) {
317
613
  const artifactId = stringArg(input, "artifactId");
318
614
  const operationId = stringArg(input, "operationId");
@@ -469,13 +765,19 @@ export function storybookAccess(opts) {
469
765
  };
470
766
  }
471
767
  export async function packArtifactAppDirectory(appDir, archivePrefix = "") {
768
+ return packDirectory(appDir, archivePrefix, shouldExclude);
769
+ }
770
+ export async function packStoryboardSourceDirectory(appDir) {
771
+ return packDirectory(appDir, "", shouldExcludeStoryboard);
772
+ }
773
+ async function packDirectory(appDir, archivePrefix, exclude) {
472
774
  const root = path.resolve(appDir);
473
775
  if ((await lstat(root)).isSymbolicLink())
474
776
  throw new Error("artifact_app_bundle_symlink_forbidden:.");
475
777
  const prefix = archivePrefix ? validateArchivePrefix(archivePrefix) : "";
476
778
  const files = [];
477
779
  const excluded = [];
478
- await walk(root, root, files, excluded);
780
+ await walk(root, root, files, excluded, exclude);
479
781
  if (files.length === 0)
480
782
  throw new Error("artifact_app_bundle_empty");
481
783
  let byteCount = 0;
@@ -506,21 +808,21 @@ export async function packArtifactAppDirectory(appDir, archivePrefix = "") {
506
808
  function compareUtf8Paths(left, right) {
507
809
  return Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8"));
508
810
  }
509
- async function walk(root, current, files, excluded) {
811
+ async function walk(root, current, files, excluded, exclude) {
510
812
  const entries = await readdir(current, { withFileTypes: true });
511
813
  for (const entry of entries) {
512
814
  const absolute = path.join(current, entry.name);
513
815
  const relative = toPosix(path.relative(root, absolute));
514
816
  validateRelativePath(relative);
515
- const stat = await lstat(absolute);
516
- if (stat.isSymbolicLink())
517
- throw new Error(`artifact_app_bundle_symlink_forbidden:${relative}`);
518
- if (shouldExclude(relative, entry.isDirectory())) {
817
+ if (exclude(relative, entry.isDirectory())) {
519
818
  excluded.push(relative);
520
819
  continue;
521
820
  }
821
+ const stat = await lstat(absolute);
822
+ if (stat.isSymbolicLink())
823
+ throw new Error(`artifact_app_bundle_symlink_forbidden:${relative}`);
522
824
  if (entry.isDirectory()) {
523
- await walk(root, absolute, files, excluded);
825
+ await walk(root, absolute, files, excluded, exclude);
524
826
  }
525
827
  else if (entry.isFile()) {
526
828
  if (stat.nlink > 1)
@@ -646,10 +948,19 @@ function shouldExclude(relative, isDirectory) {
646
948
  return true;
647
949
  if (parts.some((part) => part === ".git" || part === "node_modules"))
648
950
  return true;
951
+ if (parts.some(part => /(?:\.(?:pem|key|p12|pfx)|^\.?(?:credentials|netrc|npmrc|aws|ssh)$)/i.test(part)))
952
+ return true;
649
953
  if (isDirectory && FORBIDDEN_DIRS.has(name))
650
954
  return true;
651
955
  return false;
652
956
  }
957
+ function shouldExcludeStoryboard(relative, isDirectory) {
958
+ const parts = relative.split("/").filter(Boolean);
959
+ const name = parts.at(-1) ?? "";
960
+ return name.startsWith(".env") || parts.some((part) => part === ".git" || part === "node_modules"
961
+ || part === "storybook-static" || part === ".topline" || part === ".cursor")
962
+ || (isDirectory && (name === ".vite" || name === ".cache"));
963
+ }
653
964
  function tarHeader(name, size) {
654
965
  const header = Buffer.alloc(BLOCK_SIZE);
655
966
  const { fileName, prefix } = splitTarName(name);
@@ -698,9 +1009,9 @@ function padSize(size) {
698
1009
  const remainder = size % BLOCK_SIZE;
699
1010
  return remainder === 0 ? 0 : BLOCK_SIZE - remainder;
700
1011
  }
701
- async function fetchJson(opts, url, init) {
1012
+ async function fetchJson(opts, url, init, timeoutMs = opts.timeoutMs) {
702
1013
  const controller = new AbortController();
703
- const timeout = setTimeout(() => controller.abort(), opts.timeoutMs);
1014
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
704
1015
  try {
705
1016
  const response = await (opts.fetchImpl ?? fetch)(url, { ...init, signal: controller.signal });
706
1017
  const text = await response.text();
@@ -779,7 +1090,12 @@ const BUILD_MCP_TOOLS = [
779
1090
  description: { type: "string" },
780
1091
  projectId: { type: "string", format: "uuid" },
781
1092
  expectedBundleSha256: { type: "string", pattern: "^[0-9a-fA-F]{64}$", description: "Optional SHA-256 from platform_inspect_artifact_app; deployment fails locally if source changed." },
782
- requestId: { type: "string", minLength: 1, maxLength: 128, description: "Optional stable retry key; generated deterministically when omitted." }
1093
+ requestId: { type: "string", minLength: 1, maxLength: 128, description: "Optional stable retry key; generated deterministically when omitted." },
1094
+ sessionId: { type: "string", format: "uuid" },
1095
+ artifactVersionId: { type: "string", format: "uuid", description: "Exact current version from the session or checkout; required with sessionId." },
1096
+ idempotencyKey: { type: "string", minLength: 1, maxLength: 128, pattern: "^[A-Za-z0-9._:-]+$" },
1097
+ inspectedSourceHash: { type: "string", pattern: "^[0-9a-fA-F]{64}$", description: "Echo the session/source checkout hash; required with sessionId. expectedBundleSha256 separately pins the newly inspected local upload." },
1098
+ platformAppDir: { type: "string", minLength: 1, description: "Saved remote app root from an existing-app checkout. Session mode only; preserve this when the local checkout directory has a different name." }
783
1099
  },
784
1100
  required: ["appDir"],
785
1101
  additionalProperties: false
@@ -805,7 +1121,7 @@ const BUILD_MCP_TOOLS = [
805
1121
  annotations: mcpToolAnnotations("platform_inspect_artifact_app", { readOnly: true }),
806
1122
  inputSchema: {
807
1123
  type: "object",
808
- properties: { appDir: { type: "string", minLength: 1 } },
1124
+ properties: { appDir: { type: "string", minLength: 1 }, platformAppDir: { type: "string", minLength: 1, description: "Preserve the saved remote source root returned by checkout when inspecting an existing app." } },
809
1125
  required: ["appDir"],
810
1126
  additionalProperties: false
811
1127
  },
@@ -940,6 +1256,68 @@ const BUILD_MCP_TOOLS = [
940
1256
  required: ["ok", "storybookUrl", "mcpUrl", "mcpServers"]
941
1257
  }
942
1258
  },
1259
+ {
1260
+ name: "platform_get_design_system_state",
1261
+ description: "Get the tenant Storyboard's published and draft commits, run status, and preview URL before editing or publishing. May reconcile a completed managed draft.",
1262
+ annotations: mcpToolAnnotations("platform_get_design_system_state", { readOnly: false }),
1263
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
1264
+ outputSchema: { type: "object" }
1265
+ },
1266
+ {
1267
+ name: "platform_checkout_design_system",
1268
+ description: "Download the exact published tenant Storyboard source into a new local directory for Codex or Claude Code to edit and run with npm ci and npm run storybook. Never overwrites an existing directory.",
1269
+ annotations: mcpToolAnnotations("platform_checkout_design_system", { readOnly: false, openWorld: true }),
1270
+ inputSchema: { type: "object", properties: { targetDir: { type: "string", minLength: 1 } }, required: ["targetDir"], additionalProperties: false },
1271
+ outputSchema: { type: "object", properties: { ok: { type: "boolean" }, directory: { type: "string" }, currentCommit: { type: "string" }, localCommands: { type: "array", items: { type: "string" } } }, required: ["ok", "directory", "currentCommit", "localCommands"] }
1272
+ },
1273
+ {
1274
+ name: "platform_submit_design_system_draft",
1275
+ description: "Package a locally edited Storyboard source directory and submit it as a private draft against exact published and draft revisions. The server validates and builds it before it can be published.",
1276
+ annotations: mcpToolAnnotations("platform_submit_design_system_draft", { readOnly: false, openWorld: true }),
1277
+ inputSchema: {
1278
+ type: "object",
1279
+ properties: {
1280
+ localDir: { type: "string", minLength: 1 },
1281
+ expectedCurrentCommit: { type: "string", pattern: "^[a-f0-9]{40}$" },
1282
+ expectedDraftRunId: { anyOf: [{ type: "string", format: "uuid" }, { type: "null" }] }
1283
+ },
1284
+ required: ["localDir", "expectedCurrentCommit", "expectedDraftRunId"], additionalProperties: false
1285
+ },
1286
+ outputSchema: { type: "object", properties: { ok: { type: "boolean" }, runId: { type: "string" }, draftCommit: { type: "string" }, draftState: { type: "string" }, previewUrl: { type: "string" }, requestId: { type: "string" }, bundleSha256: { type: "string" } }, required: ["ok", "runId", "draftCommit", "draftState", "previewUrl", "requestId", "bundleSha256"] }
1287
+ },
1288
+ {
1289
+ name: "platform_edit_design_system",
1290
+ description: "Start an asynchronous Storyboard draft edit in an existing writable Topline Storyboard conversation. Pass expectedDraftRunId from the last state read (null for no draft); stale or concurrent retries are rejected.",
1291
+ annotations: mcpToolAnnotations("platform_edit_design_system", { readOnly: false, openWorld: true }),
1292
+ inputSchema: {
1293
+ type: "object",
1294
+ properties: {
1295
+ conversationId: { type: "string", format: "uuid" },
1296
+ instruction: { type: "string", minLength: 1, maxLength: 10000 },
1297
+ expectedDraftRunId: { anyOf: [{ type: "string", format: "uuid" }, { type: "null" }] }
1298
+ },
1299
+ required: ["conversationId", "instruction", "expectedDraftRunId"],
1300
+ additionalProperties: false
1301
+ },
1302
+ outputSchema: { type: "object", properties: { runId: { type: "string" }, previewUrl: { type: "string" } }, required: ["runId", "previewUrl"] }
1303
+ },
1304
+ {
1305
+ name: "platform_publish_design_system",
1306
+ description: "Publish the exact reviewed Storyboard draft after its build passes. Managed edits require their writable Storyboard conversation; local submissions require the submitting actor. Always pass state-bound run, draft, and current commits.",
1307
+ annotations: mcpToolAnnotations("platform_publish_design_system", { readOnly: false, destructive: true, openWorld: true }),
1308
+ inputSchema: {
1309
+ type: "object",
1310
+ properties: {
1311
+ conversationId: { type: "string", format: "uuid" },
1312
+ expectedDraftRunId: { type: "string", format: "uuid" },
1313
+ expectedDraftCommit: { type: "string", pattern: "^[a-f0-9]{40}$" },
1314
+ expectedCurrentCommit: { anyOf: [{ type: "string", pattern: "^[a-f0-9]{40}$" }, { type: "null" }] }
1315
+ },
1316
+ required: ["expectedDraftRunId", "expectedDraftCommit", "expectedCurrentCommit"],
1317
+ additionalProperties: false
1318
+ },
1319
+ outputSchema: { type: "object", properties: { publishedCommit: { type: "string" } }, required: ["publishedCommit"] }
1320
+ },
943
1321
  {
944
1322
  name: "platform_validate_artifact",
945
1323
  description: "Read trusted smoke and health evidence for a private artifact draft without requiring a browser session.",
@@ -997,7 +1375,8 @@ const BUILD_MCP_TOOLS = [
997
1375
  properties: { ok: { type: "boolean" }, deleted: { type: "boolean" }, artifactId: { type: "string" } },
998
1376
  required: ["ok", "deleted", "artifactId"]
999
1377
  }
1000
- }
1378
+ },
1379
+ ...externalBuildHelperTools(mcpToolAnnotations)
1001
1380
  ].map(withRecoveryOutputSchema);
1002
1381
  export function listTools() {
1003
1382
  // Preserve the original tools/list isolation boundary: callers must not be
@@ -0,0 +1,10 @@
1
+ export declare const MAX_SOURCE_ARCHIVE_BYTES: number;
2
+ /** All path rules apply on every host, including Windows aliases and devices. */
3
+ export declare function safeSourcePath(raw: string): string;
4
+ /** Extract only regular files into a task-created empty staging directory. */
5
+ export declare function extractArtifactSourceArchive(body: Buffer, stagingRoot: string): Promise<{
6
+ fileCount: number;
7
+ byteCount: number;
8
+ sha256: string;
9
+ }>;
10
+ export declare function boundedTransfer(response: Response, maxBytes: number, expectedSize?: number): Promise<Buffer>;