@topline-build/harness-mcp 0.5.2 → 0.5.3

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/CHANGELOG.md CHANGED
@@ -4,6 +4,11 @@ All notable customer-visible Build MCP changes are recorded here. A source chang
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.5.3 - pending publication
8
+
9
+ - Add tenant-scoped Storyboard state, guarded edit, and exact-draft publication tools to Build MCP.
10
+ - Check out the published Storyboard source for local Storybook iteration and submit source-only edits as a private, server-validated draft.
11
+
7
12
  ## 0.5.2 - 2026-09-26
8
13
 
9
14
  - Report rejected bearer credentials as `authorization_required`, with scoped cache-clearing and browser reconnect guidance instead of input-correction advice.
package/README.md CHANGED
@@ -24,6 +24,8 @@ Run `npx -y @topline-build/harness-mcp --help` for every supported flag and envi
24
24
 
25
25
  If a tool returns `authorization_required`, clear only that connection's OAuth cache and reconnect through browser sign-in. Replace an expired or revoked manual token if using the fallback. Inspect existing artifact/deployment progress before retrying a write; the helper does not silently replay the rejected request.
26
26
 
27
+ Storyboard authoring uses separate `storyboard:read` and `storyboard:write` OAuth scopes. Request them explicitly with `--scopes` (or `TOPLINE_MCP_SCOPES`) and reconnect so Topline can approve the expanded grant; they are not part of the default artifact scopes. `platform_get_design_system_state` shows the published commit and current draft. `platform_checkout_design_system` creates a local checkout of that published source; run `npm ci` and `npm run storybook` there to iterate, then call `platform_submit_design_system_draft` with the exact published commit and draft ID from the state read. Wait for a successful server build, inspect the private preview, and call `platform_publish_design_system` with the reviewed run and commit values. Alternatively, `platform_edit_design_system` asks Topline's guarded coding agent to edit a draft in an existing writable Storyboard conversation. A timed-out write should be recovered by reading state before another submission. The separate Storybook reference MCP endpoint does not accept source writes.
28
+
27
29
  ## Typical workflow
28
30
 
29
31
  1. Use `platform_init_artifact_app` to create a starter, or point at an existing artifact app.
