@topline-build/harness-mcp 0.5.3 → 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 CHANGED
@@ -4,6 +4,12 @@ All notable customer-visible Build MCP changes are recorded here. A source chang
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.6.0 - pending publication
8
+
9
+ - Add server-owned external build sessions, guarded artifact-private SQL, and full acceptance/reviewer forwarding.
10
+ - Check out exact artifact source and materialize selected assets through authenticated same-account transfers with hash, revision, archive and local-path checks.
11
+ - Bind local draft create/deploy to the existing session identity, checked-out version, inspected source hash and stable operation keys. Additional asset, secret-metadata and data-verification scopes require explicit consent.
12
+
7
13
  ## 0.5.3 - pending publication
8
14
 
9
15
  - Add tenant-scoped Storyboard state, guarded edit, and exact-draft publication tools to Build MCP.
package/README.md CHANGED
@@ -28,6 +28,12 @@ Storyboard authoring uses separate `storyboard:read` and `storyboard:write` OAut
28
28
 
29
29
  ## Typical workflow
30
30
 
31
+ The 0.6.0 source adds session authoring; npm publication and a compatible customer API rollout are separate prerequisites. Start `platform_start_build_session` with an existing artifact's exact version or a new app manifest, a stable idempotency key and a Topline operation budget. External client model charges stay separate. For existing apps, `platform_checkout_artifact_app` writes exact saved source into a new local directory and returns its version, commit and source hash. Never reconstruct missing source from its manifest.
32
+
33
+ Keep Build and Workspace as separate same-account connections. Workspace supplies catalog, SQL and trusted calculations through `data:read`. Reusable assets, approved secret metadata, private table writes, validation and finite artifact data verification each require their separately consented Build scopes: `harness:assets:read`, `harness:secrets:metadata:read`, `harness:artifact:tables:write`, `harness:artifact:validate`, and `harness:artifact:data:verify`. The defaults remain the existing artifact scopes; request an expanded set explicitly and reconnect. Secret discovery returns approved references only, never values.
34
+
35
+ In session mode, `platform_deploy_artifact_app` requires the session ID, its exact current artifact version, a stable idempotency key, the session/checkout `inspectedSourceHash` and a fresh local `expectedBundleSha256`. The former pins the previously inspected remote source; the latter pins the local upload. Recover pending deployment and reviewer operations with their original IDs. Unknown work is never resubmitted by a status read. Add and run acceptance checks, prepare validation and request the independent review for the exact deployed revision. Approved output stays a private draft for an operator to publish.
36
+
31
37
  1. Use `platform_init_artifact_app` to create a starter, or point at an existing artifact app.
32
38
  2. Use `platform_inspect_artifact_app` to check local package boundaries and record its bundle SHA-256.
33
39
  3. Use `platform_deploy_artifact_app` with that SHA-256 to create or update a private draft.
