@topline-build/harness-mcp 0.4.0 → 0.5.1

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.4.0";
17
- const DEFAULT_OAUTH_SCOPES = ["harness:artifact:read", "harness:artifact:write", "harness:artifact:deploy", "harness:artifact:delete"];
15
+ export const BUILD_MCP_SERVER_NAME = "topline-build-stdio";
16
+ export const HARNESS_MCP_VERSION = "0.5.1";
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,23 +95,29 @@ 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) => {
118
+ if (allowedTools && !allowedTools.has(req.params.name)) {
119
+ throw new McpError(ErrorCode.InvalidParams, `Unknown tool: ${req.params.name}`);
120
+ }
100
121
  const reportsProgress = req.params.name === "platform_deploy_artifact_app" || req.params.name === "platform_wait_artifact_deploy";
101
122
  try {
102
123
  const result = await callHarnessTool(opts, req.params.name, req.params.arguments ?? {}, reportsProgress
@@ -137,6 +158,9 @@ export async function callHarnessTool(opts, name, args, hooks) {
137
158
  case "platform_deploy_artifact_app":
138
159
  result = toolResult(await deployArtifactApp(opts, input, hooks?.onProgress));
139
160
  break;
161
+ case "platform_inspect_artifact_app":
162
+ result = toolResult(await inspectArtifactApp(opts, input));
163
+ break;
140
164
  case "platform_artifact_status":
141
165
  result = toolResult(await getArtifactStatus(opts, stringArg(input, "artifactId"), numberArg(input, "limit")));
142
166
  break;
@@ -166,7 +190,8 @@ export async function callHarnessTool(opts, name, args, hooks) {
166
190
  export async function deployArtifactApp(opts, input, onProgress) {
167
191
  await onProgress?.(0, "Reading artifact manifest");
168
192
  const appDir = stringArg(input, "appDir");
169
- const absoluteAppDir = resolveWorkspacePath(opts.workspaceRoot, appDir);
193
+ const absoluteAppDir = await resolveWorkspacePathSafely(opts.workspaceRoot, appDir);
194
+ await assertAuthFileOutsideAppDir(opts.authFile, absoluteAppDir);
170
195
  const workspaceAppDir = workspaceRelativeAppDir(opts.workspaceRoot, absoluteAppDir);
171
196
  const manifest = await readManifest(absoluteAppDir);
172
197
  const platformAppDir = logicalArtifactAppDir(workspaceAppDir, manifest);
@@ -175,6 +200,10 @@ export async function deployArtifactApp(opts, input, onProgress) {
175
200
  .digest("hex");
176
201
  await onProgress?.(0.2, "Packaging local artifact app");
177
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
+ }
178
207
  await onProgress?.(0.4, "Creating private artifact draft");
179
208
  const created = await fetchJson(opts, artifactAppsPath(opts), {
180
209
  method: "POST",
@@ -227,6 +256,35 @@ export async function deployArtifactApp(opts, input, onProgress) {
227
256
  deploy: deployed
228
257
  };
229
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
+ }
230
288
  async function callHostedBuildTool(opts, name, input) {
231
289
  if (mcpSurface(opts) !== "build")
232
290
  throw new Error(`${name}_requires_build_surface`);
@@ -287,7 +345,7 @@ export async function initArtifactApp(opts, input) {
287
345
  .toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
288
346
  if (!manifestId || manifestId.length > 80)
289
347
  throw new Error("artifact_manifest_id_invalid");
290
- const absoluteAppDir = resolveWorkspacePath(opts.workspaceRoot, appDir);
348
+ const absoluteAppDir = await resolveWorkspacePathSafely(opts.workspaceRoot, appDir);
291
349
  if (existsSync(absoluteAppDir) && (await readdir(absoluteAppDir)).length > 0)
292
350
  throw new Error("artifact_app_scaffold_target_not_empty");
293
351
  await mkdir(path.join(absoluteAppDir, "src"), { recursive: true });
@@ -321,7 +379,7 @@ export async function initArtifactApp(opts, input) {
321
379
  manifestId,
322
380
  title,
323
381
  files: ["artifact-app.json", "package.json", "src/index.js"],
324
- nextAction: `Run npm test in ${appDir}, then call platform_deploy_artifact_app with the same appDir.`
382
+ nextAction: `Run npm test in ${appDir}, then call platform_inspect_artifact_app with the same appDir before deployment.`
325
383
  };
326
384
  }
327
385
  export async function getArtifactStatus(opts, artifactId, limit) {
@@ -412,6 +470,8 @@ export function storybookAccess(opts) {
412
470
  }
413
471
  export async function packArtifactAppDirectory(appDir, archivePrefix = "") {
414
472
  const root = path.resolve(appDir);
473
+ if ((await lstat(root)).isSymbolicLink())
474
+ throw new Error("artifact_app_bundle_symlink_forbidden:.");
415
475
  const prefix = archivePrefix ? validateArchivePrefix(archivePrefix) : "";
416
476
  const files = [];
417
477
  const excluded = [];
@@ -420,11 +480,13 @@ export async function packArtifactAppDirectory(appDir, archivePrefix = "") {
420
480
  throw new Error("artifact_app_bundle_empty");
421
481
  let byteCount = 0;
422
482
  const chunks = [];
423
- for (const file of files.sort((a, b) => a.relative.localeCompare(b.relative))) {
424
- byteCount += file.size;
425
- 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)
426
485
  throw new Error(`artifact_app_bundle_unpacked_too_large:${MAX_UNPACKED_BYTES}`);
427
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}`);
428
490
  chunks.push(tarHeader(prefix ? `${prefix}/${file.relative}` : file.relative, content.length));
429
491
  chunks.push(content);
430
492
  chunks.push(Buffer.alloc(padSize(content.length)));
@@ -438,9 +500,12 @@ export async function packArtifactAppDirectory(appDir, archivePrefix = "") {
438
500
  fileCount: files.length,
439
501
  byteCount,
440
502
  sha256: createHash("sha256").update(body).digest("hex"),
441
- excluded
503
+ excluded: excluded.sort(compareUtf8Paths)
442
504
  };
443
505
  }
506
+ function compareUtf8Paths(left, right) {
507
+ return Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8"));
508
+ }
444
509
  async function walk(root, current, files, excluded) {
445
510
  const entries = await readdir(current, { withFileTypes: true });
446
511
  for (const entry of entries) {
@@ -458,6 +523,8 @@ async function walk(root, current, files, excluded) {
458
523
  await walk(root, absolute, files, excluded);
459
524
  }
460
525
  else if (entry.isFile()) {
526
+ if (stat.nlink > 1)
527
+ throw new Error(`artifact_app_bundle_hardlink_forbidden:${relative}`);
461
528
  files.push({ absolute, relative, size: stat.size });
462
529
  if (files.length > MAX_FILES)
463
530
  throw new Error(`artifact_app_bundle_file_limit:${MAX_FILES}`);
@@ -466,7 +533,32 @@ async function walk(root, current, files, excluded) {
466
533
  }
467
534
  async function readManifest(appDir) {
468
535
  const manifestPath = path.join(appDir, "artifact-app.json");
469
- 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
+ }
470
562
  if (!isRecord(parsed))
471
563
  throw new Error("artifact_app_manifest_must_be_object");
472
564
  return parsed;
@@ -474,10 +566,61 @@ async function readManifest(appDir) {
474
566
  function resolveWorkspacePath(workspaceRoot, inputPath) {
475
567
  const resolved = path.resolve(workspaceRoot, inputPath);
476
568
  const relative = path.relative(workspaceRoot, resolved);
477
- if (relative.startsWith("..") || path.isAbsolute(relative))
569
+ if (pathEscapesRoot(relative))
478
570
  throw new Error("appDir_must_be_inside_workspace_root");
479
571
  return resolved;
480
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
+ }
481
624
  function workspaceRelativeAppDir(workspaceRoot, absoluteAppDir) {
482
625
  const relative = toPosix(path.relative(path.resolve(workspaceRoot), path.resolve(absoluteAppDir)));
483
626
  return relative ? validateArchivePrefix(relative) : ".";
@@ -515,7 +658,9 @@ function tarHeader(name, size) {
515
658
  writeOctal(header, 108, 8, 0);
516
659
  writeOctal(header, 116, 8, 0);
517
660
  writeOctal(header, 124, 12, size);
518
- 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);
519
664
  header.fill(0x20, 148, 156);
520
665
  header.write("0", 156, 1, "utf8");
521
666
  header.write("ustar", 257, 5, "utf8");
@@ -584,255 +729,283 @@ async function fetchJson(opts, url, init) {
584
729
  clearTimeout(timeout);
585
730
  }
586
731
  }
587
- export function listTools() {
588
- return {
589
- tools: [
590
- {
591
- name: "platform_connection_check",
592
- description: "Complete the one-time Developer Access verification challenge for this Build MCP client.",
593
- annotations: mcpToolAnnotations("platform_connection_check", { readOnly: false, openWorld: true }),
594
- inputSchema: {
595
- type: "object",
596
- properties: {
597
- verificationCode: { type: "string", minLength: 16, maxLength: 80 }
598
- },
599
- required: ["verificationCode"],
600
- additionalProperties: false
601
- },
602
- outputSchema: {
603
- type: "object",
604
- properties: {
605
- ok: { type: "boolean" }, verified: { type: "boolean" }, product: { type: "string", enum: ["build"] }, verifiedAt: { type: "string" }
606
- },
607
- required: ["ok", "verified", "product", "verifiedAt"]
608
- }
609
- },
610
- {
611
- name: "platform_init_artifact_app",
612
- description: "Create a minimal, validated artifact app starter inside the configured local workspace.",
613
- annotations: mcpToolAnnotations("platform_init_artifact_app", { readOnly: false }),
614
- inputSchema: {
615
- type: "object",
616
- properties: { appDir: { type: "string", minLength: 1 }, manifestId: { type: "string" }, title: { type: "string" } },
617
- required: ["appDir"],
618
- additionalProperties: false
619
- },
620
- outputSchema: {
621
- type: "object",
622
- properties: { ok: { type: "boolean" }, appDir: { type: "string" }, manifestId: { type: "string" }, title: { type: "string" }, files: { type: "array", items: { type: "string" } }, nextAction: { type: "string" } },
623
- required: ["ok", "appDir", "manifestId", "title", "files", "nextAction"]
624
- }
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 }
625
741
  },
626
- {
627
- name: "platform_deploy_artifact_app",
628
- description: "Package a local artifact app directory, upload it, deploy a private draft, and return status.",
629
- annotations: mcpToolAnnotations("platform_deploy_artifact_app", { readOnly: false, openWorld: true }),
630
- inputSchema: {
631
- type: "object",
632
- properties: {
633
- appDir: { type: "string", minLength: 1 },
634
- artifactId: { type: "string" },
635
- title: { type: "string" },
636
- description: { type: "string" },
637
- projectId: { type: "string" },
638
- requestId: { type: "string", minLength: 1, maxLength: 128, description: "Optional stable retry key; generated deterministically when omitted." }
639
- },
640
- required: ["appDir"],
641
- additionalProperties: false
642
- },
643
- outputSchema: {
644
- type: "object",
645
- properties: {
646
- ok: { type: "boolean" },
647
- artifactId: { type: "string" },
648
- artifactVersionId: { type: "string" },
649
- clientRequestId: { type: "string" },
650
- operationId: { type: ["string", "null"] },
651
- bundle: { type: "object" },
652
- create: { type: "object" },
653
- deploy: { type: "object" }
654
- },
655
- required: ["ok", "artifactId", "artifactVersionId", "clientRequestId", "operationId", "bundle", "create", "deploy"]
656
- }
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" }
657
749
  },
658
- {
659
- name: "platform_list_build_workspaces",
660
- description: "List Topline workspaces available for artifact placement.",
661
- annotations: mcpToolAnnotations("platform_list_build_workspaces", { readOnly: true, openWorld: true }),
662
- inputSchema: { type: "object", properties: {}, additionalProperties: false },
663
- outputSchema: { type: "object", properties: { ok: { type: "boolean" }, workspaces: { type: "array", items: { type: "object" } } }, required: ["ok", "workspaces"] }
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." }
664
783
  },
665
- {
666
- name: "platform_list_artifact_drafts",
667
- description: "Discover private artifact drafts created through this Build MCP connection.",
668
- annotations: mcpToolAnnotations("platform_list_artifact_drafts", { readOnly: true, openWorld: true }),
669
- inputSchema: { type: "object", properties: { projectId: { type: "string" }, limit: { type: "integer", minimum: 1, maximum: 100 } }, additionalProperties: false },
670
- outputSchema: { type: "object", properties: { ok: { type: "boolean" }, drafts: { type: "array", items: { type: "object" } } }, required: ["ok", "drafts"] }
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" }
671
798
  },
672
- {
673
- name: "platform_deploy_operation",
674
- description: "Read durable server-side progress for one artifact deployment operation.",
675
- annotations: mcpToolAnnotations("platform_deploy_operation", { readOnly: true, openWorld: true }),
676
- inputSchema: { type: "object", properties: { artifactId: { type: "string", minLength: 1 }, operationId: { type: "string", minLength: 1 }, afterSeq: { type: "integer", minimum: 0 } }, required: ["artifactId", "operationId"], additionalProperties: false },
677
- 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"] }
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" }
678
823
  },
679
- {
680
- name: "platform_wait_artifact_deploy",
681
- description: "Wait for a durable deployment operation and stream server-derived progress.",
682
- annotations: mcpToolAnnotations("platform_wait_artifact_deploy", { readOnly: true, openWorld: true }),
683
- inputSchema: { type: "object", properties: { artifactId: { type: "string", minLength: 1 }, operationId: { type: "string", minLength: 1 }, timeoutSeconds: { type: "integer", minimum: 1, maximum: 900 } }, required: ["artifactId", "operationId"], additionalProperties: false },
684
- 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"] }
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" } }
685
870
  },
686
- {
687
- name: "platform_artifact_status",
688
- description: "Read private draft deployment status for a hosted artifact app.",
689
- annotations: mcpToolAnnotations("platform_artifact_status", { readOnly: true, openWorld: true }),
690
- inputSchema: {
691
- type: "object",
692
- properties: { artifactId: { type: "string", minLength: 1 }, limit: { type: "integer", minimum: 1, maximum: 50 } },
693
- required: ["artifactId"],
694
- additionalProperties: false
695
- },
696
- outputSchema: {
697
- type: "object",
698
- properties: {
699
- ok: { type: "boolean" }, phase: { type: "string" }, artifactId: { type: "string" },
700
- deployments: { type: "array", items: { type: "object" } }
701
- },
702
- required: ["ok", "phase", "artifactId", "deployments"]
703
- }
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" }
704
892
  },
705
- {
706
- name: "platform_read_artifact_logs",
707
- description: "Read bounded, redacted deployment logs for a hosted artifact app.",
708
- annotations: mcpToolAnnotations("platform_read_artifact_logs", { readOnly: true, openWorld: true }),
709
- inputSchema: {
710
- type: "object",
711
- properties: {
712
- artifactId: { type: "string", minLength: 1 },
713
- deploymentRecordId: { type: "string" },
714
- environment: { type: "string", enum: ["draft", "live"] },
715
- limit: { type: "integer", minimum: 1, maximum: 200 },
716
- severity: { type: "string" },
717
- contains: { type: "string" },
718
- since: { type: "string", format: "date-time" },
719
- windowMinutes: { type: "integer", minimum: 1, maximum: 1440 },
720
- trace: { type: "string" },
721
- requestId: { type: "string" },
722
- route: { type: "string" }
723
- },
724
- required: ["artifactId"],
725
- additionalProperties: false
726
- },
727
- outputSchema: {
728
- type: "object",
729
- properties: {
730
- ok: { type: "boolean" },
731
- phase: { type: "string" },
732
- artifactId: { type: "string" },
733
- entries: { type: "array", items: { type: "object" } }
734
- },
735
- required: ["ok", "phase", "artifactId"]
736
- }
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" } }
737
903
  },
738
- {
739
- name: "platform_open_artifact",
740
- description: "Return the operator URL and latest draft/live URLs for a hosted artifact app.",
741
- annotations: mcpToolAnnotations("platform_open_artifact", { readOnly: true, openWorld: true }),
742
- inputSchema: {
743
- type: "object",
744
- properties: { artifactId: { type: "string", minLength: 1 } },
745
- required: ["artifactId"],
746
- additionalProperties: false
747
- },
748
- outputSchema: {
749
- type: "object",
750
- properties: {
751
- ok: { type: "boolean" }, artifactId: { type: "string" }, openUrl: { type: "string" },
752
- draftUrl: { type: ["string", "null"] }, liveUrl: { type: ["string", "null"] }, versionUrl: { type: ["string", "null"] }
753
- },
754
- required: ["ok", "artifactId", "openUrl", "draftUrl", "liveUrl", "versionUrl"]
755
- }
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"] }
756
922
  },
757
- {
758
- name: "platform_storybook_access",
759
- description: "Return the tenant Storybook preview URL and HTTP MCP server config.",
760
- annotations: mcpToolAnnotations("platform_storybook_access", { readOnly: true }),
761
- inputSchema: {
762
- type: "object",
763
- properties: {},
764
- additionalProperties: false
765
- },
766
- outputSchema: {
767
- type: "object",
768
- properties: {
769
- ok: { type: "boolean" }, storybookUrl: { type: "string" }, mcpUrl: { type: "string" }, mcpServers: { type: "object" }
770
- },
771
- required: ["ok", "storybookUrl", "mcpUrl", "mcpServers"]
772
- }
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" }
773
939
  },
774
- {
775
- name: "platform_validate_artifact",
776
- description: "Read trusted smoke and health evidence for a private artifact draft without requiring a browser session.",
777
- annotations: mcpToolAnnotations("platform_validate_artifact", { readOnly: true }),
778
- inputSchema: {
779
- type: "object",
780
- properties: {
781
- artifactId: { type: "string", minLength: 1 },
782
- deploymentRecordId: { type: "string", minLength: 1 }
783
- },
784
- required: ["artifactId"],
785
- additionalProperties: false
786
- },
787
- outputSchema: {
788
- type: "object",
789
- properties: {
790
- ok: { type: "boolean" }, phase: { type: "string" }, artifactId: { type: "string" },
791
- deploymentRecordId: { type: "string" }, deploymentState: { type: "string" },
792
- healthy: { type: "boolean" }, checkedAt: { type: ["string", "null"] },
793
- draftUrl: { type: ["string", "null"] }, smoke: { type: ["object", "null"] }
794
- },
795
- required: ["ok", "phase", "artifactId", "deploymentRecordId", "deploymentState", "healthy", "checkedAt", "draftUrl", "smoke"]
796
- }
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" }
797
952
  },
798
- {
799
- name: "platform_run_artifact_smoke",
800
- description: "Rerun the trusted draft smoke check and return fresh rendered-page evidence.",
801
- annotations: mcpToolAnnotations("platform_run_artifact_smoke", { readOnly: false, openWorld: true }),
802
- inputSchema: { type: "object", properties: { artifactId: { type: "string", minLength: 1 }, deploymentRecordId: { type: "string", minLength: 1 } }, required: ["artifactId"], additionalProperties: false },
803
- 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"] }
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"] }
804
963
  },
805
- {
806
- name: "platform_plan_artifact_draft_delete",
807
- description: "Preview cleanup effects and receive the state-bound plan token required for deletion.",
808
- annotations: mcpToolAnnotations("platform_plan_artifact_draft_delete", { readOnly: true, openWorld: true }),
809
- inputSchema: { type: "object", properties: { artifactId: { type: "string", minLength: 1 } }, required: ["artifactId"], additionalProperties: false },
810
- 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"] }
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 }
811
991
  },
812
- {
813
- name: "platform_delete_artifact_draft",
814
- description: "Permanently remove an unpromoted private draft created by this MCP connection after exact-id confirmation.",
815
- annotations: mcpToolAnnotations("platform_delete_artifact_draft", { readOnly: false, destructive: true, openWorld: true }),
816
- inputSchema: {
817
- type: "object",
818
- properties: {
819
- artifactId: { type: "string", minLength: 1 },
820
- confirmArtifactId: { type: "string", minLength: 1 },
821
- planToken: { type: "string", minLength: 1 }
822
- },
823
- required: ["artifactId", "confirmArtifactId", "planToken"],
824
- additionalProperties: false
825
- },
826
- outputSchema: {
827
- type: "object",
828
- properties: { ok: { type: "boolean" }, deleted: { type: "boolean" }, artifactId: { type: "string" } },
829
- required: ["ok", "deleted", "artifactId"]
830
- }
831
- }
832
- ].map(withRecoveryOutputSchema)
833
- };
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) };
834
1006
  }
835
- function withRecoveryOutputSchema(tool) {
1007
+ function withRecoveryOutputSchema(value) {
1008
+ const tool = value;
836
1009
  if (!tool.outputSchema)
837
1010
  return tool;
838
1011
  return {
@@ -889,17 +1062,23 @@ function toolResult(value) {
889
1062
  function toolError(message) {
890
1063
  return structuredToolError(message);
891
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)])));
892
1079
  function structuredToolError(message) {
893
1080
  const code = recoveryErrorCode(message);
894
- const recovery = {
895
- 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." },
896
- platform_mcp_tool_error: { retryable: false, recoveryAction: "Read the returned error code, correct the request or OAuth scopes, then retry." },
897
- artifact_deploy_wait_timeout: { retryable: true, recoveryAction: "Call platform_wait_artifact_deploy again with the same artifactId and operationId." },
898
- artifact_delete_plan_stale: { retryable: true, recoveryAction: "Call platform_plan_artifact_draft_delete again, review the new effects, and use its planToken." },
899
- scope_required: { retryable: false, recoveryAction: "Reconnect the Build MCP and approve the requested scope." },
900
- artifact_app_scaffold_target_not_empty: { retryable: false, recoveryAction: "Choose an empty appDir or deploy the existing app without scaffolding." }
901
- };
902
- const selected = recovery[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." };
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." };
903
1082
  const structuredContent = { ok: false, error: { code, message, ...selected } };
904
1083
  return {
905
1084
  content: [{ type: "text", text: JSON.stringify(structuredContent, null, 2) }],
@@ -946,9 +1125,30 @@ async function jsonHeaders(opts) {
946
1125
  "MCP-Protocol-Version": opts.protocolVersion
947
1126
  };
948
1127
  }
1128
+ const inFlightBearerTokenRequests = new Map();
949
1129
  export async function getBearerToken(opts) {
950
1130
  if (opts.token?.trim())
951
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) {
952
1152
  const stored = await readStoredOAuthCredentials(opts);
953
1153
  if (stored?.accessToken && stored.accessTokenExpiresAt && new Date(stored.accessTokenExpiresAt).getTime() - Date.now() > 60_000) {
954
1154
  return stored.accessToken;
@@ -1092,7 +1292,7 @@ async function readStoredOAuthCredentials(opts) {
1092
1292
  const issuer = typeof parsed.issuer === "string" ? parsed.issuer : "";
1093
1293
  const resource = typeof parsed.resource === "string" ? parsed.resource : "";
1094
1294
  const clientId = typeof parsed.clientId === "string" ? parsed.clientId : "";
1095
- 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];
1096
1296
  if (!issuer || !resource || !clientId)
1097
1297
  return null;
1098
1298
  const expectedIssuer = platformBaseUrl(opts);
@@ -1229,8 +1429,19 @@ function normalizeApiUrl(raw) {
1229
1429
  }
1230
1430
  if (url.protocol !== "http:" && url.protocol !== "https:")
1231
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
+ }
1232
1439
  return url.toString().replace(/\/$/g, "");
1233
1440
  }
1441
+ function isLoopbackHostname(hostname) {
1442
+ const normalized = hostname.toLowerCase();
1443
+ return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "[::1]" || normalized === "::1";
1444
+ }
1234
1445
  function firstEnv(env, ...keys) {
1235
1446
  for (const key of keys) {
1236
1447
  const value = env[key]?.trim();
@@ -1273,8 +1484,22 @@ function toPosix(value) {
1273
1484
  }
1274
1485
  function redactSecrets(value, opts) {
1275
1486
  const token = opts.token?.trim();
1276
- const withoutStatic = token ? value.split(token).join("[redacted]") : value;
1277
- 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
1278
1503
  .replace(/ovmcp_[A-Za-z0-9_-]+/g, "[redacted]")
1279
1504
  .replace(/tlmcp_(?:at|rt|code)_[A-Za-z0-9_-]+/g, "[redacted]");
1280
1505
  }