@topline-build/harness-mcp 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,10 +44,63 @@ npx -y @topline-build/harness-mcp --url https://hawx.topline.build
44
44
  Set `TOPLINE_WORKSPACE_ROOT` when your MCP host starts outside the repo that
45
45
  contains the artifact app.
46
46
 
47
+ The app can live anywhere under that workspace. For source tracking, the MCP
48
+ maps ordinary local folders to `artifact-apps/<manifest-id>` while preserving
49
+ existing `artifact-apps/<name>` roots and workspace-root (`.`) apps. Deploys
50
+ report manifest, packaging, draft creation, upload, and trusted-deployment
51
+ progress to clients that support MCP progress notifications.
52
+ Every deploy also receives a deterministic retry key. If a client loses the
53
+ response and repeats the call, Topline resumes the same private draft instead
54
+ of creating an ordinary duplicate.
55
+
47
56
  Topline Developer Access provides Build MCP snippets for Codex and Claude Code.
48
57
  Those snippets contain no raw token; the first tool call opens a browser
49
58
  approval flow scoped to artifact building.
50
59
 
60
+ ## Build workflow
61
+
62
+ - `platform_init_artifact_app` creates a dependency-free starter in an empty
63
+ local directory.
64
+ - `platform_connection_check` completes the one-time verification shown in
65
+ Developer Access.
66
+ - `platform_list_build_workspaces` and `platform_list_artifact_drafts` discover
67
+ valid placement targets and recover existing drafts without exposing chat or
68
+ memory data.
69
+ - `platform_deploy_artifact_app` packages the local app and deploys a private
70
+ draft through Topline's trusted runtime. Its `operationId` can be passed to
71
+ `platform_wait_artifact_deploy` for server-derived progress and recovery
72
+ after a disconnected client.
73
+ - `platform_run_artifact_smoke` reruns trusted draft rendering and returns the
74
+ fresh evidence used by validation.
75
+ - `platform_validate_artifact` returns bounded trusted smoke and rendered-page
76
+ evidence without requiring the agent to hold a browser session.
77
+ - `platform_artifact_status`, `platform_read_artifact_logs`, and
78
+ `platform_open_artifact` inspect and hand off the draft.
79
+ - `platform_plan_artifact_draft_delete` previews cleanup effects. Deletion uses
80
+ a separately approved `harness:artifact:delete` scope, the exact artifact id,
81
+ and the current plan token; stale plans fail closed.
82
+
83
+ Tool failures include a stable error code, retryability, and a concrete
84
+ recovery action in `structuredContent`.
85
+
86
+ ## Live certification
87
+
88
+ After building the package, a release operator can exercise the real MCP client
89
+ protocol, workspace discovery, deploy/resume, smoke, validation, and cleanup
90
+ planning against a tenant:
91
+
92
+ ```bash
93
+ npm run build
94
+ TOPLINE_API_URL=https://tenant.example \
95
+ TOPLINE_MCP_TOKEN=... \
96
+ TOPLINE_WORKSPACE_ROOT=/path/to/workspace \
97
+ TOPLINE_CERTIFICATION_APP_DIR=artifact-apps/certification \
98
+ npm run certify:live
99
+ ```
100
+
101
+ Set `TOPLINE_CERTIFICATION_CLEANUP=1` only when the certification draft should
102
+ be removed after its cleanup plan is reviewed.
103
+
51
104
  ## Storybook
52
105
 
53
106
  Use the `platform_storybook_access` tool to get the tenant Storybook preview URL
package/dist/index.d.ts CHANGED
@@ -29,17 +29,22 @@ export interface PackedArtifactAppBundle {
29
29
  sha256: string;
30
30
  excluded: string[];
31
31
  }
32
- export declare const HARNESS_MCP_VERSION = "0.2.0";
32
+ type ToolProgressReporter = (progress: number, message: string) => void | Promise<void>;
33
+ export declare const HARNESS_MCP_VERSION = "0.4.0";
33
34
  export declare function parseHarnessOptions(argv: string[], env?: Record<string, string | undefined>): HarnessMcpStdioOptions;
34
35
  export declare function runHarnessMcpStdioCli(): Promise<void>;
