@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 +53 -0
- package/dist/index.d.ts +18 -3
- package/dist/index.js +345 -20
- package/package.json +3 -2
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
|
-
|
|
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
|
|
38
|
-
|
|
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.
|
|
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
|
-
|
|
106
|
-
|
|
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
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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
|
|
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.
|
|
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"
|