@@ -0,0 +1,9 @@
1
+ import type { ListToolsResult, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
2
+ type Annotate = (name: string, policy: {
3
+ readOnly: boolean;
4
+ idempotent?: boolean;
5
+ openWorld?: boolean;
6
+ }) => ToolAnnotations;
7
+ export declare function externalBuildHelperTools(annotate: Annotate): ListToolsResult["tools"];
8
+ export declare const EXTERNAL_BUILD_FORWARD_TOOLS: Set<string>;
9
+ export {};
@@ -0,0 +1,63 @@
1
+ const id = { type: "string", format: "uuid" };
2
+ const requestKey = { type: "string", minLength: 1, maxLength: 128, pattern: "^[A-Za-z0-9._:-]+$" };
3
+ const session = { sessionId: id };
4
+ const mutation = { ...session, artifactVersionId: id, idempotencyKey: requestKey };
5
+ const deployed = { ...mutation, deploymentRecordId: id };
6
+ export function externalBuildHelperTools(annotate) {
7
+ const tool = (name, description, properties, required, readOnly = false) => ({
8
+ name, description, annotations: annotate(name, { readOnly, idempotent: true, openWorld: true }),
9
+ inputSchema: { type: "object", additionalProperties: false, properties, required },
10
+ // Recovery adds a oneOf branch for {ok:false,error:...}; successful objects
11
+ // may omit ok, but must never match that branch too.
12
+ outputSchema: { type: "object", properties: { ok: { const: true } } }
13
+ });
14
+ return [
15
+ tool("platform_capability_status", "Read the connected Build product's current available, denied, unsupported and unconfigured tool families, contract version and helper requirement. Reconnect explicitly for newly consented scopes.", {}, [], true),
16
+ tool("platform_start_build_session", "Start external authoring with a private artifact identity, current version and platform-operation budget. Launches no internal coder. Existing artifact starts require its current version; new app starts require a manifest. Optional own conversation selects exact operator-approved secret references.", {
17
+ idempotencyKey: requestKey, artifactId: id, artifactVersionId: id, manifest: { type: "object" }, projectId: id, owningConversationId: id,
18
+ budgetCents: { type: "integer", minimum: 0, maximum: 100000 }, brief: { type: "string", minLength: 3, maxLength: 4000 }
19
+ }, ["idempotencyKey", "budgetCents", "brief"]),
20
+ tool("platform_read_build_session", "Read the owned build session and original pending operations. Does not start another coder or deployment.", session, ["sessionId"], true),
21
+ tool("platform_close_build_session", "Stop new authoring and request pending-operation cancellation. Unknown effects retain their reservation; cancellation does not undo committed writes.", session, ["sessionId"]),
22
+ tool("platform_run_artifact_workspace_sql", "Run guarded private artifact SQL using the exact session namespace, version and retry key. CREATE TABLE/ADD COLUMN require documentation. Enforced non-admin writes remain denied; unknown writes are never replayed.", {
23
+ ...mutation, sql: { type: "string", minLength: 1, maxLength: 20000 }, purpose: { type: "string", minLength: 3, maxLength: 500 },
24
+ params: { type: "object", additionalProperties: true }, maxRows: { type: "integer", minimum: 1, maximum: 5000 }, documentation: { type: "object" }, columnDocumentation: { type: "object" }
25
+ }, [...Object.keys(mutation), "sql", "purpose"]),
26
+ tool("platform_checkout_artifact_app", "Download exact saved app source through the owned session, verify archive hash/version/commit and extract regular source files into a new local directory. Missing source is never reconstructed.", {
27
+ ...session, targetDir: { type: "string", minLength: 1 }
28
+ }, ["sessionId", "targetDir"]),
29
+ tool("platform_list_asset_folders", "List the session's permitted reusable asset folders.", session, ["sessionId"], true),
30
+ tool("platform_list_assets", "List permitted reusable asset metadata with bounded pagination; excluded/deleted attachments are omitted.", {
31
+ ...session, query: { type: "string", maxLength: 200 }, folderId: id, limit: { type: "integer", minimum: 1, maximum: 500 }, offset: { type: "integer", minimum: 0 }
32
+ }, ["sessionId"], true),
33
+ tool("platform_materialize_assets", "Download exactly selected approved files into a new local directory. Verify each authenticated transfer's hash and size; source and attachment budgets remain separate.", {
34
+ ...session, assetIds: { type: "array", minItems: 1, maxItems: 50, uniqueItems: true, items: id }, targetDir: { type: "string", minLength: 1 }
35
+ }, ["sessionId", "assetIds", "targetDir"]),
36
+ tool("platform_list_build_secrets", "Read only exact operator-approved binding references for this session. Values are never retrieved. Authoring detach does not prove deployed runtime revocation.", session, ["sessionId"], true),
37
+ tool("platform_add_acceptance_check", "Add a required acceptance check bound to this build session and deployed revision. A semantic control query requires separate Build data-verification consent.", {
38
+ ...mutation, description: { type: "string", minLength: 3, maxLength: 1000 }
39
+ }, [...Object.keys(mutation), "description"]),
40
+ tool("platform_run_acceptance_checks", "Run required acceptance checks for the exact current draft revision through the shared service.", mutation, Object.keys(mutation)),
41
+ tool("platform_record_acceptance_review", "Record the builder's acceptance review without impersonating the independent validator or removing required checks.", {
42
+ ...mutation, checkId: id, status: { type: "string", enum: ["passed", "failed"] }, summary: { type: "string", minLength: 3, maxLength: 2000 }
43
+ }, [...Object.keys(mutation), "checkId", "status", "summary"]),
44
+ tool("platform_prepare_artifact_validation", "Prepare validation evidence for the exact private draft revision; missing required checks block readiness.", { ...session, artifactVersionId: id, deploymentRecordId: id }, ["sessionId", "artifactVersionId", "deploymentRecordId"], true),
45
+ tool("platform_request_artifact_validation", "Request the independent reviewer for the exact deployment and bounded evidence. Returns durable operation status; unavailable infrastructure leaves operator review required, never approval.", {
46
+ ...deployed, summary: { type: "string", minLength: 3, maxLength: 4000 }, waitMs: { type: "integer", minimum: 0, maximum: 1000 },
47
+ buildOk: { type: "boolean" }, typecheckOk: { type: "boolean" }, docImpact: { type: "string", enum: ["material", "none"] },
48
+ docImpactNotes: { type: "string", minLength: 12, maxLength: 2000 }, visualReview: { type: "object", additionalProperties: true }, dataValidation: { type: "object", additionalProperties: true }
49
+ }, [...Object.keys(deployed), "summary", "docImpact", "docImpactNotes", "visualReview"]),
50
+ tool("platform_artifact_validation_status", "Read the original validation result and pending prompts without starting another reviewer or deploy. Current authorization is rechecked before evidence delivery.", {
51
+ ...session, operationId: id
52
+ }, ["sessionId", "operationId"], true),
53
+ tool("platform_answer_operator_prompt", "Answer the exact saved operator prompt on the original review operation. Appends guidance and reopens the same eligible session; cannot approve validation or widen the saved grant.", {
54
+ ...session, operationId: id, promptId: id, idempotencyKey: requestKey, message: { type: "string", minLength: 3, maxLength: 4000 }
55
+ }, ["sessionId", "operationId", "promptId", "idempotencyKey", "message"])
56
+ ];
57
+ }
58
+ export const EXTERNAL_BUILD_FORWARD_TOOLS = new Set([
59
+ "platform_capability_status",
60
+ "platform_start_build_session", "platform_read_build_session", "platform_close_build_session", "platform_run_artifact_workspace_sql",
61
+ "platform_list_asset_folders", "platform_list_assets", "platform_list_build_secrets", "platform_add_acceptance_check", "platform_run_acceptance_checks",
62
+ "platform_record_acceptance_review", "platform_prepare_artifact_validation", "platform_request_artifact_validation", "platform_artifact_validation_status", "platform_answer_operator_prompt"
63
+ ]);
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.3";
35
+ export declare const HARNESS_MCP_VERSION = "0.6.0";
36
36
  export declare const DEFAULT_OAUTH_SCOPES: readonly string[];
37
37
  export interface BuildMcpSetupOption {
38
38
  readonly flag: string;
@@ -52,11 +52,6 @@ export declare function callHarnessTool(opts: HarnessMcpStdioOptions, name: stri
52
52
  onProgress?: ToolProgressReporter;
53
53
  }): Promise<CallToolResult>;
54
54
  export declare function deployArtifactApp(opts: HarnessMcpStdioOptions, input: Record<string, unknown>, onProgress?: ToolProgressReporter): Promise<{
55
- ok: boolean;
56
- artifactId: string;
57
- artifactVersionId: string;
58
- manifestId: string | null;
59
- clientRequestId: string;
60
55
  operationId: string | null;
61
56
  bundle: {
62
57
  fileCount: number;
@@ -66,6 +61,37 @@ export declare function deployArtifactApp(opts: HarnessMcpStdioOptions, input: R
66
61
  };
67
62
  create: unknown;
68
63
  deploy: unknown;
64
+ sessionId?: string | undefined;
65
+ ok: boolean;
66
+ artifactId: string;
67
+ artifactVersionId: string;
68
+ manifestId: string | null;
69
+ clientRequestId: string;
70
+ }>;
71
+ export declare function checkoutArtifactApp(opts: HarnessMcpStdioOptions, input: Record<string, unknown>): Promise<{
72
+ ok: boolean;
73
+ sessionId: string;
74
+ artifactId: unknown;
75
+ artifactVersionId: string;
76
+ commitSha: string;
77
+ sourceHash: string;
78
+ appDir: string;
79
+ platformAppDir: unknown;
80
+ fileCount: number;
81
+ byteCount: number;
82
+ }>;
83
+ export declare function materializeBuildAssets(opts: HarnessMcpStdioOptions, input: Record<string, unknown>): Promise<{
84
+ ok: boolean;
85
+ sessionId: string;
86
+ directory: string;
87
+ byteCount: number;
88
+ files: {
89
+ id: string;
90
+ file: string;
91
+ size: number;
92
+ sha256: unknown;
93
+ mime: unknown;
94
+ }[];
69
95
  }>;
70
96
  export declare function inspectArtifactApp(opts: HarnessMcpStdioOptions, input: Record<string, unknown>): Promise<{
71
97
  ok: boolean;
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, mkdtemp, readdir, readFile, realpath, lstat, writeFile, rm, rename } 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.3";
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." },
@@ -161,6 +163,12 @@ export async function callHarnessTool(opts, name, args, hooks) {
161
163
  case "platform_checkout_design_system":
162
164
  result = toolResult(await checkoutDesignSystem(opts, input));
163
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;
164
172
  case "platform_submit_design_system_draft":
165
173
  result = toolResult(await submitDesignSystemDraft(opts, input));
166
174
  break;
@@ -189,6 +197,12 @@ export async function callHarnessTool(opts, name, args, hooks) {
189
197
  result = toolResult(await initArtifactApp(opts, input));
190
198
  break;
191
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
+ }
192
206
  throw new McpError(ErrorCode.InvalidParams, `Unknown tool: ${name}`);
193
207
  }
194
208
  const outputError = tool.outputSchema
@@ -203,7 +217,8 @@ export async function deployArtifactApp(opts, input, onProgress) {
203
217
  await assertAuthFileOutsideAppDir(opts.authFile, absoluteAppDir);
204
218
  const workspaceAppDir = workspaceRelativeAppDir(opts.workspaceRoot, absoluteAppDir);
205
219
  const manifest = await readManifest(absoluteAppDir);
206
- const platformAppDir = logicalArtifactAppDir(workspaceAppDir, manifest);
220
+ const suppliedPlatformAppDir = optionalString(input, "platformAppDir");
221
+ const platformAppDir = suppliedPlatformAppDir ? (suppliedPlatformAppDir === "." ? "." : validateArchivePrefix(suppliedPlatformAppDir)) : logicalArtifactAppDir(workspaceAppDir, manifest);
207
222
  const clientRequestId = optionalString(input, "requestId") ?? createHash("sha256")
208
223
  .update(JSON.stringify({ apiUrl: platformBaseUrl(opts), appDir: platformAppDir, manifestId: manifest.id ?? null }))
209
224
  .digest("hex");
@@ -213,18 +228,39 @@ export async function deployArtifactApp(opts, input, onProgress) {
213
228
  if (expectedBundleSha256 && bundle.sha256 !== expectedBundleSha256) {
214
229
  throw new Error("artifact_app_bundle_changed_after_inspection");
215
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");
216
251
  await onProgress?.(0.4, "Creating private artifact draft");
217
252
  const created = await fetchJson(opts, artifactAppsPath(opts), {
218
253
  method: "POST",
219
254
  headers: await jsonHeaders(opts),
220
255
  body: JSON.stringify({
221
- artifactId: optionalString(input, "artifactId"),
256
+ artifactId: sessionArtifactId ?? optionalString(input, "artifactId"),
222
257
  title: optionalString(input, "title"),
223
258
  description: optionalString(input, "description"),
224
259
  projectId: optionalString(input, "projectId"),
225
260
  clientRequestId,
226
261
  bundleSha256: bundle.sha256,
227
- manifest
262
+ manifest,
263
+ ...(sessionId ? { sessionId, artifactVersionId: sessionVersion, idempotencyKey, inspectedSourceHash } : {})
228
264
  })
229
265
  });
230
266
  const createdRecord = asRecord(created);
@@ -241,19 +277,22 @@ export async function deployArtifactApp(opts, input, onProgress) {
241
277
  "content-type": "application/gzip",
242
278
  "MCP-Protocol-Version": opts.protocolVersion,
243
279
  "x-platform-artifact-version-id": artifactVersionId,
244
- "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 } : {})
245
282
  },
246
283
  body: bundle.body
247
284
  });
248
285
  await onProgress?.(1, "Completed trusted draft deployment");
249
286
  const deployedRecord = asRecord(deployed);
250
- 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;
251
289
  return {
252
290
  ok: true,
253
291
  artifactId,
254
292
  artifactVersionId,
255
293
  manifestId: typeof manifest.id === "string" ? manifest.id : null,
256
294
  clientRequestId,
295
+ ...(sessionId ? { sessionId } : {}),
257
296
  operationId,
258
297
  bundle: {
259
298
  fileCount: bundle.fileCount,
@@ -265,13 +304,135 @@ export async function deployArtifactApp(opts, input, onProgress) {
265
304
  deploy: deployed
266
305
  };
267
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
+ }
268
428
  export async function inspectArtifactApp(opts, input) {
269
429
  const appDir = stringArg(input, "appDir");
270
430
  const absoluteAppDir = await resolveWorkspacePathSafely(opts.workspaceRoot, appDir);
271
431
  await assertAuthFileOutsideAppDir(opts.authFile, absoluteAppDir);
272
432
  const workspaceAppDir = workspaceRelativeAppDir(opts.workspaceRoot, absoluteAppDir);
273
433
  const manifest = await readManifest(absoluteAppDir);
274
- const platformAppDir = logicalArtifactAppDir(workspaceAppDir, manifest);
434
+ const suppliedPlatformAppDir = optionalString(input, "platformAppDir");
435
+ const platformAppDir = suppliedPlatformAppDir ? (suppliedPlatformAppDir === "." ? "." : validateArchivePrefix(suppliedPlatformAppDir)) : logicalArtifactAppDir(workspaceAppDir, manifest);
275
436
  const bundle = await packArtifactAppDirectory(absoluteAppDir, platformAppDir === "." ? "" : platformAppDir);
276
437
  return {
277
438
  ok: true,
@@ -787,6 +948,8 @@ function shouldExclude(relative, isDirectory) {
787
948
  return true;
788
949
  if (parts.some((part) => part === ".git" || part === "node_modules"))
789
950
  return true;
951
+ if (parts.some(part => /(?:\.(?:pem|key|p12|pfx)|^\.?(?:credentials|netrc|npmrc|aws|ssh)$)/i.test(part)))
952
+ return true;
790
953
  if (isDirectory && FORBIDDEN_DIRS.has(name))
791
954
  return true;
792
955
  return false;
@@ -927,7 +1090,12 @@ const BUILD_MCP_TOOLS = [
927
1090
  description: { type: "string" },
928
1091
  projectId: { type: "string", format: "uuid" },
929
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." },
930
- 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." }
931
1099
  },
932
1100
  required: ["appDir"],
933
1101
  additionalProperties: false
@@ -953,7 +1121,7 @@ const BUILD_MCP_TOOLS = [
953
1121
  annotations: mcpToolAnnotations("platform_inspect_artifact_app", { readOnly: true }),
954
1122
  inputSchema: {
955
1123
  type: "object",
956
- 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." } },
957
1125
  required: ["appDir"],
958
1126
  additionalProperties: false
959
1127
  },
@@ -1207,7 +1375,8 @@ const BUILD_MCP_TOOLS = [
1207
1375
  properties: { ok: { type: "boolean" }, deleted: { type: "boolean" }, artifactId: { type: "string" } },
1208
1376
  required: ["ok", "deleted", "artifactId"]
1209
1377
  }
1210
- }
1378
+ },
1379
+ ...externalBuildHelperTools(mcpToolAnnotations)
1211
1380
  ].map(withRecoveryOutputSchema);
1212
1381
  export function listTools() {
1213
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>;
@@ -0,0 +1,146 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { gunzipSync } from "node:zlib";
5
+ export const MAX_SOURCE_ARCHIVE_BYTES = 64 * 1024 * 1024;
6
+ const MAX_CONTENT_BYTES = 512 * 1024 * 1024;
7
+ const MAX_FILES = 25_000;
8
+ const BLOCK = 512;
9
+ const forbiddenDirs = new Set([".git", "node_modules", ".next", ".vite", ".turbo", ".cache", "dist", "build", "coverage"]);
10
+ /** All path rules apply on every host, including Windows aliases and devices. */
11
+ export function safeSourcePath(raw) {
12
+ if (!raw || raw.startsWith("/") || raw.includes("\\"))
13
+ throw new Error("artifact_source_path_invalid");
14
+ const segments = raw.replace(/^\.\//, "").split("/");
15
+ if (segments.at(-1) === "")
16
+ segments.pop();
17
+ if (!segments.length || segments.length > 64 || segments.some(part => !part || part === "." || part === ".." || /[\u0000-\u001f\u007f:]/u.test(part) || /[. ]$/.test(part) || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part)))
18
+ throw new Error("artifact_source_path_invalid");
19
+ if (segments.some(part => forbiddenDirs.has(part.toLowerCase()) || part.toLowerCase().startsWith(".env") || /(?:\.(?:pem|key|p12|pfx)|^\.?(?:credentials|netrc|npmrc|aws|ssh)$)/i.test(part)))
20
+ throw new Error("artifact_source_credential_or_cache_forbidden");
21
+ return segments.join("/");
22
+ }
23
+ function text(header, offset, length) {
24
+ const field = header.subarray(offset, offset + length);
25
+ const nul = field.indexOf(0);
26
+ return field.subarray(0, nul < 0 ? field.length : nul).toString("utf8");
27
+ }
28
+ function octal(header, offset, length) {
29
+ const value = text(header, offset, length).trim();
30
+ if (!/^[0-7]+$/.test(value))
31
+ throw new Error("artifact_source_tar_invalid");
32
+ const number = Number.parseInt(value, 8);
33
+ if (!Number.isSafeInteger(number) || number < 0)
34
+ throw new Error("artifact_source_tar_invalid");
35
+ return number;
36
+ }
37
+ /** Extract only regular files into a task-created empty staging directory. */
38
+ export async function extractArtifactSourceArchive(body, stagingRoot) {
39
+ if (!body.length || body.length > MAX_SOURCE_ARCHIVE_BYTES)
40
+ throw new Error("artifact_source_transfer_too_large");
41
+ let tar;
42
+ try {
43
+ tar = gunzipSync(body, { maxOutputLength: MAX_CONTENT_BYTES + MAX_FILES * 1024 });
44
+ }
45
+ catch {
46
+ throw new Error("artifact_source_archive_invalid");
47
+ }
48
+ let position = 0;
49
+ let fileCount = 0;
50
+ let byteCount = 0;
51
+ let ended = false;
52
+ let entryCount = 0;
53
+ const files = new Set();
54
+ const dirs = new Set();
55
+ const paths = new Map();
56
+ while (position + BLOCK <= tar.length) {
57
+ const header = tar.subarray(position, position + BLOCK);
58
+ position += BLOCK;
59
+ if (header.every(byte => byte === 0)) {
60
+ if (position + BLOCK > tar.length || !tar.subarray(position).every(byte => byte === 0))
61
+ throw new Error("artifact_source_tar_invalid");
62
+ ended = true;
63
+ break;
64
+ }
65
+ if (++entryCount > MAX_FILES * 2)
66
+ throw new Error("artifact_source_unpacked_too_large");
67
+ const expectedChecksum = octal(header, 148, 8);
68
+ const checksum = header.reduce((sum, byte, index) => sum + (index >= 148 && index < 156 ? 32 : byte), 0);
69
+ if (checksum !== expectedChecksum || text(header, 257, 6) !== "ustar")
70
+ throw new Error("artifact_source_tar_invalid");
71
+ const prefix = text(header, 345, 155);
72
+ const rawName = `${prefix ? prefix + "/" : ""}${text(header, 0, 100)}`;
73
+ const size = octal(header, 124, 12);
74
+ const padded = Math.ceil(size / BLOCK) * BLOCK;
75
+ if (position + padded > tar.length)
76
+ throw new Error("artifact_source_tar_invalid");
77
+ const kind = String.fromCharCode(header[156]);
78
+ if ((rawName === "." || rawName === "./") && kind === "5" && size === 0)
79
+ continue;
80
+ const relative = safeSourcePath(rawName);
81
+ const key = relative.toLowerCase();
82
+ const segments = relative.split("/");
83
+ for (let length = 1; length <= segments.length; length++) {
84
+ const actual = segments.slice(0, length).join("/");
85
+ const canonical = actual.toLowerCase();
86
+ if (paths.has(canonical) && paths.get(canonical) !== actual)
87
+ throw new Error("artifact_source_duplicate_path");
88
+ paths.set(canonical, actual);
89
+ }
90
+ if (paths.size > MAX_FILES * 2)
91
+ throw new Error("artifact_source_unpacked_too_large");
92
+ if (kind !== "0" && kind !== "\0" && kind !== "5")
93
+ throw new Error("artifact_source_entry_type_forbidden");
94
+ if (files.has(key) || dirs.has(key))
95
+ throw new Error("artifact_source_duplicate_path");
96
+ const ancestors = key.split("/").slice(0, -1);
97
+ if (ancestors.some((_, index) => files.has(ancestors.slice(0, index + 1).join("/"))))
98
+ throw new Error("artifact_source_duplicate_path");
99
+ const output = path.resolve(stagingRoot, relative);
100
+ const diff = path.relative(stagingRoot, output);
101
+ if (diff === ".." || diff.startsWith(`..${path.sep}`) || path.isAbsolute(diff))
102
+ throw new Error("artifact_source_path_invalid");
103
+ if (kind === "5") {
104
+ if (size !== 0)
105
+ throw new Error("artifact_source_tar_invalid");
106
+ dirs.add(key);
107
+ await mkdir(output, { recursive: true, mode: 0o700 });
108
+ }
109
+ else {
110
+ if (++fileCount > MAX_FILES || (byteCount += size) > MAX_CONTENT_BYTES)
111
+ throw new Error("artifact_source_unpacked_too_large");
112
+ files.add(key);
113
+ await mkdir(path.dirname(output), { recursive: true, mode: 0o700 });
114
+ await writeFile(output, tar.subarray(position, position + size), { flag: "wx", mode: 0o600 });
115
+ }
116
+ position += padded;
117
+ }
118
+ if (!ended || !fileCount)
119
+ throw new Error("artifact_source_archive_invalid");
120
+ return { fileCount, byteCount, sha256: createHash("sha256").update(body).digest("hex") };
121
+ }
122
+ export async function boundedTransfer(response, maxBytes, expectedSize) {
123
+ const rawLength = response.headers.get("content-length");
124
+ const declared = rawLength === null ? null : Number(rawLength);
125
+ if (declared !== null && (!Number.isSafeInteger(declared) || declared < 0 || declared > maxBytes || (expectedSize !== undefined && declared !== expectedSize)))
126
+ throw new Error("build_transfer_size_invalid");
127
+ if (!response.body)
128
+ throw new Error("build_transfer_size_invalid");
129
+ const chunks = [];
130
+ let total = 0;
131
+ const reader = response.body.getReader();
132
+ for (;;) {
133
+ const part = await reader.read();
134
+ if (part.done)
135
+ break;
136
+ total += part.value.byteLength;
137
+ if (total > maxBytes) {
138
+ await reader.cancel().catch(() => undefined);
139
+ throw new Error("build_transfer_size_invalid");
140
+ }
141
+ chunks.push(Buffer.from(part.value));
142
+ }
143
+ if ((!total && expectedSize !== 0) || (declared !== null && total !== declared) || (expectedSize !== undefined && total !== expectedSize))
144
+ throw new Error("build_transfer_size_invalid");
145
+ return Buffer.concat(chunks);
146
+ }