package/dist/index.d.ts CHANGED
@@ -32,7 +32,7 @@ export interface PackedArtifactAppBundle {
32
32
  type ToolProgressReporter = (progress: number, message: string) => void | Promise<void>;
33
33
  export declare const DEFAULT_MCP_PROTOCOL_VERSION = "2025-11-25";
34
34
  export declare const BUILD_MCP_SERVER_NAME = "topline-build-stdio";
35
- export declare const HARNESS_MCP_VERSION = "0.5.2";
35
+ export declare const HARNESS_MCP_VERSION = "0.5.3";
36
36
  export declare const DEFAULT_OAUTH_SCOPES: readonly string[];
37
37
  export interface BuildMcpSetupOption {
38
38
  readonly flag: string;
@@ -161,6 +161,7 @@ export declare function storybookAccess(opts: HarnessMcpStdioOptions): {
161
161
  operatorAction: string;
162
162
  };
163
163
  export declare function packArtifactAppDirectory(appDir: string, archivePrefix?: string): Promise<PackedArtifactAppBundle>;
164
+ export declare function packStoryboardSourceDirectory(appDir: string): Promise<PackedArtifactAppBundle>;
164
165
  export declare function listTools(): ListToolsResult;
165
166
  export interface BuildMcpRecoveryGuidance {
166
167
  readonly retryable: boolean;
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
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 { 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";
@@ -13,7 +13,7 @@ import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } fr
13
13
  export const DEFAULT_MCP_PROTOCOL_VERSION = "2025-11-25";
14
14
  const DEFAULT_TIMEOUT_MS = 120_000;
15
15
  export const BUILD_MCP_SERVER_NAME = "topline-build-stdio";
16
- export const HARNESS_MCP_VERSION = "0.5.2";
16
+ export const HARNESS_MCP_VERSION = "0.5.3";
17
17
  export const DEFAULT_OAUTH_SCOPES = Object.freeze(["harness:artifact:read", "harness:artifact:write", "harness:artifact:deploy", "harness:artifact:delete"]);
18
18
  export const BUILD_MCP_SETUP_OPTIONS = Object.freeze([
19
19
  { 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 +146,9 @@ export async function callHarnessTool(opts, name, args, hooks) {
146
146
  let result;
147
147
  switch (name) {
148
148
  case "platform_connection_check":
149
+ case "platform_get_design_system_state":
150
+ case "platform_edit_design_system":
151
+ case "platform_publish_design_system":
149
152
  case "platform_list_build_workspaces":
150
153
  case "platform_list_artifact_drafts":
151
154
  case "platform_deploy_operation":
@@ -155,6 +158,12 @@ export async function callHarnessTool(opts, name, args, hooks) {
155
158
  case "platform_delete_artifact_draft":
156
159
  result = toolResult(await callHostedBuildTool(opts, name, input));
157
160
  break;
161
+ case "platform_checkout_design_system":
162
+ result = toolResult(await checkoutDesignSystem(opts, input));
163
+ break;
164
+ case "platform_submit_design_system_draft":
165
+ result = toolResult(await submitDesignSystemDraft(opts, input));
166
+ break;
158
167
  case "platform_deploy_artifact_app":
159
168
  result = toolResult(await deployArtifactApp(opts, input, hooks?.onProgress));
160
169
  break;
@@ -313,6 +322,132 @@ async function callHostedBuildTool(opts, name, input) {
313
322
  return result.structuredContent;
314
323
  throw new Error(`platform_mcp_tool_output_missing:${name}`);
315
324
  }
325
+ async function checkoutDesignSystem(opts, input) {
326
+ if (mcpSurface(opts) !== "build")
327
+ throw new Error("storyboard_requires_build_surface");
328
+ const targetDir = stringArg(input, "targetDir");
329
+ const target = await resolveWorkspacePathSafely(opts.workspaceRoot, targetDir);
330
+ if (existsSync(target))
331
+ throw new Error("storyboard_checkout_target_exists");
332
+ await assertAuthFileOutsideAppDir(opts.authFile, target);
333
+ const controller = new AbortController();
334
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
335
+ let bundle;
336
+ let commitSha;
337
+ try {
338
+ const response = await (opts.fetchImpl ?? fetch)(`${platformBaseUrl(opts)}/api/harness/storyboard/source-bundle`, {
339
+ headers: await jsonHeaders(opts), signal: controller.signal
340
+ });
341
+ if (!response.ok)
342
+ throw new Error(`platform_harness_http_error:${response.status}:storyboard_source_unavailable`);
343
+ commitSha = response.headers.get("x-topline-storyboard-commit") ?? "";
344
+ if (!/^[a-f0-9]{40}$/.test(commitSha))
345
+ throw new Error("storyboard_source_commit_invalid");
346
+ bundle = await readBoundedStoryboardBundle(response);
347
+ }
348
+ catch (error) {
349
+ if (controller.signal.aborted)
350
+ throw new Error("platform_harness_timeout");
351
+ throw error;
352
+ }
353
+ finally {
354
+ clearTimeout(timer);
355
+ }
356
+ const temp = await mkdtemp(path.join(os.tmpdir(), "topline-storyboard-checkout-"));
357
+ try {
358
+ const bundlePath = path.join(temp, "source.bundle");
359
+ await writeFile(bundlePath, bundle);
360
+ await mkdir(path.dirname(target), { recursive: true });
361
+ const staging = await mkdtemp(path.join(path.dirname(target), ".topline-storyboard-checkout-"));
362
+ try {
363
+ const candidate = path.join(staging, "source");
364
+ await runLocalGit(["clone", "--quiet", bundlePath, candidate]);
365
+ await runLocalGit(["-C", candidate, "checkout", "--quiet", "--detach", commitSha]);
366
+ await runLocalGit(["-C", candidate, "remote", "remove", "origin"]);
367
+ const actual = (await runLocalGit(["-C", candidate, "rev-parse", "HEAD"])).trim();
368
+ if (actual !== commitSha)
369
+ throw new Error("storyboard_source_commit_mismatch");
370
+ if (existsSync(target))
371
+ throw new Error("storyboard_checkout_target_exists");
372
+ await rename(candidate, target);
373
+ return { ok: true, directory: path.relative(opts.workspaceRoot, target).replaceAll("\\", "/"), currentCommit: commitSha, localCommands: ["npm ci", "npm run storybook"] };
374
+ }
375
+ finally {
376
+ await rm(staging, { recursive: true, force: true });
377
+ }
378
+ }
379
+ finally {
380
+ await rm(temp, { recursive: true, force: true });
381
+ }
382
+ }
383
+ async function readBoundedStoryboardBundle(response) {
384
+ const declared = Number(response.headers.get("content-length") ?? 0);
385
+ if (!Number.isSafeInteger(declared) || declared < 0 || declared > MAX_BUNDLE_BYTES) {
386
+ throw new Error("storyboard_source_bundle_size_invalid");
387
+ }
388
+ const reader = response.body?.getReader();
389
+ if (!reader)
390
+ throw new Error("storyboard_source_bundle_size_invalid");
391
+ const chunks = [];
392
+ let bytes = 0;
393
+ for (;;) {
394
+ const { done, value } = await reader.read();
395
+ if (done)
396
+ break;
397
+ bytes += value.byteLength;
398
+ if (bytes > MAX_BUNDLE_BYTES) {
399
+ await reader.cancel().catch(() => undefined);
400
+ throw new Error("storyboard_source_bundle_size_invalid");
401
+ }
402
+ chunks.push(Buffer.from(value));
403
+ }
404
+ if (bytes === 0 || (declared > 0 && declared !== bytes))
405
+ throw new Error("storyboard_source_bundle_size_invalid");
406
+ return Buffer.concat(chunks, bytes);
407
+ }
408
+ async function submitDesignSystemDraft(opts, input) {
409
+ if (mcpSurface(opts) !== "build")
410
+ throw new Error("storyboard_requires_build_surface");
411
+ const localDir = await resolveWorkspacePathSafely(opts.workspaceRoot, stringArg(input, "localDir"));
412
+ await assertAuthFileOutsideAppDir(opts.authFile, localDir);
413
+ const expectedCurrentCommit = stringArg(input, "expectedCurrentCommit");
414
+ const expectedDraftRunId = input.expectedDraftRunId === null ? null : stringArg(input, "expectedDraftRunId");
415
+ const bundle = await packStoryboardSourceDirectory(localDir);
416
+ const digest = createHash("sha256").update(`${platformBaseUrl(opts)}:${expectedCurrentCommit}:${expectedDraftRunId ?? "none"}:${bundle.sha256}`).digest();
417
+ digest[6] = (digest[6] & 0x0f) | 0x40;
418
+ digest[8] = (digest[8] & 0x3f) | 0x80;
419
+ const hex = digest.subarray(0, 16).toString("hex");
420
+ const requestId = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
421
+ const payload = await fetchJson(opts, `${platformBaseUrl(opts)}/api/harness/storyboard/draft-bundle`, {
422
+ method: "POST",
423
+ headers: {
424
+ ...await jsonHeaders(opts), "content-type": "application/gzip",
425
+ "x-topline-request-id": requestId,
426
+ "x-topline-expected-current-commit": expectedCurrentCommit,
427
+ "x-topline-expected-draft-run-id": expectedDraftRunId ?? "none"
428
+ },
429
+ body: new Uint8Array(bundle.body)
430
+ }, Math.max(opts.timeoutMs, 120_000));
431
+ return { ...asRecord(payload), requestId, bundleSha256: bundle.sha256 };
432
+ }
433
+ async function runLocalGit(args) {
434
+ return new Promise((resolve, reject) => {
435
+ const child = spawn("git", ["-c", "core.fsmonitor=false", ...args], { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
436
+ let output = "";
437
+ const collect = (chunk) => { output = (output + chunk.toString()).slice(-2000); };
438
+ child.stdout.on("data", collect);
439
+ child.stderr.on("data", collect);
440
+ const timer = setTimeout(() => child.kill(), 60_000);
441
+ child.on("error", reject);
442
+ child.on("close", (code) => {
443
+ clearTimeout(timer);
444
+ if (code === 0)
445
+ resolve(output);
446
+ else
447
+ reject(new Error(`storyboard_git_command_failed:${code ?? "unknown"}`));
448
+ });
449
+ });
450
+ }
316
451
  export async function waitForArtifactDeploy(opts, input, onProgress) {
317
452
  const artifactId = stringArg(input, "artifactId");
318
453
  const operationId = stringArg(input, "operationId");
@@ -469,13 +604,19 @@ export function storybookAccess(opts) {
469
604
  };
470
605
  }
471
606
  export async function packArtifactAppDirectory(appDir, archivePrefix = "") {
607
+ return packDirectory(appDir, archivePrefix, shouldExclude);
608
+ }
609
+ export async function packStoryboardSourceDirectory(appDir) {
610
+ return packDirectory(appDir, "", shouldExcludeStoryboard);
611
+ }
612
+ async function packDirectory(appDir, archivePrefix, exclude) {
472
613
  const root = path.resolve(appDir);
473
614
  if ((await lstat(root)).isSymbolicLink())
474
615
  throw new Error("artifact_app_bundle_symlink_forbidden:.");
475
616
  const prefix = archivePrefix ? validateArchivePrefix(archivePrefix) : "";
476
617
  const files = [];
477
618
  const excluded = [];
478
- await walk(root, root, files, excluded);
619
+ await walk(root, root, files, excluded, exclude);
479
620
  if (files.length === 0)
480
621
  throw new Error("artifact_app_bundle_empty");
481
622
  let byteCount = 0;
@@ -506,21 +647,21 @@ export async function packArtifactAppDirectory(appDir, archivePrefix = "") {
506
647
  function compareUtf8Paths(left, right) {
507
648
  return Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8"));
508
649
  }
509
- async function walk(root, current, files, excluded) {
650
+ async function walk(root, current, files, excluded, exclude) {
510
651
  const entries = await readdir(current, { withFileTypes: true });
511
652
  for (const entry of entries) {
512
653
  const absolute = path.join(current, entry.name);
513
654
  const relative = toPosix(path.relative(root, absolute));
514
655
  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())) {
656
+ if (exclude(relative, entry.isDirectory())) {
519
657
  excluded.push(relative);
520
658
  continue;
521
659
  }
660
+ const stat = await lstat(absolute);
661
+ if (stat.isSymbolicLink())
662
+ throw new Error(`artifact_app_bundle_symlink_forbidden:${relative}`);
522
663
  if (entry.isDirectory()) {
523
- await walk(root, absolute, files, excluded);
664
+ await walk(root, absolute, files, excluded, exclude);
524
665
  }
525
666
  else if (entry.isFile()) {
526
667
  if (stat.nlink > 1)
@@ -650,6 +791,13 @@ function shouldExclude(relative, isDirectory) {
650
791
  return true;
651
792
  return false;
652
793
  }
794
+ function shouldExcludeStoryboard(relative, isDirectory) {
795
+ const parts = relative.split("/").filter(Boolean);
796
+ const name = parts.at(-1) ?? "";
797
+ return name.startsWith(".env") || parts.some((part) => part === ".git" || part === "node_modules"
798
+ || part === "storybook-static" || part === ".topline" || part === ".cursor")
799
+ || (isDirectory && (name === ".vite" || name === ".cache"));
800
+ }
653
801
  function tarHeader(name, size) {
654
802
  const header = Buffer.alloc(BLOCK_SIZE);
655
803
  const { fileName, prefix } = splitTarName(name);
@@ -698,9 +846,9 @@ function padSize(size) {
698
846
  const remainder = size % BLOCK_SIZE;
699
847
  return remainder === 0 ? 0 : BLOCK_SIZE - remainder;
700
848
  }
701
- async function fetchJson(opts, url, init) {
849
+ async function fetchJson(opts, url, init, timeoutMs = opts.timeoutMs) {
702
850
  const controller = new AbortController();
703
- const timeout = setTimeout(() => controller.abort(), opts.timeoutMs);
851
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
704
852
  try {
705
853
  const response = await (opts.fetchImpl ?? fetch)(url, { ...init, signal: controller.signal });
706
854
  const text = await response.text();
@@ -940,6 +1088,68 @@ const BUILD_MCP_TOOLS = [
940
1088
  required: ["ok", "storybookUrl", "mcpUrl", "mcpServers"]
941
1089
  }
942
1090
  },
1091
+ {
1092
+ name: "platform_get_design_system_state",
1093
+ 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.",
1094
+ annotations: mcpToolAnnotations("platform_get_design_system_state", { readOnly: false }),
1095
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
1096
+ outputSchema: { type: "object" }
1097
+ },
1098
+ {
1099
+ name: "platform_checkout_design_system",
1100
+ 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.",
1101
+ annotations: mcpToolAnnotations("platform_checkout_design_system", { readOnly: false, openWorld: true }),
1102
+ inputSchema: { type: "object", properties: { targetDir: { type: "string", minLength: 1 } }, required: ["targetDir"], additionalProperties: false },
1103
+ outputSchema: { type: "object", properties: { ok: { type: "boolean" }, directory: { type: "string" }, currentCommit: { type: "string" }, localCommands: { type: "array", items: { type: "string" } } }, required: ["ok", "directory", "currentCommit", "localCommands"] }
1104
+ },
1105
+ {
1106
+ name: "platform_submit_design_system_draft",
1107
+ 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.",
1108
+ annotations: mcpToolAnnotations("platform_submit_design_system_draft", { readOnly: false, openWorld: true }),
1109
+ inputSchema: {
1110
+ type: "object",
1111
+ properties: {
1112
+ localDir: { type: "string", minLength: 1 },
1113
+ expectedCurrentCommit: { type: "string", pattern: "^[a-f0-9]{40}$" },
1114
+ expectedDraftRunId: { anyOf: [{ type: "string", format: "uuid" }, { type: "null" }] }
1115
+ },
1116
+ required: ["localDir", "expectedCurrentCommit", "expectedDraftRunId"], additionalProperties: false
1117
+ },
1118
+ 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"] }
1119
+ },
1120
+ {
1121
+ name: "platform_edit_design_system",
1122
+ 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.",
1123
+ annotations: mcpToolAnnotations("platform_edit_design_system", { readOnly: false, openWorld: true }),
1124
+ inputSchema: {
1125
+ type: "object",
1126
+ properties: {
1127
+ conversationId: { type: "string", format: "uuid" },
1128
+ instruction: { type: "string", minLength: 1, maxLength: 10000 },
1129
+ expectedDraftRunId: { anyOf: [{ type: "string", format: "uuid" }, { type: "null" }] }
1130
+ },
1131
+ required: ["conversationId", "instruction", "expectedDraftRunId"],
1132
+ additionalProperties: false
1133
+ },
1134
+ outputSchema: { type: "object", properties: { runId: { type: "string" }, previewUrl: { type: "string" } }, required: ["runId", "previewUrl"] }
1135
+ },
1136
+ {
1137
+ name: "platform_publish_design_system",
1138
+ 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.",
1139
+ annotations: mcpToolAnnotations("platform_publish_design_system", { readOnly: false, destructive: true, openWorld: true }),
1140
+ inputSchema: {
1141
+ type: "object",
1142
+ properties: {
1143
+ conversationId: { type: "string", format: "uuid" },
1144
+ expectedDraftRunId: { type: "string", format: "uuid" },
1145
+ expectedDraftCommit: { type: "string", pattern: "^[a-f0-9]{40}$" },
1146
+ expectedCurrentCommit: { anyOf: [{ type: "string", pattern: "^[a-f0-9]{40}$" }, { type: "null" }] }
1147
+ },
1148
+ required: ["expectedDraftRunId", "expectedDraftCommit", "expectedCurrentCommit"],
1149
+ additionalProperties: false
1150
+ },
1151
+ outputSchema: { type: "object", properties: { publishedCommit: { type: "string" } }, required: ["publishedCommit"] }
1152
+ },
943
1153
  {
944
1154
  name: "platform_validate_artifact",
945
1155
  description: "Read trusted smoke and health evidence for a private artifact draft without requiring a browser session.",