@topline-build/harness-mcp 0.2.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,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, lstat, writeFile } from "node:fs/promises";
4
+ import { mkdir, readdir, readFile, realpath, lstat, writeFile } 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";
@@ -10,14 +10,29 @@ import { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js";
10
10
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
11
  import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
12
12
  import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from "@modelcontextprotocol/sdk/types.js";
13
- const DEFAULT_MCP_PROTOCOL_VERSION = "2025-11-25";
13
+ export const DEFAULT_MCP_PROTOCOL_VERSION = "2025-11-25";
14
14
  const DEFAULT_TIMEOUT_MS = 120_000;
15
- const SERVER_NAME = "topline-build-stdio";
16
- export const HARNESS_MCP_VERSION = "0.2.0";
17
- const DEFAULT_OAUTH_SCOPES = ["harness:artifact:read", "harness:artifact:write", "harness:artifact:deploy"];
15
+ export const BUILD_MCP_SERVER_NAME = "topline-build-stdio";
16
+ export const HARNESS_MCP_VERSION = "0.5.0";
17
+ export const DEFAULT_OAUTH_SCOPES = Object.freeze(["harness:artifact:read", "harness:artifact:write", "harness:artifact:deploy", "harness:artifact:delete"]);
18
+ export const BUILD_MCP_SETUP_OPTIONS = Object.freeze([
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." },
20
+ { flag: "--workspace-root", env: "TOPLINE_WORKSPACE_ROOT", aliases: ["PLATFORM_WORKSPACE_ROOT"], default: "current working directory", description: "Local root that contains artifact app directories." },
21
+ { flag: "--surface", env: "TOPLINE_MCP_SURFACE", default: "build", allowed: ["build", "harness"], description: "Build is the current surface; harness preserves legacy OAuth compatibility." },
22
+ { flag: "--protocol-version", env: "TOPLINE_MCP_PROTOCOL_VERSION", aliases: ["PLATFORM_MCP_PROTOCOL_VERSION"], default: DEFAULT_MCP_PROTOCOL_VERSION, description: "MCP protocol version sent to Topline HTTP endpoints." },
23
+ { flag: "--auth-file", env: "TOPLINE_MCP_AUTH_FILE", aliases: ["PLATFORM_MCP_AUTH_FILE"], default: "per-tenant file in the local Topline config directory", description: "OAuth credential cache. Keep it outside artifact app directories." },
24
+ { flag: "--scopes", env: "TOPLINE_MCP_SCOPES", aliases: ["PLATFORM_MCP_SCOPES"], default: DEFAULT_OAUTH_SCOPES.join(" "), description: "Space- or comma-separated OAuth scopes." },
25
+ { flag: "--timeout-ms", env: "TOPLINE_MCP_TIMEOUT_MS", aliases: ["PLATFORM_MCP_TIMEOUT_MS", "OVERSITE_MCP_TIMEOUT_MS"], default: DEFAULT_TIMEOUT_MS, description: "Positive request timeout in milliseconds." },
26
+ { flag: "--token", env: "TOPLINE_MCP_TOKEN", aliases: ["PLATFORM_MCP_TOKEN", "OVERSITE_MCP_TOKEN"], description: "Advanced manual bearer-token fallback. Prefer browser-approved OAuth." }
27
+ ].map((option) => Object.freeze({
28
+ ...option,
29
+ ...(option.aliases ? { aliases: Object.freeze(option.aliases) } : {}),
30
+ ...(option.allowed ? { allowed: Object.freeze(option.allowed) } : {})
31
+ })));
18
32
  const BLOCK_SIZE = 512;
19
33
  const MAX_BUNDLE_BYTES = 64 * 1024 * 1024;
20
34
  const MAX_UNPACKED_BYTES = 512 * 1024 * 1024;
35
+ const MAX_MANIFEST_BYTES = 512 * 1024;
21
36
  const MAX_FILES = 25_000;
22
37
  const MAX_DEPTH = 64;
23
38
  const FORBIDDEN_DIRS = new Set([".git", "node_modules", ".next", ".vite", ".turbo", ".cache", "dist", "build", "coverage"]);
@@ -55,7 +70,7 @@ export function parseHarnessOptions(argv, env = process.env) {
55
70
  surface: surfaceRaw,
56
71
  token,
57
72
  authFile,
58
- oauthScopes: scopes.length > 0 ? [...new Set(scopes)] : DEFAULT_OAUTH_SCOPES,
73
+ oauthScopes: scopes.length > 0 ? [...new Set(scopes)] : [...DEFAULT_OAUTH_SCOPES],
59
74
  timeoutMs: Math.floor(timeoutMs),
60
75
  protocolVersion,
61
76
  workspaceRoot
@@ -80,30 +95,34 @@ export function cliInfo(argv) {
80
95
  return [
81
96
  "Usage: topline-mcp --url https://your-topline-host [--workspace-root <path>]",
82
97
  "",
83
- "Environment:",
84
- " TOPLINE_API_URL Required unless --url is passed",
85
- " TOPLINE_WORKSPACE_ROOT Defaults to the current working directory",
86
- " TOPLINE_MCP_SURFACE Defaults to build; harness keeps legacy OAuth compatibility",
87
- " TOPLINE_MCP_TOKEN Optional manual token fallback",
88
- " TOPLINE_MCP_AUTH_FILE Optional OAuth credential cache path",
89
- " TOPLINE_MCP_TIMEOUT_MS Defaults to 120000",
98
+ "Options and environment:",
99
+ ...BUILD_MCP_SETUP_OPTIONS.map((option) => {
100
+ const requirement = option.required ? "required" : `default: ${option.default ?? "optional"}`;
101
+ return ` ${option.flag.padEnd(20)} ${option.env} (${requirement})`;
102
+ }),
90
103
  "",
91
104
  "Examples:",
92
105
  " npx -y @topline-build/harness-mcp --url https://hawx.topline.build",
93
106
  " TOPLINE_API_URL=https://hawx.topline.build npx -y @topline-build/harness-mcp"
94
107
  ].join("\n");
95
108
  }
96
- export function createHarnessMcpStdioServer(opts) {
97
- const mcp = new McpServer({ name: SERVER_NAME, version: HARNESS_MCP_VERSION }, { capabilities: { tools: {} } });
98
- mcp.setRequestHandler(ListToolsRequestSchema, async () => listTools());
109
+ export function createHarnessMcpStdioServer(opts, toolAllowlist) {
110
+ const allowedTools = toolAllowlist ? new Set(toolAllowlist) : null;
111
+ const listedTools = () => {
112
+ const listed = listTools();
113
+ return allowedTools ? { ...listed, tools: listed.tools.filter((tool) => allowedTools.has(tool.name)) } : listed;
114
+ };
115
+ const mcp = new McpServer({ name: BUILD_MCP_SERVER_NAME, version: HARNESS_MCP_VERSION }, { capabilities: { tools: {} } });
116
+ mcp.setRequestHandler(ListToolsRequestSchema, async () => listedTools());
99
117
  mcp.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
100
- const reportsProgress = req.params.name === "platform_deploy_artifact_app";
101
- if (reportsProgress)
102
- await sendProgress(extra, 0, 1, "Starting artifact deployment");
118
+ if (allowedTools && !allowedTools.has(req.params.name)) {
119
+ throw new McpError(ErrorCode.InvalidParams, `Unknown tool: ${req.params.name}`);
120
+ }
121
+ const reportsProgress = req.params.name === "platform_deploy_artifact_app" || req.params.name === "platform_wait_artifact_deploy";
103
122
  try {
104
- const result = await callHarnessTool(opts, req.params.name, req.params.arguments ?? {});
105
- if (reportsProgress)
106
- await sendProgress(extra, 1, 1, "Completed artifact deployment");
123
+ const result = await callHarnessTool(opts, req.params.name, req.params.arguments ?? {}, reportsProgress
124
+ ? { onProgress: (progress, message) => sendProgress(extra, progress, 1, message) }
125
+ : undefined);
107
126
  return result;
108
127
  }
109
128
  catch (err) {
@@ -111,15 +130,12 @@ export function createHarnessMcpStdioServer(opts) {
111
130
  throw err;
112
131
  if (reportsProgress)
113
132
  await sendProgress(extra, 1, 1, "Failed artifact deployment");
114
- return {
115
- content: [{ type: "text", text: redactSecrets(err instanceof Error ? err.message : String(err), opts) }],
116
- isError: true
117
- };
133
+ return structuredToolError(redactSecrets(err instanceof Error ? err.message : String(err), opts));
118
134
  }
119
135
  });
120
136
  return mcp;
121
137
  }
122
- export async function callHarnessTool(opts, name, args) {
138
+ export async function callHarnessTool(opts, name, args, hooks) {
123
139
  const input = isRecord(args) ? args : {};
124
140
  const tool = listTools().tools.find((candidate) => candidate.name === name);
125
141
  if (!tool)
@@ -129,8 +145,21 @@ export async function callHarnessTool(opts, name, args) {
129
145
  return toolError(`Invalid arguments for ${name}: ${inputError}`);
130
146
  let result;
131
147
  switch (name) {
148
+ case "platform_connection_check":
149
+ case "platform_list_build_workspaces":
150
+ case "platform_list_artifact_drafts":
151
+ case "platform_deploy_operation":
152
+ case "platform_validate_artifact":
153
+ case "platform_run_artifact_smoke":
154
+ case "platform_plan_artifact_draft_delete":
155
+ case "platform_delete_artifact_draft":
156
+ result = toolResult(await callHostedBuildTool(opts, name, input));
157
+ break;
132
158
  case "platform_deploy_artifact_app":
133
- result = toolResult(await deployArtifactApp(opts, input));
159
+ result = toolResult(await deployArtifactApp(opts, input, hooks?.onProgress));
160
+ break;
161
+ case "platform_inspect_artifact_app":
162
+ result = toolResult(await inspectArtifactApp(opts, input));
134
163
  break;
135
164
  case "platform_artifact_status":
136
165
  result = toolResult(await getArtifactStatus(opts, stringArg(input, "artifactId"), numberArg(input, "limit")));
@@ -144,6 +173,12 @@ export async function callHarnessTool(opts, name, args) {
144
173
  case "platform_storybook_access":
145
174
  result = toolResult(storybookAccess(opts));
146
175
  break;
176
+ case "platform_wait_artifact_deploy":
177
+ result = toolResult(await waitForArtifactDeploy(opts, input, hooks?.onProgress));
178
+ break;
179
+ case "platform_init_artifact_app":
180
+ result = toolResult(await initArtifactApp(opts, input));
181
+ break;
147
182
  default:
148
183
  throw new McpError(ErrorCode.InvalidParams, `Unknown tool: ${name}`);
149
184
  }
@@ -152,12 +187,24 @@ export async function callHarnessTool(opts, name, args) {
152
187
  : null;
153
188
  return outputError ? toolError(`Invalid output for ${name}: ${outputError}`) : result;
154
189
  }
155
- export async function deployArtifactApp(opts, input) {
190
+ export async function deployArtifactApp(opts, input, onProgress) {
191
+ await onProgress?.(0, "Reading artifact manifest");
156
192
  const appDir = stringArg(input, "appDir");
157
- const absoluteAppDir = resolveWorkspacePath(opts.workspaceRoot, appDir);
158
- const platformAppDir = workspaceRelativeAppDir(opts.workspaceRoot, absoluteAppDir);
193
+ const absoluteAppDir = await resolveWorkspacePathSafely(opts.workspaceRoot, appDir);
194
+ await assertAuthFileOutsideAppDir(opts.authFile, absoluteAppDir);
195
+ const workspaceAppDir = workspaceRelativeAppDir(opts.workspaceRoot, absoluteAppDir);
159
196
  const manifest = await readManifest(absoluteAppDir);
197
+ const platformAppDir = logicalArtifactAppDir(workspaceAppDir, manifest);
198
+ const clientRequestId = optionalString(input, "requestId") ?? createHash("sha256")
199
+ .update(JSON.stringify({ apiUrl: platformBaseUrl(opts), appDir: platformAppDir, manifestId: manifest.id ?? null }))
200
+ .digest("hex");
201
+ await onProgress?.(0.2, "Packaging local artifact app");
160
202
  const bundle = await packArtifactAppDirectory(absoluteAppDir, platformAppDir === "." ? "" : platformAppDir);
203
+ const expectedBundleSha256 = optionalString(input, "expectedBundleSha256")?.toLowerCase();
204
+ if (expectedBundleSha256 && bundle.sha256 !== expectedBundleSha256) {
205
+ throw new Error("artifact_app_bundle_changed_after_inspection");
206
+ }
207
+ await onProgress?.(0.4, "Creating private artifact draft");
161
208
  const created = await fetchJson(opts, artifactAppsPath(opts), {
162
209
  method: "POST",
163
210
  headers: await jsonHeaders(opts),
@@ -166,6 +213,8 @@ export async function deployArtifactApp(opts, input) {
166
213
  title: optionalString(input, "title"),
167
214
  description: optionalString(input, "description"),
168
215
  projectId: optionalString(input, "projectId"),
216
+ clientRequestId,
217
+ bundleSha256: bundle.sha256,
169
218
  manifest
170
219
  })
171
220
  });
@@ -175,6 +224,7 @@ export async function deployArtifactApp(opts, input) {
175
224
  const artifactId = stringValue(createdRecord.artifactId, "artifactId");
176
225
  const artifactVersionId = stringValue(createdRecord.artifactVersionId, "artifactVersionId");
177
226
  const bearerToken = await getBearerToken(opts);
227
+ await onProgress?.(0.6, "Uploading bundle and waiting for trusted draft deployment");
178
228
  const deployed = await fetchJson(opts, `${artifactAppsPath(opts)}/${encodeURIComponent(artifactId)}/deploy-bundle`, {
179
229
  method: "POST",
180
230
  headers: {
@@ -186,11 +236,16 @@ export async function deployArtifactApp(opts, input) {
186
236
  },
187
237
  body: bundle.body
188
238
  });
239
+ await onProgress?.(1, "Completed trusted draft deployment");
240
+ const deployedRecord = asRecord(deployed);
241
+ const operationId = typeof deployedRecord.taskId === "string" ? deployedRecord.taskId : null;
189
242
  return {
190
243
  ok: true,
191
244
  artifactId,
192
245
  artifactVersionId,
193
246
  manifestId: typeof manifest.id === "string" ? manifest.id : null,
247
+ clientRequestId,
248
+ operationId,
194
249
  bundle: {
195
250
  fileCount: bundle.fileCount,
196
251
  byteCount: bundle.byteCount,
@@ -201,6 +256,132 @@ export async function deployArtifactApp(opts, input) {
201
256
  deploy: deployed
202
257
  };
203
258
  }
259
+ export async function inspectArtifactApp(opts, input) {
260
+ const appDir = stringArg(input, "appDir");
261
+ const absoluteAppDir = await resolveWorkspacePathSafely(opts.workspaceRoot, appDir);
262
+ await assertAuthFileOutsideAppDir(opts.authFile, absoluteAppDir);
263
+ const workspaceAppDir = workspaceRelativeAppDir(opts.workspaceRoot, absoluteAppDir);
264
+ const manifest = await readManifest(absoluteAppDir);
265
+ const platformAppDir = logicalArtifactAppDir(workspaceAppDir, manifest);
266
+ const bundle = await packArtifactAppDirectory(absoluteAppDir, platformAppDir === "." ? "" : platformAppDir);
267
+ return {
268
+ ok: true,
269
+ packageReady: true,
270
+ serverManifestValidationRequired: true,
271
+ appDir: workspaceAppDir,
272
+ platformAppDir,
273
+ manifest: {
274
+ id: typeof manifest.id === "string" ? manifest.id : null,
275
+ manifestVersion: typeof manifest.manifestVersion === "number" ? manifest.manifestVersion : null,
276
+ title: typeof manifest.title === "string" ? manifest.title : null,
277
+ kind: typeof manifest.kind === "string" ? manifest.kind : null
278
+ },
279
+ bundle: {
280
+ fileCount: bundle.fileCount,
281
+ byteCount: bundle.byteCount,
282
+ sha256: bundle.sha256,
283
+ excluded: bundle.excluded
284
+ },
285
+ nextAction: "Call platform_deploy_artifact_app with the same appDir and expectedBundleSha256 set to bundle.sha256 when this local package is ready to deploy."
286
+ };
287
+ }
288
+ async function callHostedBuildTool(opts, name, input) {
289
+ if (mcpSurface(opts) !== "build")
290
+ throw new Error(`${name}_requires_build_surface`);
291
+ const headers = await jsonHeaders(opts);
292
+ const payload = asRecord(await fetchJson(opts, `${platformBaseUrl(opts)}/mcp/build`, {
293
+ method: "POST",
294
+ headers: { ...headers, accept: "application/json, text/event-stream" },
295
+ body: JSON.stringify({
296
+ jsonrpc: "2.0",
297
+ id: `stdio-${Date.now()}`,
298
+ method: "tools/call",
299
+ params: { name, arguments: input }
300
+ })
301
+ }));
302
+ if (payload.error) {
303
+ const error = asRecord(payload.error);
304
+ throw new Error(`platform_mcp_tool_error:${String(error.message ?? "unknown_error")}`);
305
+ }
306
+ const result = asRecord(payload.result);
307
+ if (result.isError === true) {
308
+ const content = Array.isArray(result.content) ? result.content.filter(isRecord) : [];
309
+ const message = content.find((item) => item.type === "text" && typeof item.text === "string")?.text;
310
+ throw new Error(`platform_mcp_tool_error:${String(message ?? "tool_failed")}`);
311
+ }
312
+ if (isRecord(result.structuredContent))
313
+ return result.structuredContent;
314
+ throw new Error(`platform_mcp_tool_output_missing:${name}`);
315
+ }
316
+ export async function waitForArtifactDeploy(opts, input, onProgress) {
317
+ const artifactId = stringArg(input, "artifactId");
318
+ const operationId = stringArg(input, "operationId");
319
+ const timeoutSeconds = Math.min(Math.max(numberArg(input, "timeoutSeconds") ?? 300, 1), 900);
320
+ const deadline = Date.now() + timeoutSeconds * 1000;
321
+ let afterSeq = 0;
322
+ let latest = {};
323
+ while (Date.now() < deadline) {
324
+ latest = asRecord(await callHostedBuildTool(opts, "platform_deploy_operation", { artifactId, operationId, afterSeq }));
325
+ const progress = typeof latest.progress === "number" ? latest.progress : 0.7;
326
+ const events = Array.isArray(latest.events) ? latest.events.filter(isRecord) : [];
327
+ const eventMessage = [...events].reverse().find((event) => typeof event.message === "string")?.message;
328
+ await onProgress?.(progress, typeof eventMessage === "string" ? eventMessage : `Artifact deployment: ${String(latest.phase ?? latest.taskStatus ?? "running")}`);
329
+ if (typeof latest.nextSeq === "number")
330
+ afterSeq = latest.nextSeq;
331
+ if (latest.terminal === true) {
332
+ if (latest.successful !== true)
333
+ throw new Error(`artifact_deploy_failed:${String(latest.taskStatus ?? "failed")}`);
334
+ return latest;
335
+ }
336
+ const waitMs = typeof latest.nextPollAfterMs === "number" ? Math.min(Math.max(latest.nextPollAfterMs, 250), 10_000) : 2_000;
337
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
338
+ }
339
+ throw new Error(`artifact_deploy_wait_timeout:${operationId}`);
340
+ }
341
+ export async function initArtifactApp(opts, input) {
342
+ const appDir = stringArg(input, "appDir");
343
+ const title = optionalString(input, "title") ?? "Topline artifact";
344
+ const manifestId = (optionalString(input, "manifestId") ?? path.basename(appDir))
345
+ .toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
346
+ if (!manifestId || manifestId.length > 80)
347
+ throw new Error("artifact_manifest_id_invalid");
348
+ const absoluteAppDir = await resolveWorkspacePathSafely(opts.workspaceRoot, appDir);
349
+ if (existsSync(absoluteAppDir) && (await readdir(absoluteAppDir)).length > 0)
350
+ throw new Error("artifact_app_scaffold_target_not_empty");
351
+ await mkdir(path.join(absoluteAppDir, "src"), { recursive: true });
352
+ const manifest = {
353
+ manifestVersion: 2,
354
+ id: manifestId,
355
+ title,
356
+ kind: "tool",
357
+ runtime: { target: "aws_lambda_function_url", region: "us-west-2", entryPoint: "artifactApp" },
358
+ build: { command: "node src/index.js --build", outputDir: "dist" },
359
+ validation: { localCommand: "node src/index.js --test", testCommand: "node src/index.js --test --artifact-tests-json" },
360
+ routes: [
361
+ { path: "/", kind: "page", handler: "artifactApp" },
362
+ { path: "/_artifact/health", kind: "api", handler: "artifactApp" }
363
+ ],
364
+ sourceBindings: [],
365
+ env: ["ARTIFACT_ID", "ARTIFACT_VERSION_ID", "ARTIFACT_MANIFEST_URI", "PLATFORM_ARTIFACT_ID", "PLATFORM_ARTIFACT_VERSION_ID", "PLATFORM_ARTIFACT_MANIFEST_URI"],
366
+ viewerPolicy: "private"
367
+ };
368
+ const packageJson = { name: manifestId, private: true, type: "module", main: "src/index.js", scripts: { build: "node src/index.js --build", test: "node src/index.js --test" } };
369
+ const safeTitle = title.replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
370
+ const source = `import { mkdirSync, writeFileSync } from "node:fs";\nimport path from "node:path";\n\nconst page = ${JSON.stringify(`<!doctype html><html><body><main data-platform-artifact-root><h1>${safeTitle}</h1><p>Ready.</p></main></body></html>`)};\n\nexport function artifactApp(_req, res) {\n res.setHeader("content-type", "text/html; charset=utf-8");\n res.end(page);\n}\n\nif (process.argv.includes("--build")) {\n mkdirSync("dist", { recursive: true });\n writeFileSync(path.join("dist", "index.html"), page, "utf8");\n}\n\nif (process.argv.includes("--test") && process.argv.includes("--artifact-tests-json")) {\n console.log(JSON.stringify({ tests: [{ description: "The artifact renders a visible app root.", required: true, status: "passed", summary: "The page includes data-platform-artifact-root.", evidence: { selector: "data-platform-artifact-root" } }] }));\n} else if (process.argv.includes("--build") || process.argv.includes("--test")) {\n console.log("artifact app template ok");\n}\n`;
371
+ await Promise.all([
372
+ writeFile(path.join(absoluteAppDir, "artifact-app.json"), `${JSON.stringify(manifest, null, 2)}\n`, { flag: "wx" }),
373
+ writeFile(path.join(absoluteAppDir, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`, { flag: "wx" }),
374
+ writeFile(path.join(absoluteAppDir, "src", "index.js"), source, { flag: "wx" })
375
+ ]);
376
+ return {
377
+ ok: true,
378
+ appDir: workspaceRelativeAppDir(opts.workspaceRoot, absoluteAppDir),
379
+ manifestId,
380
+ title,
381
+ files: ["artifact-app.json", "package.json", "src/index.js"],
382
+ nextAction: `Run npm test in ${appDir}, then call platform_inspect_artifact_app with the same appDir before deployment.`
383
+ };
384
+ }
204
385
  export async function getArtifactStatus(opts, artifactId, limit) {
205
386
  const query = new URLSearchParams();
206
387
  if (limit)
@@ -289,6 +470,8 @@ export function storybookAccess(opts) {
289
470
  }
290
471
  export async function packArtifactAppDirectory(appDir, archivePrefix = "") {
291
472
  const root = path.resolve(appDir);
473
+ if ((await lstat(root)).isSymbolicLink())
474
+ throw new Error("artifact_app_bundle_symlink_forbidden:.");
292
475
  const prefix = archivePrefix ? validateArchivePrefix(archivePrefix) : "";
293
476
  const files = [];
294
477
  const excluded = [];
@@ -297,11 +480,13 @@ export async function packArtifactAppDirectory(appDir, archivePrefix = "") {
297
480
  throw new Error("artifact_app_bundle_empty");
298
481
  let byteCount = 0;
299
482
  const chunks = [];
300
- for (const file of files.sort((a, b) => a.relative.localeCompare(b.relative))) {
301
- byteCount += file.size;
302
- if (byteCount > MAX_UNPACKED_BYTES)
483
+ for (const file of files.sort((a, b) => compareUtf8Paths(a.relative, b.relative))) {
484
+ if (byteCount + file.size > MAX_UNPACKED_BYTES)
303
485
  throw new Error(`artifact_app_bundle_unpacked_too_large:${MAX_UNPACKED_BYTES}`);
304
486
  const content = await readFile(file.absolute);
487
+ byteCount += content.length;
488
+ if (byteCount > MAX_UNPACKED_BYTES)
489
+ throw new Error(`artifact_app_bundle_unpacked_too_large:${MAX_UNPACKED_BYTES}`);
305
490
  chunks.push(tarHeader(prefix ? `${prefix}/${file.relative}` : file.relative, content.length));
306
491
  chunks.push(content);
307
492
  chunks.push(Buffer.alloc(padSize(content.length)));
@@ -315,9 +500,12 @@ export async function packArtifactAppDirectory(appDir, archivePrefix = "") {
315
500
  fileCount: files.length,
316
501
  byteCount,
317
502
  sha256: createHash("sha256").update(body).digest("hex"),
318
- excluded
503
+ excluded: excluded.sort(compareUtf8Paths)
319
504
  };
320
505
  }
506
+ function compareUtf8Paths(left, right) {
507
+ return Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8"));
508
+ }
321
509
  async function walk(root, current, files, excluded) {
322
510
  const entries = await readdir(current, { withFileTypes: true });
323
511
  for (const entry of entries) {
@@ -335,6 +523,8 @@ async function walk(root, current, files, excluded) {
335
523
  await walk(root, absolute, files, excluded);
336
524
  }
337
525
  else if (entry.isFile()) {
526
+ if (stat.nlink > 1)
527
+ throw new Error(`artifact_app_bundle_hardlink_forbidden:${relative}`);
338
528
  files.push({ absolute, relative, size: stat.size });
339
529
  if (files.length > MAX_FILES)
340
530
  throw new Error(`artifact_app_bundle_file_limit:${MAX_FILES}`);
@@ -343,7 +533,32 @@ async function walk(root, current, files, excluded) {
343
533
  }
344
534
  async function readManifest(appDir) {
345
535
  const manifestPath = path.join(appDir, "artifact-app.json");
346
- const parsed = JSON.parse(await readFile(manifestPath, "utf8"));
536
+ let stat;
537
+ try {
538
+ stat = await lstat(manifestPath);
539
+ }
540
+ catch (error) {
541
+ if (error.code === "ENOENT")
542
+ throw new Error("artifact_app_manifest_not_found");
543
+ throw error;
544
+ }
545
+ if (stat.isSymbolicLink()) {
546
+ throw new Error("artifact_app_bundle_symlink_forbidden:artifact-app.json");
547
+ }
548
+ if (!stat.isFile())
549
+ throw new Error("artifact_app_manifest_must_be_file");
550
+ if (stat.size > MAX_MANIFEST_BYTES)
551
+ throw new Error(`artifact_app_manifest_too_large:${MAX_MANIFEST_BYTES}`);
552
+ const source = await readFile(manifestPath, "utf8");
553
+ if (Buffer.byteLength(source) > MAX_MANIFEST_BYTES)
554
+ throw new Error(`artifact_app_manifest_too_large:${MAX_MANIFEST_BYTES}`);
555
+ let parsed;
556
+ try {
557
+ parsed = JSON.parse(source);
558
+ }
559
+ catch {
560
+ throw new Error("artifact_app_manifest_invalid_json");
561
+ }
347
562
  if (!isRecord(parsed))
348
563
  throw new Error("artifact_app_manifest_must_be_object");
349
564
  return parsed;
@@ -351,10 +566,61 @@ async function readManifest(appDir) {
351
566
  function resolveWorkspacePath(workspaceRoot, inputPath) {
352
567
  const resolved = path.resolve(workspaceRoot, inputPath);
353
568
  const relative = path.relative(workspaceRoot, resolved);
354
- if (relative.startsWith("..") || path.isAbsolute(relative))
569
+ if (pathEscapesRoot(relative))
355
570
  throw new Error("appDir_must_be_inside_workspace_root");
356
571
  return resolved;
357
572
  }
573
+ async function resolveWorkspacePathSafely(workspaceRoot, inputPath) {
574
+ const root = path.resolve(workspaceRoot);
575
+ const resolved = resolveWorkspacePath(root, inputPath);
576
+ const rootRealPath = await realpath(root);
577
+ let existingPath = root;
578
+ let candidatePath = root;
579
+ const relative = path.relative(root, resolved);
580
+ for (const segment of relative.split(path.sep).filter(Boolean)) {
581
+ candidatePath = path.join(candidatePath, segment);
582
+ try {
583
+ const stat = await lstat(candidatePath);
584
+ if (stat.isSymbolicLink()) {
585
+ throw new Error(`appDir_symlink_forbidden:${toPosix(path.relative(root, candidatePath))}`);
586
+ }
587
+ existingPath = candidatePath;
588
+ }
589
+ catch (error) {
590
+ if (error.code === "ENOENT")
591
+ break;
592
+ throw error;
593
+ }
594
+ }
595
+ const existingRealPath = await realpath(existingPath);
596
+ const realRelative = path.relative(rootRealPath, existingRealPath);
597
+ if (pathEscapesRoot(realRelative)) {
598
+ throw new Error("appDir_must_resolve_inside_workspace_root");
599
+ }
600
+ return resolved;
601
+ }
602
+ function pathEscapesRoot(relativePath) {
603
+ return relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath);
604
+ }
605
+ async function assertAuthFileOutsideAppDir(authFile, appDir) {
606
+ const relative = path.relative(path.resolve(appDir), path.resolve(authFile));
607
+ if (!pathEscapesRoot(relative))
608
+ throw new Error("artifact_app_auth_file_inside_app_dir");
609
+ let realAuthFile;
610
+ let realAppDir;
611
+ try {
612
+ realAuthFile = await realpath(authFile);
613
+ realAppDir = await realpath(appDir);
614
+ }
615
+ catch (error) {
616
+ if (error.code === "ENOENT")
617
+ return;
618
+ throw error;
619
+ }
620
+ const realRelative = path.relative(realAppDir, realAuthFile);
621
+ if (!pathEscapesRoot(realRelative))
622
+ throw new Error("artifact_app_auth_file_inside_app_dir");
623
+ }
358
624
  function workspaceRelativeAppDir(workspaceRoot, absoluteAppDir) {
359
625
  const relative = toPosix(path.relative(path.resolve(workspaceRoot), path.resolve(absoluteAppDir)));
360
626
  return relative ? validateArchivePrefix(relative) : ".";
@@ -392,7 +658,9 @@ function tarHeader(name, size) {
392
658
  writeOctal(header, 108, 8, 0);
393
659
  writeOctal(header, 116, 8, 0);
394
660
  writeOctal(header, 124, 12, size);
395
- writeOctal(header, 136, 12, Math.floor(Date.now() / 1000));
661
+ // Content-identical retries must produce the same bundle hash. A wall-clock
662
+ // mtime would make every archive look new even when no source file changed.
663
+ writeOctal(header, 136, 12, 0);
396
664
  header.fill(0x20, 148, 156);
397
665
  header.write("0", 156, 1, "utf8");
398
666
  header.write("ustar", 257, 5, "utf8");
@@ -444,6 +712,10 @@ async function fetchJson(opts, url, init) {
444
712
  payload = { detail: text.slice(0, 500) };
445
713
  }
446
714
  if (!response.ok) {
715
+ const code = isRecord(payload) && typeof payload.error === "string" ? payload.error : null;
716
+ if (code === "artifact_source_git_app_dir_mismatch") {
717
+ throw new Error("artifact_source_git_app_dir_mismatch: Use the same local app/source root as the existing artifact, or deploy without artifactId to create a new draft.");
718
+ }
447
719
  throw new Error(`platform_harness_http_error:${response.status}:${redactSecrets(JSON.stringify(payload), opts)}`);
448
720
  }
449
721
  return payload;
@@ -457,127 +729,312 @@ async function fetchJson(opts, url, init) {
457
729
  clearTimeout(timeout);
458
730
  }
459
731
  }
460
- export function listTools() {
461
- return {
462
- tools: [
463
- {
464
- name: "platform_deploy_artifact_app",
465
- description: "Package a local artifact app directory, upload it, deploy a private draft, and return status.",
466
- annotations: mcpToolAnnotations("platform_deploy_artifact_app", { readOnly: false, openWorld: true }),
467
- inputSchema: {
468
- type: "object",
469
- properties: {
470
- appDir: { type: "string", minLength: 1 },
471
- artifactId: { type: "string" },
472
- title: { type: "string" },
473
- description: { type: "string" },
474
- projectId: { type: "string" }
475
- },
476
- required: ["appDir"],
477
- additionalProperties: false
478
- },
479
- outputSchema: {
480
- type: "object",
481
- properties: {
482
- ok: { type: "boolean" },
483
- artifactId: { type: "string" },
484
- artifactVersionId: { type: "string" },
485
- bundle: { type: "object" },
486
- create: { type: "object" },
487
- deploy: { type: "object" }
488
- },
489
- required: ["ok", "artifactId", "artifactVersionId", "bundle", "create", "deploy"]
490
- }
732
+ const BUILD_MCP_TOOLS = [
733
+ {
734
+ name: "platform_connection_check",
735
+ description: "Complete the one-time Developer Access verification challenge for this Build MCP client.",
736
+ annotations: mcpToolAnnotations("platform_connection_check", { readOnly: false, openWorld: true }),
737
+ inputSchema: {
738
+ type: "object",
739
+ properties: {
740
+ verificationCode: { type: "string", minLength: 16, maxLength: 80 }
491
741
  },
492
- {
493
- name: "platform_artifact_status",
494
- description: "Read private draft deployment status for a hosted artifact app.",
495
- annotations: mcpToolAnnotations("platform_artifact_status", { readOnly: true, openWorld: true }),
496
- inputSchema: {
497
- type: "object",
498
- properties: { artifactId: { type: "string", minLength: 1 }, limit: { type: "integer", minimum: 1, maximum: 50 } },
499
- required: ["artifactId"],
500
- additionalProperties: false
501
- },
502
- outputSchema: {
503
- type: "object",
504
- properties: {
505
- ok: { type: "boolean" }, phase: { type: "string" }, artifactId: { type: "string" },
506
- deployments: { type: "array", items: { type: "object" } }
507
- },
508
- required: ["ok", "phase", "artifactId", "deployments"]
509
- }
742
+ required: ["verificationCode"],
743
+ additionalProperties: false
744
+ },
745
+ outputSchema: {
746
+ type: "object",
747
+ properties: {
748
+ ok: { type: "boolean" }, verified: { type: "boolean" }, product: { type: "string", enum: ["build"] }, verifiedAt: { type: "string" }
510
749
  },
511
- {
512
- name: "platform_read_artifact_logs",
513
- description: "Read bounded, redacted deployment logs for a hosted artifact app.",
514
- annotations: mcpToolAnnotations("platform_read_artifact_logs", { readOnly: true, openWorld: true }),
515
- inputSchema: {
516
- type: "object",
517
- properties: {
518
- artifactId: { type: "string", minLength: 1 },
519
- deploymentRecordId: { type: "string" },
520
- environment: { type: "string", enum: ["draft", "live"] },
521
- limit: { type: "integer", minimum: 1, maximum: 200 },
522
- severity: { type: "string" },
523
- contains: { type: "string" },
524
- since: { type: "string", format: "date-time" },
525
- windowMinutes: { type: "integer", minimum: 1, maximum: 1440 },
526
- trace: { type: "string" },
527
- requestId: { type: "string" },
528
- route: { type: "string" }
529
- },
530
- required: ["artifactId"],
531
- additionalProperties: false
532
- },
533
- outputSchema: {
534
- type: "object",
535
- properties: {
536
- ok: { type: "boolean" },
537
- phase: { type: "string" },
538
- artifactId: { type: "string" },
539
- entries: { type: "array", items: { type: "object" } }
540
- },
541
- required: ["ok", "phase", "artifactId"]
542
- }
750
+ required: ["ok", "verified", "product", "verifiedAt"]
751
+ }
752
+ },
753
+ {
754
+ name: "platform_init_artifact_app",
755
+ description: "Create a minimal, validated artifact app starter inside the configured local workspace.",
756
+ annotations: mcpToolAnnotations("platform_init_artifact_app", { readOnly: false }),
757
+ inputSchema: {
758
+ type: "object",
759
+ properties: { appDir: { type: "string", minLength: 1 }, manifestId: { type: "string" }, title: { type: "string" } },
760
+ required: ["appDir"],
761
+ additionalProperties: false
762
+ },
763
+ outputSchema: {
764
+ type: "object",
765
+ properties: { ok: { type: "boolean" }, appDir: { type: "string" }, manifestId: { type: "string" }, title: { type: "string" }, files: { type: "array", items: { type: "string" } }, nextAction: { type: "string" } },
766
+ required: ["ok", "appDir", "manifestId", "title", "files", "nextAction"]
767
+ }
768
+ },
769
+ {
770
+ name: "platform_deploy_artifact_app",
771
+ description: "Package a local artifact app directory, upload it, deploy a private draft, and return status.",
772
+ annotations: mcpToolAnnotations("platform_deploy_artifact_app", { readOnly: false, openWorld: true }),
773
+ inputSchema: {
774
+ type: "object",
775
+ properties: {
776
+ appDir: { type: "string", minLength: 1 },
777
+ artifactId: { type: "string", format: "uuid" },
778
+ title: { type: "string" },
779
+ description: { type: "string" },
780
+ projectId: { type: "string", format: "uuid" },
781
+ 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." }
543
783
  },
544
- {
545
- name: "platform_open_artifact",
546
- description: "Return the operator URL and latest draft/live URLs for a hosted artifact app.",
547
- annotations: mcpToolAnnotations("platform_open_artifact", { readOnly: true, openWorld: true }),
548
- inputSchema: {
549
- type: "object",
550
- properties: { artifactId: { type: "string", minLength: 1 } },
551
- required: ["artifactId"],
552
- additionalProperties: false
553
- },
554
- outputSchema: {
555
- type: "object",
556
- properties: {
557
- ok: { type: "boolean" }, artifactId: { type: "string" }, openUrl: { type: "string" },
558
- draftUrl: { type: ["string", "null"] }, liveUrl: { type: ["string", "null"] }, versionUrl: { type: ["string", "null"] }
559
- },
560
- required: ["ok", "artifactId", "openUrl", "draftUrl", "liveUrl", "versionUrl"]
561
- }
784
+ required: ["appDir"],
785
+ additionalProperties: false
786
+ },
787
+ outputSchema: {
788
+ type: "object",
789
+ properties: {
790
+ ok: { type: "boolean" },
791
+ artifactId: { type: "string" },
792
+ artifactVersionId: { type: "string" },
793
+ clientRequestId: { type: "string" },
794
+ operationId: { type: ["string", "null"] },
795
+ bundle: { type: "object" },
796
+ create: { type: "object" },
797
+ deploy: { type: "object" }
562
798
  },
563
- {
564
- name: "platform_storybook_access",
565
- description: "Return the tenant Storybook preview URL and HTTP MCP server config.",
566
- annotations: mcpToolAnnotations("platform_storybook_access", { readOnly: true }),
567
- inputSchema: {
568
- type: "object",
569
- properties: {},
570
- additionalProperties: false
571
- },
572
- outputSchema: {
799
+ required: ["ok", "artifactId", "artifactVersionId", "clientRequestId", "operationId", "bundle", "create", "deploy"]
800
+ }
801
+ },
802
+ {
803
+ name: "platform_inspect_artifact_app",
804
+ description: "Inspect local artifact paths and package boundaries without authenticating, uploading, or creating a remote draft.",
805
+ annotations: mcpToolAnnotations("platform_inspect_artifact_app", { readOnly: true }),
806
+ inputSchema: {
807
+ type: "object",
808
+ properties: { appDir: { type: "string", minLength: 1 } },
809
+ required: ["appDir"],
810
+ additionalProperties: false
811
+ },
812
+ outputSchema: {
813
+ type: "object",
814
+ properties: {
815
+ ok: { type: "boolean" },
816
+ packageReady: { type: "boolean" },
817
+ serverManifestValidationRequired: { type: "boolean" },
818
+ appDir: { type: "string" },
819
+ platformAppDir: { type: "string" },
820
+ manifest: { type: "object" },
821
+ bundle: { type: "object" },
822
+ nextAction: { type: "string" }
823
+ },
824
+ required: ["ok", "packageReady", "serverManifestValidationRequired", "appDir", "platformAppDir", "manifest", "bundle", "nextAction"]
825
+ }
826
+ },
827
+ {
828
+ name: "platform_list_build_workspaces",
829
+ description: "List Topline workspaces available for artifact placement.",
830
+ annotations: mcpToolAnnotations("platform_list_build_workspaces", { readOnly: true, openWorld: true }),
831
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
832
+ outputSchema: { type: "object", properties: { ok: { type: "boolean" }, workspaces: { type: "array", items: { type: "object" } } }, required: ["ok", "workspaces"] }
833
+ },
834
+ {
835
+ name: "platform_list_artifact_drafts",
836
+ description: "Discover private artifact drafts created through this Build MCP connection.",
837
+ annotations: mcpToolAnnotations("platform_list_artifact_drafts", { readOnly: true, openWorld: true }),
838
+ inputSchema: { type: "object", properties: { projectId: { type: "string", format: "uuid" }, limit: { type: "integer", minimum: 1, maximum: 100 } }, additionalProperties: false },
839
+ outputSchema: { type: "object", properties: { ok: { type: "boolean" }, drafts: { type: "array", items: { type: "object" } } }, required: ["ok", "drafts"] }
840
+ },
841
+ {
842
+ name: "platform_deploy_operation",
843
+ description: "Read durable server-side progress for one artifact deployment operation.",
844
+ annotations: mcpToolAnnotations("platform_deploy_operation", { readOnly: true, openWorld: true }),
845
+ inputSchema: { type: "object", properties: { artifactId: { type: "string", format: "uuid" }, operationId: { type: "string", format: "uuid" }, afterSeq: { type: "integer", minimum: 0 } }, required: ["artifactId", "operationId"], additionalProperties: false },
846
+ outputSchema: { type: "object", properties: { ok: { type: "boolean" }, phase: { type: "string" }, artifactId: { type: "string" }, operationId: { type: "string" }, taskStatus: { type: "string" }, progress: { type: "number" }, terminal: { type: "boolean" }, successful: { type: "boolean" }, events: { type: "array", items: { type: "object" } } }, required: ["ok", "phase", "artifactId", "operationId", "taskStatus", "progress", "terminal", "successful", "events"] }
847
+ },
848
+ {
849
+ name: "platform_wait_artifact_deploy",
850
+ description: "Wait for a durable deployment operation and stream server-derived progress.",
851
+ annotations: mcpToolAnnotations("platform_wait_artifact_deploy", { readOnly: true, openWorld: true }),
852
+ inputSchema: { type: "object", properties: { artifactId: { type: "string", format: "uuid" }, operationId: { type: "string", format: "uuid" }, timeoutSeconds: { type: "integer", minimum: 1, maximum: 900 } }, required: ["artifactId", "operationId"], additionalProperties: false },
853
+ outputSchema: { type: "object", properties: { ok: { type: "boolean" }, phase: { type: "string" }, artifactId: { type: "string" }, operationId: { type: "string" }, taskStatus: { type: "string" }, progress: { type: "number" }, terminal: { type: "boolean" }, successful: { type: "boolean" }, events: { type: "array", items: { type: "object" } } }, required: ["ok", "phase", "artifactId", "operationId", "taskStatus", "progress", "terminal", "successful", "events"] }
854
+ },
855
+ {
856
+ name: "platform_artifact_status",
857
+ description: "Read private draft deployment status for a hosted artifact app.",
858
+ annotations: mcpToolAnnotations("platform_artifact_status", { readOnly: true, openWorld: true }),
859
+ inputSchema: {
860
+ type: "object",
861
+ properties: { artifactId: { type: "string", format: "uuid" }, limit: { type: "integer", minimum: 1, maximum: 50 } },
862
+ required: ["artifactId"],
863
+ additionalProperties: false
864
+ },
865
+ outputSchema: {
866
+ type: "object",
867
+ properties: {
868
+ ok: { type: "boolean" }, phase: { type: "string" }, artifactId: { type: "string" },
869
+ deployments: { type: "array", items: { type: "object" } }
870
+ },
871
+ required: ["ok", "phase", "artifactId", "deployments"]
872
+ }
873
+ },
874
+ {
875
+ name: "platform_read_artifact_logs",
876
+ description: "Read bounded, redacted deployment logs for a hosted artifact app.",
877
+ annotations: mcpToolAnnotations("platform_read_artifact_logs", { readOnly: true, openWorld: true }),
878
+ inputSchema: {
879
+ type: "object",
880
+ properties: {
881
+ artifactId: { type: "string", format: "uuid" },
882
+ deploymentRecordId: { type: "string", format: "uuid" },
883
+ environment: { type: "string", enum: ["draft", "live"] },
884
+ limit: { type: "integer", minimum: 1, maximum: 200 },
885
+ severity: { type: "string" },
886
+ contains: { type: "string" },
887
+ since: { type: "string", format: "date-time" },
888
+ windowMinutes: { type: "integer", minimum: 1, maximum: 1440 },
889
+ trace: { type: "string" },
890
+ requestId: { type: "string" },
891
+ route: { type: "string" }
892
+ },
893
+ required: ["artifactId"],
894
+ additionalProperties: false
895
+ },
896
+ outputSchema: {
897
+ type: "object",
898
+ properties: {
899
+ ok: { type: "boolean" },
900
+ phase: { type: "string" },
901
+ artifactId: { type: "string" },
902
+ entries: { type: "array", items: { type: "object" } }
903
+ },
904
+ required: ["ok", "phase", "artifactId"]
905
+ }
906
+ },
907
+ {
908
+ name: "platform_open_artifact",
909
+ description: "Return the operator URL and latest draft/live URLs for a hosted artifact app.",
910
+ annotations: mcpToolAnnotations("platform_open_artifact", { readOnly: true, openWorld: true }),
911
+ inputSchema: {
912
+ type: "object",
913
+ properties: { artifactId: { type: "string", format: "uuid" } },
914
+ required: ["artifactId"],
915
+ additionalProperties: false
916
+ },
917
+ outputSchema: {
918
+ type: "object",
919
+ properties: {
920
+ ok: { type: "boolean" }, artifactId: { type: "string" }, openUrl: { type: "string" },
921
+ draftUrl: { type: ["string", "null"] }, liveUrl: { type: ["string", "null"] }, versionUrl: { type: ["string", "null"] }
922
+ },
923
+ required: ["ok", "artifactId", "openUrl", "draftUrl", "liveUrl", "versionUrl"]
924
+ }
925
+ },
926
+ {
927
+ name: "platform_storybook_access",
928
+ description: "Return the tenant Storybook preview URL and HTTP MCP server config.",
929
+ annotations: mcpToolAnnotations("platform_storybook_access", { readOnly: true }),
930
+ inputSchema: {
931
+ type: "object",
932
+ properties: {},
933
+ additionalProperties: false
934
+ },
935
+ outputSchema: {
936
+ type: "object",
937
+ properties: {
938
+ ok: { type: "boolean" }, storybookUrl: { type: "string" }, mcpUrl: { type: "string" }, mcpServers: { type: "object" }
939
+ },
940
+ required: ["ok", "storybookUrl", "mcpUrl", "mcpServers"]
941
+ }
942
+ },
943
+ {
944
+ name: "platform_validate_artifact",
945
+ description: "Read trusted smoke and health evidence for a private artifact draft without requiring a browser session.",
946
+ annotations: mcpToolAnnotations("platform_validate_artifact", { readOnly: true }),
947
+ inputSchema: {
948
+ type: "object",
949
+ properties: {
950
+ artifactId: { type: "string", format: "uuid" },
951
+ deploymentRecordId: { type: "string", format: "uuid" }
952
+ },
953
+ required: ["artifactId"],
954
+ additionalProperties: false
955
+ },
956
+ outputSchema: {
957
+ type: "object",
958
+ properties: {
959
+ ok: { type: "boolean" }, phase: { type: "string" }, artifactId: { type: "string" },
960
+ deploymentRecordId: { type: "string" }, deploymentState: { type: "string" },
961
+ healthy: { type: "boolean" }, checkedAt: { type: ["string", "null"] },
962
+ draftUrl: { type: ["string", "null"] }, smoke: { type: ["object", "null"] }
963
+ },
964
+ required: ["ok", "phase", "artifactId", "deploymentRecordId", "deploymentState", "healthy", "checkedAt", "draftUrl", "smoke"]
965
+ }
966
+ },
967
+ {
968
+ name: "platform_run_artifact_smoke",
969
+ description: "Rerun the trusted draft smoke check and return fresh rendered-page evidence.",
970
+ annotations: mcpToolAnnotations("platform_run_artifact_smoke", { readOnly: false, openWorld: true }),
971
+ inputSchema: { type: "object", properties: { artifactId: { type: "string", format: "uuid" }, deploymentRecordId: { type: "string", format: "uuid" } }, required: ["artifactId"], additionalProperties: false },
972
+ outputSchema: { type: "object", properties: { ok: { type: "boolean" }, phase: { type: "string" }, artifactId: { type: "string" }, deploymentRecordId: { type: "string" }, smoke: { type: "object" }, validation: { type: "object" } }, required: ["ok", "phase", "artifactId", "deploymentRecordId", "smoke", "validation"] }
973
+ },
974
+ {
975
+ name: "platform_plan_artifact_draft_delete",
976
+ description: "Preview cleanup effects and receive the state-bound plan token required for deletion.",
977
+ annotations: mcpToolAnnotations("platform_plan_artifact_draft_delete", { readOnly: true, openWorld: true }),
978
+ inputSchema: { type: "object", properties: { artifactId: { type: "string", format: "uuid" } }, required: ["artifactId"], additionalProperties: false },
979
+ outputSchema: { type: "object", properties: { ok: { type: "boolean" }, phase: { type: "string" }, artifactId: { type: "string" }, eligible: { type: "boolean" }, effects: { type: "object" }, planToken: { type: "string" } }, required: ["ok", "phase", "artifactId", "eligible", "effects", "planToken"] }
980
+ },
981
+ {
982
+ name: "platform_delete_artifact_draft",
983
+ description: "Permanently remove an unpromoted private draft created by this MCP connection after exact-id confirmation.",
984
+ annotations: mcpToolAnnotations("platform_delete_artifact_draft", { readOnly: false, destructive: true, openWorld: true }),
985
+ inputSchema: {
986
+ type: "object",
987
+ properties: {
988
+ artifactId: { type: "string", format: "uuid" },
989
+ confirmArtifactId: { type: "string", format: "uuid" },
990
+ planToken: { type: "string", minLength: 1 }
991
+ },
992
+ required: ["artifactId", "confirmArtifactId", "planToken"],
993
+ additionalProperties: false
994
+ },
995
+ outputSchema: {
996
+ type: "object",
997
+ properties: { ok: { type: "boolean" }, deleted: { type: "boolean" }, artifactId: { type: "string" } },
998
+ required: ["ok", "deleted", "artifactId"]
999
+ }
1000
+ }
1001
+ ].map(withRecoveryOutputSchema);
1002
+ export function listTools() {
1003
+ // Preserve the original tools/list isolation boundary: callers must not be
1004
+ // able to mutate the registry used by later requests or input validation.
1005
+ return { tools: structuredClone(BUILD_MCP_TOOLS) };
1006
+ }
1007
+ function withRecoveryOutputSchema(value) {
1008
+ const tool = value;
1009
+ if (!tool.outputSchema)
1010
+ return tool;
1011
+ return {
1012
+ ...tool,
1013
+ outputSchema: {
1014
+ type: "object",
1015
+ oneOf: [
1016
+ tool.outputSchema,
1017
+ {
573
1018
  type: "object",
574
1019
  properties: {
575
- ok: { type: "boolean" }, storybookUrl: { type: "string" }, mcpUrl: { type: "string" }, mcpServers: { type: "object" }
1020
+ ok: { const: false },
1021
+ error: {
1022
+ type: "object",
1023
+ properties: {
1024
+ code: { type: "string" },
1025
+ message: { type: "string" },
1026
+ retryable: { type: "boolean" },
1027
+ recoveryAction: { type: "string" }
1028
+ },
1029
+ required: ["code", "message", "retryable", "recoveryAction"],
1030
+ additionalProperties: false
1031
+ }
576
1032
  },
577
- required: ["ok", "storybookUrl", "mcpUrl", "mcpServers"]
1033
+ required: ["ok", "error"],
1034
+ additionalProperties: false
578
1035
  }
579
- }
580
- ]
1036
+ ]
1037
+ }
581
1038
  };
582
1039
  }
583
1040
  function mcpToolAnnotations(name, policy) {
@@ -603,7 +1060,45 @@ function toolResult(value) {
603
1060
  return { content, structuredContent };
604
1061
  }
605
1062
  function toolError(message) {
606
- return { content: [{ type: "text", text: message }], isError: true };
1063
+ return structuredToolError(message);
1064
+ }
1065
+ const BUILD_MCP_RECOVERY_ERROR_VALUES = {
1066
+ platform_harness_timeout: { retryable: true, recoveryAction: "Retry the call. For a deployment, use platform_list_artifact_drafts to recover the artifact and platform_wait_artifact_deploy to resume progress." },
1067
+ platform_mcp_tool_error: { retryable: false, recoveryAction: "Read the returned error code, correct the request or OAuth scopes, then retry." },
1068
+ artifact_deploy_wait_timeout: { retryable: true, recoveryAction: "Call platform_wait_artifact_deploy again with the same artifactId and operationId." },
1069
+ artifact_delete_plan_stale: { retryable: true, recoveryAction: "Call platform_plan_artifact_draft_delete again, review the new effects, and use its planToken." },
1070
+ artifact_app_bundle_changed_after_inspection: { retryable: true, recoveryAction: "Call platform_inspect_artifact_app again, review the new bundle hash, then retry deployment with that hash." },
1071
+ artifact_app_bundle_hardlink_forbidden: { retryable: false, recoveryAction: "Replace the hard link with a regular file copied into appDir, then inspect again." },
1072
+ artifact_app_auth_file_inside_app_dir: { retryable: false, recoveryAction: "Move TOPLINE_MCP_AUTH_FILE outside appDir, reconnect the Build MCP, then inspect again." },
1073
+ artifact_app_manifest_not_found: { retryable: false, recoveryAction: "Create artifact-app.json inside appDir, or correct appDir, then inspect again." },
1074
+ artifact_app_manifest_too_large: { retryable: false, recoveryAction: "Keep artifact-app.json below 512 KiB. Move application data and source into separate files, then inspect again." },
1075
+ scope_required: { retryable: false, recoveryAction: "Reconnect the Build MCP and approve the requested scope." },
1076
+ artifact_app_scaffold_target_not_empty: { retryable: false, recoveryAction: "Choose an empty appDir or deploy the existing app without scaffolding." }
1077
+ };
1078
+ export const BUILD_MCP_RECOVERY_ERRORS = Object.freeze(Object.fromEntries(Object.entries(BUILD_MCP_RECOVERY_ERROR_VALUES).map(([code, guidance]) => [code, Object.freeze(guidance)])));
1079
+ function structuredToolError(message) {
1080
+ const code = recoveryErrorCode(message);
1081
+ const selected = BUILD_MCP_RECOVERY_ERRORS[code] ?? { retryable: /^platform_harness_http_error:5|timeout|rate_limited/.test(message), recoveryAction: "Inspect the code and message, correct the input if needed, then retry the same tool." };
1082
+ const structuredContent = { ok: false, error: { code, message, ...selected } };
1083
+ return {
1084
+ content: [{ type: "text", text: JSON.stringify(structuredContent, null, 2) }],
1085
+ structuredContent,
1086
+ isError: true
1087
+ };
1088
+ }
1089
+ function recoveryErrorCode(message) {
1090
+ const known = [
1091
+ "artifact_delete_plan_stale",
1092
+ "artifact_deploy_wait_timeout",
1093
+ "artifact_app_scaffold_target_not_empty",
1094
+ "platform_harness_timeout",
1095
+ "platform_mcp_tool_error",
1096
+ "scope_required"
1097
+ ].find((candidate) => message.includes(candidate));
1098
+ if (known)
1099
+ return known;
1100
+ const match = message.match(/(?:^|:)([a-z][a-z0-9_]{2,})(?::|$)/);
1101
+ return match?.[1] ?? "topline_mcp_tool_failed";
607
1102
  }
608
1103
  const jsonSchemaValidator = new AjvJsonSchemaValidator();
609
1104
  function validateJsonSchema(schema, value) {
@@ -630,9 +1125,30 @@ async function jsonHeaders(opts) {
630
1125
  "MCP-Protocol-Version": opts.protocolVersion
631
1126
  };
632
1127
  }
1128
+ const inFlightBearerTokenRequests = new Map();
633
1129
  export async function getBearerToken(opts) {
634
1130
  if (opts.token?.trim())
635
1131
  return opts.token.trim();
1132
+ const key = [
1133
+ path.resolve(opts.authFile),
1134
+ platformBaseUrl(opts),
1135
+ mcpSurface(opts),
1136
+ [...opts.oauthScopes].sort().join(" ")
1137
+ ].join("\0");
1138
+ const existing = inFlightBearerTokenRequests.get(key);
1139
+ if (existing)
1140
+ return existing;
1141
+ const pending = getOAuthBearerToken(opts);
1142
+ inFlightBearerTokenRequests.set(key, pending);
1143
+ try {
1144
+ return await pending;
1145
+ }
1146
+ finally {
1147
+ if (inFlightBearerTokenRequests.get(key) === pending)
1148
+ inFlightBearerTokenRequests.delete(key);
1149
+ }
1150
+ }
1151
+ async function getOAuthBearerToken(opts) {
636
1152
  const stored = await readStoredOAuthCredentials(opts);
637
1153
  if (stored?.accessToken && stored.accessTokenExpiresAt && new Date(stored.accessTokenExpiresAt).getTime() - Date.now() > 60_000) {
638
1154
  return stored.accessToken;
@@ -776,7 +1292,7 @@ async function readStoredOAuthCredentials(opts) {
776
1292
  const issuer = typeof parsed.issuer === "string" ? parsed.issuer : "";
777
1293
  const resource = typeof parsed.resource === "string" ? parsed.resource : "";
778
1294
  const clientId = typeof parsed.clientId === "string" ? parsed.clientId : "";
779
- const scopes = Array.isArray(parsed.scopes) ? parsed.scopes.filter((scope) => typeof scope === "string") : DEFAULT_OAUTH_SCOPES;
1295
+ const scopes = Array.isArray(parsed.scopes) ? parsed.scopes.filter((scope) => typeof scope === "string") : [...DEFAULT_OAUTH_SCOPES];
780
1296
  if (!issuer || !resource || !clientId)
781
1297
  return null;
782
1298
  const expectedIssuer = platformBaseUrl(opts);
@@ -890,6 +1406,15 @@ function platformBaseUrl(opts) {
890
1406
  function mcpSurface(opts) {
891
1407
  return opts.surface ?? "build";
892
1408
  }
1409
+ function logicalArtifactAppDir(workspaceAppDir, manifest) {
1410
+ if (workspaceAppDir === "." || workspaceAppDir.startsWith("artifact-apps/"))
1411
+ return workspaceAppDir;
1412
+ const manifestId = typeof manifest.id === "string" ? manifest.id.trim() : "";
1413
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,126}[A-Za-z0-9])?$/.test(manifestId)) {
1414
+ throw new Error("artifact_app_manifest_id_not_safe_for_source_root");
1415
+ }
1416
+ return `artifact-apps/${manifestId}`;
1417
+ }
893
1418
  function defaultAuthFile(apiUrl, surface) {
894
1419
  const key = createHash("sha256").update(`${platformBaseUrl({ apiUrl })}:${surface}`).digest("hex").slice(0, 16);
895
1420
  return path.join(os.homedir(), ".topline", "build-mcp", `${key}.json`);
@@ -904,8 +1429,19 @@ function normalizeApiUrl(raw) {
904
1429
  }
905
1430
  if (url.protocol !== "http:" && url.protocol !== "https:")
906
1431
  throw new Error("TOPLINE_API_URL must be an http(s) URL");
1432
+ if (url.username || url.password)
1433
+ throw new Error("TOPLINE_API_URL must not include credentials");
1434
+ if (url.search || url.hash)
1435
+ throw new Error("TOPLINE_API_URL must not include a query or fragment");
1436
+ if (url.protocol !== "https:" && !isLoopbackHostname(url.hostname)) {
1437
+ throw new Error("TOPLINE_API_URL must use https except for loopback development");
1438
+ }
907
1439
  return url.toString().replace(/\/$/g, "");
908
1440
  }
1441
+ function isLoopbackHostname(hostname) {
1442
+ const normalized = hostname.toLowerCase();
1443
+ return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "[::1]" || normalized === "::1";
1444
+ }
909
1445
  function firstEnv(env, ...keys) {
910
1446
  for (const key of keys) {
911
1447
  const value = env[key]?.trim();
@@ -948,8 +1484,22 @@ function toPosix(value) {
948
1484
  }
949
1485
  function redactSecrets(value, opts) {
950
1486
  const token = opts.token?.trim();
951
- const withoutStatic = token ? value.split(token).join("[redacted]") : value;
952
- return withoutStatic
1487
+ let redacted = token ? value.split(token).join("[redacted]") : value;
1488
+ const localPaths = [opts.authFile, opts.workspaceRoot, os.homedir()]
1489
+ .map((candidate) => path.resolve(candidate))
1490
+ .filter((candidate, index, all) => candidate.length > path.parse(candidate).root.length && all.indexOf(candidate) === index)
1491
+ .sort((left, right) => right.length - left.length);
1492
+ for (const localPath of localPaths) {
1493
+ const variants = new Set([
1494
+ localPath,
1495
+ toPosix(localPath),
1496
+ JSON.stringify(localPath).slice(1, -1),
1497
+ JSON.stringify(toPosix(localPath)).slice(1, -1)
1498
+ ]);
1499
+ for (const variant of variants)
1500
+ redacted = redacted.split(variant).join("[local-path]");
1501
+ }
1502
+ return redacted
953
1503
  .replace(/ovmcp_[A-Za-z0-9_-]+/g, "[redacted]")
954
1504
  .replace(/tlmcp_(?:at|rt|code)_[A-Za-z0-9_-]+/g, "[redacted]");
955
1505
  }