35
36
  export declare function cliInfo(argv: string[]): string | null;
36
37
  export declare function createHarnessMcpStdioServer(opts: HarnessMcpStdioOptions): McpServer;
37
- export declare function callHarnessTool(opts: HarnessMcpStdioOptions, name: string, args: unknown): Promise<CallToolResult>;
38
- export declare function deployArtifactApp(opts: HarnessMcpStdioOptions, input: Record<string, unknown>): Promise<{
38
+ export declare function callHarnessTool(opts: HarnessMcpStdioOptions, name: string, args: unknown, hooks?: {
39
+ onProgress?: ToolProgressReporter;
40
+ }): Promise<CallToolResult>;
41
+ export declare function deployArtifactApp(opts: HarnessMcpStdioOptions, input: Record<string, unknown>, onProgress?: ToolProgressReporter): Promise<{
39
42
  ok: boolean;
40
43
  artifactId: string;
41
44
  artifactVersionId: string;
42
45
  manifestId: string | null;
46
+ clientRequestId: string;
47
+ operationId: string | null;
43
48
  bundle: {
44
49
  fileCount: number;
45
50
  byteCount: number;
@@ -49,6 +54,15 @@ export declare function deployArtifactApp(opts: HarnessMcpStdioOptions, input: R
49
54
  create: unknown;
50
55
  deploy: unknown;
51
56
  }>;
57
+ export declare function waitForArtifactDeploy(opts: HarnessMcpStdioOptions, input: Record<string, unknown>, onProgress?: ToolProgressReporter): Promise<Record<string, unknown>>;
58
+ export declare function initArtifactApp(opts: HarnessMcpStdioOptions, input: Record<string, unknown>): Promise<{
59
+ ok: boolean;
60
+ appDir: string;
61
+ manifestId: string;
62
+ title: string;
63
+ files: string[];
64
+ nextAction: string;
65
+ }>;
52
66
  export declare function getArtifactStatus(opts: HarnessMcpStdioOptions, artifactId: string, limit?: number): Promise<unknown>;
53
67
  export declare function readArtifactLogs(opts: HarnessMcpStdioOptions, input: Record<string, unknown>): Promise<unknown>;
54
68
  export declare function openArtifact(opts: HarnessMcpStdioOptions, artifactId: string): Promise<{
@@ -120,3 +134,4 @@ export declare function browserOpenCommand(url: string, platform?: string): {
120
134
  command: string;
121
135
  args: string[];
122
136
  };
137
+ export {};
package/dist/index.js CHANGED
@@ -13,8 +13,8 @@ import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } fr
13
13
  const DEFAULT_MCP_PROTOCOL_VERSION = "2025-11-25";
14
14
  const DEFAULT_TIMEOUT_MS = 120_000;
15
15
  const SERVER_NAME = "topline-build-stdio";
16
- export const HARNESS_MCP_VERSION = "0.2.0";
17
- const DEFAULT_OAUTH_SCOPES = ["harness:artifact:read", "harness:artifact:write", "harness:artifact:deploy"];
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"];
18
18
  const BLOCK_SIZE = 512;
19
19
  const MAX_BUNDLE_BYTES = 64 * 1024 * 1024;
20
20
  const MAX_UNPACKED_BYTES = 512 * 1024 * 1024;
@@ -97,13 +97,11 @@ export function createHarnessMcpStdioServer(opts) {
97
97
  const mcp = new McpServer({ name: SERVER_NAME, version: HARNESS_MCP_VERSION }, { capabilities: { tools: {} } });
98
98
  mcp.setRequestHandler(ListToolsRequestSchema, async () => listTools());
99
99
  mcp.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
100
- const reportsProgress = req.params.name === "platform_deploy_artifact_app";
101
- if (reportsProgress)
102
- await sendProgress(extra, 0, 1, "Starting artifact deployment");
100
+ const reportsProgress = req.params.name === "platform_deploy_artifact_app" || req.params.name === "platform_wait_artifact_deploy";
103
101
  try {
104
- const result = await callHarnessTool(opts, req.params.name, req.params.arguments ?? {});
105
- if (reportsProgress)
106
- await sendProgress(extra, 1, 1, "Completed artifact deployment");
102
+ const result = await callHarnessTool(opts, req.params.name, req.params.arguments ?? {}, reportsProgress
103
+ ? { onProgress: (progress, message) => sendProgress(extra, progress, 1, message) }
104
+ : undefined);
107
105
  return result;
108
106
  }
109
107
  catch (err) {
@@ -111,15 +109,12 @@ export function createHarnessMcpStdioServer(opts) {
111
109
  throw err;
112
110
  if (reportsProgress)
113
111
  await sendProgress(extra, 1, 1, "Failed artifact deployment");
114
- return {
115
- content: [{ type: "text", text: redactSecrets(err instanceof Error ? err.message : String(err), opts) }],
116
- isError: true
117
- };
112
+ return structuredToolError(redactSecrets(err instanceof Error ? err.message : String(err), opts));
118
113
  }
119
114
  });
120
115
  return mcp;
121
116
  }
122
- export async function callHarnessTool(opts, name, args) {
117
+ export async function callHarnessTool(opts, name, args, hooks) {
123
118
  const input = isRecord(args) ? args : {};
124
119
  const tool = listTools().tools.find((candidate) => candidate.name === name);
125
120
  if (!tool)
@@ -129,8 +124,18 @@ export async function callHarnessTool(opts, name, args) {
129
124
  return toolError(`Invalid arguments for ${name}: ${inputError}`);
130
125
  let result;
131
126
  switch (name) {
127
+ case "platform_connection_check":
128
+ case "platform_list_build_workspaces":
129
+ case "platform_list_artifact_drafts":
130
+ case "platform_deploy_operation":
131
+ case "platform_validate_artifact":
132
+ case "platform_run_artifact_smoke":
133
+ case "platform_plan_artifact_draft_delete":
134
+ case "platform_delete_artifact_draft":
135
+ result = toolResult(await callHostedBuildTool(opts, name, input));
136
+ break;
132
137
  case "platform_deploy_artifact_app":
133
- result = toolResult(await deployArtifactApp(opts, input));
138
+ result = toolResult(await deployArtifactApp(opts, input, hooks?.onProgress));
134
139
  break;
135
140
  case "platform_artifact_status":
136
141
  result = toolResult(await getArtifactStatus(opts, stringArg(input, "artifactId"), numberArg(input, "limit")));
@@ -144,6 +149,12 @@ export async function callHarnessTool(opts, name, args) {
144
149
  case "platform_storybook_access":
145
150
  result = toolResult(storybookAccess(opts));
146
151
  break;
152
+ case "platform_wait_artifact_deploy":
153
+ result = toolResult(await waitForArtifactDeploy(opts, input, hooks?.onProgress));
154
+ break;
155
+ case "platform_init_artifact_app":
156
+ result = toolResult(await initArtifactApp(opts, input));
157
+ break;
147
158
  default:
148
159
  throw new McpError(ErrorCode.InvalidParams, `Unknown tool: ${name}`);
149
160
  }
@@ -152,12 +163,19 @@ export async function callHarnessTool(opts, name, args) {
152
163
  : null;
153
164
  return outputError ? toolError(`Invalid output for ${name}: ${outputError}`) : result;
154
165
  }
155
- export async function deployArtifactApp(opts, input) {
166
+ export async function deployArtifactApp(opts, input, onProgress) {
167
+ await onProgress?.(0, "Reading artifact manifest");
156
168
  const appDir = stringArg(input, "appDir");
157
169
  const absoluteAppDir = resolveWorkspacePath(opts.workspaceRoot, appDir);
158
- const platformAppDir = workspaceRelativeAppDir(opts.workspaceRoot, absoluteAppDir);
170
+ const workspaceAppDir = workspaceRelativeAppDir(opts.workspaceRoot, absoluteAppDir);
159
171
  const manifest = await readManifest(absoluteAppDir);
172
+ const platformAppDir = logicalArtifactAppDir(workspaceAppDir, manifest);
173
+ const clientRequestId = optionalString(input, "requestId") ?? createHash("sha256")
174
+ .update(JSON.stringify({ apiUrl: platformBaseUrl(opts), appDir: platformAppDir, manifestId: manifest.id ?? null }))
175
+ .digest("hex");
176
+ await onProgress?.(0.2, "Packaging local artifact app");
160
177
  const bundle = await packArtifactAppDirectory(absoluteAppDir, platformAppDir === "." ? "" : platformAppDir);
178
+ await onProgress?.(0.4, "Creating private artifact draft");
161
179
  const created = await fetchJson(opts, artifactAppsPath(opts), {
162
180
  method: "POST",
163
181
  headers: await jsonHeaders(opts),
@@ -166,6 +184,8 @@ export async function deployArtifactApp(opts, input) {
166
184
  title: optionalString(input, "title"),
167
185
  description: optionalString(input, "description"),
168
186
  projectId: optionalString(input, "projectId"),
187
+ clientRequestId,
188
+ bundleSha256: bundle.sha256,
169
189
  manifest
170
190
  })
171
191
  });
@@ -175,6 +195,7 @@ export async function deployArtifactApp(opts, input) {
175
195
  const artifactId = stringValue(createdRecord.artifactId, "artifactId");
176
196
  const artifactVersionId = stringValue(createdRecord.artifactVersionId, "artifactVersionId");
177
197
  const bearerToken = await getBearerToken(opts);
198
+ await onProgress?.(0.6, "Uploading bundle and waiting for trusted draft deployment");
178
199
  const deployed = await fetchJson(opts, `${artifactAppsPath(opts)}/${encodeURIComponent(artifactId)}/deploy-bundle`, {
179
200
  method: "POST",
180
201
  headers: {
@@ -186,11 +207,16 @@ export async function deployArtifactApp(opts, input) {
186
207
  },
187
208
  body: bundle.body
188
209
  });
210
+ await onProgress?.(1, "Completed trusted draft deployment");
211
+ const deployedRecord = asRecord(deployed);
212
+ const operationId = typeof deployedRecord.taskId === "string" ? deployedRecord.taskId : null;
189
213
  return {
190
214
  ok: true,
191
215
  artifactId,
192
216
  artifactVersionId,
193
217
  manifestId: typeof manifest.id === "string" ? manifest.id : null,
218
+ clientRequestId,
219
+ operationId,
194
220
  bundle: {
195
221
  fileCount: bundle.fileCount,
196
222
  byteCount: bundle.byteCount,
@@ -201,6 +227,103 @@ export async function deployArtifactApp(opts, input) {
201
227
  deploy: deployed
202
228
  };
203
229
  }
230
+ async function callHostedBuildTool(opts, name, input) {
231
+ if (mcpSurface(opts) !== "build")
232
+ throw new Error(`${name}_requires_build_surface`);
233
+ const headers = await jsonHeaders(opts);
234
+ const payload = asRecord(await fetchJson(opts, `${platformBaseUrl(opts)}/mcp/build`, {
235
+ method: "POST",
236
+ headers: { ...headers, accept: "application/json, text/event-stream" },
237
+ body: JSON.stringify({
238
+ jsonrpc: "2.0",
239
+ id: `stdio-${Date.now()}`,
240
+ method: "tools/call",
241
+ params: { name, arguments: input }
242
+ })
243
+ }));
244
+ if (payload.error) {
245
+ const error = asRecord(payload.error);
246
+ throw new Error(`platform_mcp_tool_error:${String(error.message ?? "unknown_error")}`);
247
+ }
248
+ const result = asRecord(payload.result);
249
+ if (result.isError === true) {
250
+ const content = Array.isArray(result.content) ? result.content.filter(isRecord) : [];
251
+ const message = content.find((item) => item.type === "text" && typeof item.text === "string")?.text;
252
+ throw new Error(`platform_mcp_tool_error:${String(message ?? "tool_failed")}`);
253
+ }
254
+ if (isRecord(result.structuredContent))
255
+ return result.structuredContent;
256
+ throw new Error(`platform_mcp_tool_output_missing:${name}`);
257
+ }
258
+ export async function waitForArtifactDeploy(opts, input, onProgress) {
259
+ const artifactId = stringArg(input, "artifactId");
260
+ const operationId = stringArg(input, "operationId");
261
+ const timeoutSeconds = Math.min(Math.max(numberArg(input, "timeoutSeconds") ?? 300, 1), 900);
262
+ const deadline = Date.now() + timeoutSeconds * 1000;
263
+ let afterSeq = 0;
264
+ let latest = {};
265
+ while (Date.now() < deadline) {
266
+ latest = asRecord(await callHostedBuildTool(opts, "platform_deploy_operation", { artifactId, operationId, afterSeq }));
267
+ const progress = typeof latest.progress === "number" ? latest.progress : 0.7;
268
+ const events = Array.isArray(latest.events) ? latest.events.filter(isRecord) : [];
269
+ const eventMessage = [...events].reverse().find((event) => typeof event.message === "string")?.message;
270
+ await onProgress?.(progress, typeof eventMessage === "string" ? eventMessage : `Artifact deployment: ${String(latest.phase ?? latest.taskStatus ?? "running")}`);
271
+ if (typeof latest.nextSeq === "number")
272
+ afterSeq = latest.nextSeq;
273
+ if (latest.terminal === true) {
274
+ if (latest.successful !== true)
275
+ throw new Error(`artifact_deploy_failed:${String(latest.taskStatus ?? "failed")}`);
276
+ return latest;
277
+ }
278
+ const waitMs = typeof latest.nextPollAfterMs === "number" ? Math.min(Math.max(latest.nextPollAfterMs, 250), 10_000) : 2_000;
279
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
280
+ }
281
+ throw new Error(`artifact_deploy_wait_timeout:${operationId}`);
282
+ }
283
+ export async function initArtifactApp(opts, input) {
284
+ const appDir = stringArg(input, "appDir");
285
+ const title = optionalString(input, "title") ?? "Topline artifact";
286
+ const manifestId = (optionalString(input, "manifestId") ?? path.basename(appDir))
287
+ .toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
288
+ if (!manifestId || manifestId.length > 80)
289
+ throw new Error("artifact_manifest_id_invalid");
290
+ const absoluteAppDir = resolveWorkspacePath(opts.workspaceRoot, appDir);
291
+ if (existsSync(absoluteAppDir) && (await readdir(absoluteAppDir)).length > 0)
292
+ throw new Error("artifact_app_scaffold_target_not_empty");
293
+ await mkdir(path.join(absoluteAppDir, "src"), { recursive: true });
294
+ const manifest = {
295
+ manifestVersion: 2,
296
+ id: manifestId,
297
+ title,
298
+ kind: "tool",
299
+ runtime: { target: "aws_lambda_function_url", region: "us-west-2", entryPoint: "artifactApp" },
300
+ build: { command: "node src/index.js --build", outputDir: "dist" },
301
+ validation: { localCommand: "node src/index.js --test", testCommand: "node src/index.js --test --artifact-tests-json" },
302
+ routes: [
303
+ { path: "/", kind: "page", handler: "artifactApp" },
304
+ { path: "/_artifact/health", kind: "api", handler: "artifactApp" }
305
+ ],
306
+ sourceBindings: [],
307
+ env: ["ARTIFACT_ID", "ARTIFACT_VERSION_ID", "ARTIFACT_MANIFEST_URI", "PLATFORM_ARTIFACT_ID", "PLATFORM_ARTIFACT_VERSION_ID", "PLATFORM_ARTIFACT_MANIFEST_URI"],
308
+ viewerPolicy: "private"
309
+ };
310
+ const packageJson = { name: manifestId, private: true, type: "module", main: "src/index.js", scripts: { build: "node src/index.js --build", test: "node src/index.js --test" } };
311
+ const safeTitle = title.replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
312
+ const source = `import { mkdirSync, writeFileSync } from "node:fs";\nimport path from "node:path";\n\nconst page = ${JSON.stringify(`<!doctype html><html><body><main data-platform-artifact-root><h1>${safeTitle}</h1><p>Ready.</p></main></body></html>`)};\n\nexport function artifactApp(_req, res) {\n res.setHeader("content-type", "text/html; charset=utf-8");\n res.end(page);\n}\n\nif (process.argv.includes("--build")) {\n mkdirSync("dist", { recursive: true });\n writeFileSync(path.join("dist", "index.html"), page, "utf8");\n}\n\nif (process.argv.includes("--test") && process.argv.includes("--artifact-tests-json")) {\n console.log(JSON.stringify({ tests: [{ description: "The artifact renders a visible app root.", required: true, status: "passed", summary: "The page includes data-platform-artifact-root.", evidence: { selector: "data-platform-artifact-root" } }] }));\n} else if (process.argv.includes("--build") || process.argv.includes("--test")) {\n console.log("artifact app template ok");\n}\n`;
313
+ await Promise.all([
314
+ writeFile(path.join(absoluteAppDir, "artifact-app.json"), `${JSON.stringify(manifest, null, 2)}\n`, { flag: "wx" }),
315
+ writeFile(path.join(absoluteAppDir, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`, { flag: "wx" }),
316
+ writeFile(path.join(absoluteAppDir, "src", "index.js"), source, { flag: "wx" })
317
+ ]);
318
+ return {
319
+ ok: true,
320
+ appDir: workspaceRelativeAppDir(opts.workspaceRoot, absoluteAppDir),
321
+ manifestId,
322
+ title,
323
+ 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.`
325
+ };
326
+ }
204
327
  export async function getArtifactStatus(opts, artifactId, limit) {
205
328
  const query = new URLSearchParams();
206
329
  if (limit)
@@ -444,6 +567,10 @@ async function fetchJson(opts, url, init) {
444
567
  payload = { detail: text.slice(0, 500) };
445
568
  }
446
569
  if (!response.ok) {
570
+ const code = isRecord(payload) && typeof payload.error === "string" ? payload.error : null;
571
+ if (code === "artifact_source_git_app_dir_mismatch") {
572
+ throw new Error("artifact_source_git_app_dir_mismatch: Use the same local app/source root as the existing artifact, or deploy without artifactId to create a new draft.");
573
+ }
447
574
  throw new Error(`platform_harness_http_error:${response.status}:${redactSecrets(JSON.stringify(payload), opts)}`);
448
575
  }
449
576
  return payload;
@@ -460,6 +587,42 @@ async function fetchJson(opts, url, init) {
460
587
  export function listTools() {
461
588
  return {
462
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
+ }
625
+ },
463
626
  {
464
627
  name: "platform_deploy_artifact_app",
465
628
  description: "Package a local artifact app directory, upload it, deploy a private draft, and return status.",
@@ -471,7 +634,8 @@ export function listTools() {
471
634
  artifactId: { type: "string" },
472
635
  title: { type: "string" },
473
636
  description: { type: "string" },
474
- projectId: { type: "string" }
637
+ projectId: { type: "string" },
638
+ requestId: { type: "string", minLength: 1, maxLength: 128, description: "Optional stable retry key; generated deterministically when omitted." }
475
639
  },
476
640
  required: ["appDir"],
477
641
  additionalProperties: false
@@ -482,13 +646,43 @@ export function listTools() {
482
646
  ok: { type: "boolean" },
483
647
  artifactId: { type: "string" },
484
648
  artifactVersionId: { type: "string" },
649
+ clientRequestId: { type: "string" },
650
+ operationId: { type: ["string", "null"] },
485
651
  bundle: { type: "object" },
486
652
  create: { type: "object" },
487
653
  deploy: { type: "object" }
488
654
  },
489
- required: ["ok", "artifactId", "artifactVersionId", "bundle", "create", "deploy"]
655
+ required: ["ok", "artifactId", "artifactVersionId", "clientRequestId", "operationId", "bundle", "create", "deploy"]
490
656
  }
491
657
  },
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"] }
664
+ },
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"] }
671
+ },
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"] }
678
+ },
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"] }
685
+ },
492
686
  {
493
687
  name: "platform_artifact_status",
494
688
  description: "Read private draft deployment status for a hosted artifact app.",
@@ -576,8 +770,98 @@ export function listTools() {
576
770
  },
577
771
  required: ["ok", "storybookUrl", "mcpUrl", "mcpServers"]
578
772
  }
773
+ },
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
+ }
797
+ },
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"] }
804
+ },
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"] }
811
+ },
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
+ }
579
831
  }
580
- ]
832
+ ].map(withRecoveryOutputSchema)
833
+ };
834
+ }
835
+ function withRecoveryOutputSchema(tool) {
836
+ if (!tool.outputSchema)
837
+ return tool;
838
+ return {
839
+ ...tool,
840
+ outputSchema: {
841
+ type: "object",
842
+ oneOf: [
843
+ tool.outputSchema,
844
+ {
845
+ type: "object",
846
+ properties: {
847
+ ok: { const: false },
848
+ error: {
849
+ type: "object",
850
+ properties: {
851
+ code: { type: "string" },
852
+ message: { type: "string" },
853
+ retryable: { type: "boolean" },
854
+ recoveryAction: { type: "string" }
855
+ },
856
+ required: ["code", "message", "retryable", "recoveryAction"],
857
+ additionalProperties: false
858
+ }
859
+ },
860
+ required: ["ok", "error"],
861
+ additionalProperties: false
862
+ }
863
+ ]
864
+ }
581
865
  };
582
866
  }
