@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/CHANGELOG.md +17 -0
- package/README.md +80 -104
- package/dist/index.d.ts +40 -2
- package/dist/index.js +498 -273
- package/docs/generated/build-mcp-manifest.json +2120 -0
- package/docs/generated/build-mcp-reference.md +2126 -0
- package/package.json +11 -5
- package/scripts/certify-live.mjs +622 -0
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
|
|
16
|
-
export const HARNESS_MCP_VERSION = "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
|
-
"
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
98
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
424
|
-
byteCount
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
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
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
1277
|
-
|
|
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
|
}
|