@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/CHANGELOG.md +11 -0
- package/README.md +8 -0
- package/dist/build-session-tools.d.ts +9 -0
- package/dist/build-session-tools.js +63 -0
- package/dist/index.d.ts +33 -6
- package/dist/index.js +399 -20
- package/dist/source-checkout.d.ts +10 -0
- package/dist/source-checkout.js +146 -0
- package/docs/generated/build-mcp-manifest.json +2384 -143
- package/docs/generated/build-mcp-reference.md +2624 -136
- package/package.json +4 -2
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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(),
|
|
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>;
|