583
867
  function mcpToolAnnotations(name, policy) {
@@ -603,7 +887,39 @@ function toolResult(value) {
603
887
  return { content, structuredContent };
604
888
  }
605
889
  function toolError(message) {
606
- return { content: [{ type: "text", text: message }], isError: true };
890
+ return structuredToolError(message);
891
+ }
892
+ function structuredToolError(message) {
893
+ 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." };
903
+ const structuredContent = { ok: false, error: { code, message, ...selected } };
904
+ return {
905
+ content: [{ type: "text", text: JSON.stringify(structuredContent, null, 2) }],
906
+ structuredContent,
907
+ isError: true
908
+ };
909
+ }
910
+ function recoveryErrorCode(message) {
911
+ const known = [
912
+ "artifact_delete_plan_stale",
913
+ "artifact_deploy_wait_timeout",
914
+ "artifact_app_scaffold_target_not_empty",
915
+ "platform_harness_timeout",
916
+ "platform_mcp_tool_error",
917
+ "scope_required"
918
+ ].find((candidate) => message.includes(candidate));
919
+ if (known)
920
+ return known;
921
+ const match = message.match(/(?:^|:)([a-z][a-z0-9_]{2,})(?::|$)/);
922
+ return match?.[1] ?? "topline_mcp_tool_failed";
607
923
  }
608
924
  const jsonSchemaValidator = new AjvJsonSchemaValidator();
609
925
  function validateJsonSchema(schema, value) {
@@ -890,6 +1206,15 @@ function platformBaseUrl(opts) {
890
1206
  function mcpSurface(opts) {
891
1207
  return opts.surface ?? "build";
892
1208
  }
1209
+ function logicalArtifactAppDir(workspaceAppDir, manifest) {
1210
+ if (workspaceAppDir === "." || workspaceAppDir.startsWith("artifact-apps/"))
1211
+ return workspaceAppDir;
1212
+ const manifestId = typeof manifest.id === "string" ? manifest.id.trim() : "";
1213
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,126}[A-Za-z0-9])?$/.test(manifestId)) {
1214
+ throw new Error("artifact_app_manifest_id_not_safe_for_source_root");
1215
+ }
1216
+ return `artifact-apps/${manifestId}`;
1217
+ }
893
1218
  function defaultAuthFile(apiUrl, surface) {
894
1219
  const key = createHash("sha256").update(`${platformBaseUrl({ apiUrl })}:${surface}`).digest("hex").slice(0, 16);
895
1220
  return path.join(os.homedir(), ".topline", "build-mcp", `${key}.json`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topline-build/harness-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Topline Build MCP for creating and deploying artifact app drafts.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -35,7 +35,8 @@
35
35
  "mcp:stdio": "node dist/cli.js",
36
36
  "prepack": "npm run build",
37
37
  "test": "vitest run",
38
- "typecheck": "tsc --noEmit -p tsconfig.json"
38
+ "typecheck": "tsc --noEmit -p tsconfig.json",
39
+ "certify:live": "node scripts/certify-live.mjs"
39
40
  },
40
41
  "dependencies": {
41
42
  "@modelcontextprotocol/sdk": "^1.29.0"