@topline-build/harness-mcp 0.1.2 → 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
@@ -1,4 +1,4 @@
1
- # Topline Harness MCP
1
+ # Topline Build MCP
2
2
 
3
3
  Local stdio MCP connector for deploying Topline artifact app drafts from Codex,
4
4
  Claude Code, or another local MCP host.
@@ -13,6 +13,7 @@ Required environment:
13
13
 
14
14
  Optional environment:
15
15
 
16
+ - `TOPLINE_MCP_SURFACE` (`build` by default; `harness` for legacy compatibility)
16
17
  - `TOPLINE_MCP_PROTOCOL_VERSION`
17
18
  - `TOPLINE_WORKSPACE_ROOT`
18
19
  - `TOPLINE_MCP_AUTH_FILE`
@@ -43,6 +44,85 @@ npx -y @topline-build/harness-mcp --url https://hawx.topline.build
43
44
  Set `TOPLINE_WORKSPACE_ROOT` when your MCP host starts outside the repo that
44
45
  contains the artifact app.
45
46
 
46
- Topline Developer Access provides harness-specific snippets for Codex and
47
- Claude Code. Those snippets contain no raw token; the first tool call opens a
48
- browser approval flow.
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
+
56
+ Topline Developer Access provides Build MCP snippets for Codex and Claude Code.
57
+ Those snippets contain no raw token; the first tool call opens a browser
58
+ approval flow scoped to artifact building.
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
+
104
+ ## Storybook
105
+
106
+ Use the `platform_storybook_access` tool to get the tenant Storybook preview URL
107
+ and the HTTP MCP client config. For clients with HTTP MCP OAuth support, use
108
+ the returned `cursorMcpJson`:
109
+
110
+ ```json
111
+ {
112
+ "mcpServers": {
113
+ "storybook": {
114
+ "url": "https://your-topline-host/api/design-systems/storybook/mcp",
115
+ "type": "http"
116
+ }
117
+ }
118
+ }
119
+ ```
120
+
121
+ Storybook MCP access uses the harness bearer/OAuth credentials with
122
+ `harness:artifact:read`; OAuth discovery is available at
123
+ `/.well-known/oauth-protected-resource/mcp/build`.
124
+
125
+ If your MCP client does not support HTTP MCP OAuth, use the returned
126
+ `manualBearerMcpJson` template with a read-scoped `TOPLINE_MCP_TOKEN` in the
127
+ client's secret mechanism. Opening the visual preview URL in a browser still
128
+ requires a signed-in Topline session.
package/dist/index.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js";
2
- import { type CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
+ import { type CallToolResult, type ListToolsResult } from "@modelcontextprotocol/sdk/types.js";
3
3
  export interface HarnessMcpStdioOptions {
4
4
  apiUrl: string;
5
+ /** Defaults to the Build MCP. Use harness only for legacy deployments. */
6
+ surface?: "build" | "harness";
5
7
  token?: string;
6
8
  authFile: string;
7
9
  oauthScopes: string[];
@@ -27,17 +29,22 @@ export interface PackedArtifactAppBundle {
27
29
  sha256: string;
28
30
  excluded: string[];
29
31
  }
30
- export declare const HARNESS_MCP_VERSION = "0.1.2";
32
+ type ToolProgressReporter = (progress: number, message: string) => void | Promise<void>;
33
+ export declare const HARNESS_MCP_VERSION = "0.4.0";
31
34
  export declare function parseHarnessOptions(argv: string[], env?: Record<string, string | undefined>): HarnessMcpStdioOptions;
32
35
  export declare function runHarnessMcpStdioCli(): Promise<void>;
33
36
  export declare function cliInfo(argv: string[]): string | null;
34
37
  export declare function createHarnessMcpStdioServer(opts: HarnessMcpStdioOptions): McpServer;
35
- export declare function callHarnessTool(opts: HarnessMcpStdioOptions, name: string, args: unknown): Promise<CallToolResult>;
36
- 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<{
37
42
  ok: boolean;
38
43
  artifactId: string;
39
44
  artifactVersionId: string;
40
45
  manifestId: string | null;
46
+ clientRequestId: string;
47
+ operationId: string | null;
41
48
  bundle: {
42
49
  fileCount: number;
43
50
  byteCount: number;
@@ -47,6 +54,15 @@ export declare function deployArtifactApp(opts: HarnessMcpStdioOptions, input: R
47
54
  create: unknown;
48
55
  deploy: unknown;
49
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
+ }>;
50
66
  export declare function getArtifactStatus(opts: HarnessMcpStdioOptions, artifactId: string, limit?: number): Promise<unknown>;
51
67
  export declare function readArtifactLogs(opts: HarnessMcpStdioOptions, input: Record<string, unknown>): Promise<unknown>;
52
68
  export declare function openArtifact(opts: HarnessMcpStdioOptions, artifactId: string): Promise<{
@@ -72,9 +88,50 @@ export declare function openArtifact(opts: HarnessMcpStdioOptions, artifactId: s
72
88
  liveUrl: string | null;
73
89
  versionUrl: string | null;
74
90
  }>;
91
+ export declare function storybookAccess(opts: HarnessMcpStdioOptions): {
92
+ ok: boolean;
93
+ storybookUrl: string;
94
+ previewUrl: string;
95
+ mcpUrl: string;
96
+ mcpServers: {
97
+ storybook: {
98
+ url: string;
99
+ type: string;
100
+ };
101
+ };
102
+ cursorMcpJson: {
103
+ mcpServers: {
104
+ storybook: {
105
+ url: string;
106
+ type: string;
107
+ };
108
+ };
109
+ };
110
+ manualBearerMcpJson: {
111
+ mcpServers: {
112
+ storybook: {
113
+ url: string;
114
+ type: string;
115
+ headers: {
116
+ Authorization: string;
117
+ };
118
+ };
119
+ };
120
+ };
121
+ auth: {
122
+ mcp: string;
123
+ preview: string;
124
+ resourceMetadataUrl: string;
125
+ scope: string;
126
+ manualBearerEnv: string;
127
+ };
128
+ operatorAction: string;
129
+ };
75
130
  export declare function packArtifactAppDirectory(appDir: string, archivePrefix?: string): Promise<PackedArtifactAppBundle>;
131
+ export declare function listTools(): ListToolsResult;
76
132
  export declare function getBearerToken(opts: HarnessMcpStdioOptions): Promise<string>;
77
133
  export declare function browserOpenCommand(url: string, platform?: string): {
78
134
  command: string;
79
135
  args: string[];
80
136
  };
137
+ export {};
package/dist/index.js CHANGED
@@ -8,12 +8,13 @@ import path from "node:path";
8
8
  import { gzipSync } from "node:zlib";
9
9
  import { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js";
10
10
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
- import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
11
+ import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
12
+ import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from "@modelcontextprotocol/sdk/types.js";
12
13
  const DEFAULT_MCP_PROTOCOL_VERSION = "2025-11-25";
13
14
  const DEFAULT_TIMEOUT_MS = 120_000;
14
- const SERVER_NAME = "topline-harness-stdio";
15
- export const HARNESS_MCP_VERSION = "0.1.2";
16
- const DEFAULT_OAUTH_SCOPES = ["harness:artifact:read", "harness:artifact:write", "harness:artifact:deploy"];
15
+ const SERVER_NAME = "topline-build-stdio";
16
+ export const HARNESS_MCP_VERSION = "0.4.0";
17
+ const DEFAULT_OAUTH_SCOPES = ["harness:artifact:read", "harness:artifact:write", "harness:artifact:deploy", "harness:artifact:delete"];
17
18
  const BLOCK_SIZE = 512;
18
19
  const MAX_BUNDLE_BYTES = 64 * 1024 * 1024;
19
20
  const MAX_UNPACKED_BYTES = 512 * 1024 * 1024;
@@ -34,6 +35,9 @@ export function parseHarnessOptions(argv, env = process.env) {
34
35
  const token = getArg("--token") || firstEnv(env, "TOPLINE_MCP_TOKEN", "PLATFORM_MCP_TOKEN", "OVERSITE_MCP_TOKEN") || undefined;
35
36
  const timeoutRaw = getArg("--timeout-ms") || firstEnv(env, "TOPLINE_MCP_TIMEOUT_MS", "PLATFORM_MCP_TIMEOUT_MS", "OVERSITE_MCP_TIMEOUT_MS");
36
37
  const protocolVersion = getArg("--protocol-version") || firstEnv(env, "TOPLINE_MCP_PROTOCOL_VERSION", "PLATFORM_MCP_PROTOCOL_VERSION") || DEFAULT_MCP_PROTOCOL_VERSION;
38
+ const surfaceRaw = getArg("--surface") || firstEnv(env, "TOPLINE_MCP_SURFACE") || "build";
39
+ if (surfaceRaw !== "build" && surfaceRaw !== "harness")
40
+ throw new Error("TOPLINE_MCP_SURFACE must be build or harness");
37
41
  const workspaceRoot = path.resolve(getArg("--workspace-root") || firstEnv(env, "TOPLINE_WORKSPACE_ROOT", "PLATFORM_WORKSPACE_ROOT") || process.cwd());
38
42
  const timeoutMs = timeoutRaw ? Number(timeoutRaw) : DEFAULT_TIMEOUT_MS;
39
43
  if (!apiUrl)
@@ -41,13 +45,14 @@ export function parseHarnessOptions(argv, env = process.env) {
41
45
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
42
46
  throw new Error("TOPLINE_MCP_TIMEOUT_MS must be a positive number");
43
47
  const normalizedApiUrl = normalizeApiUrl(apiUrl);
44
- const authFile = path.resolve(getArg("--auth-file") || firstEnv(env, "TOPLINE_MCP_AUTH_FILE", "PLATFORM_MCP_AUTH_FILE") || defaultAuthFile(normalizedApiUrl));
48
+ const authFile = path.resolve(getArg("--auth-file") || firstEnv(env, "TOPLINE_MCP_AUTH_FILE", "PLATFORM_MCP_AUTH_FILE") || defaultAuthFile(normalizedApiUrl, surfaceRaw));
45
49
  const scopes = (getArg("--scopes") || firstEnv(env, "TOPLINE_MCP_SCOPES", "PLATFORM_MCP_SCOPES") || DEFAULT_OAUTH_SCOPES.join(" "))
46
50
  .split(/[\s,]+/)
47
51
  .map((scope) => scope.trim())
48
52
  .filter(Boolean);
49
53
  return {
50
54
  apiUrl: normalizedApiUrl,
55
+ surface: surfaceRaw,
51
56
  token,
52
57
  authFile,
53
58
  oauthScopes: scopes.length > 0 ? [...new Set(scopes)] : DEFAULT_OAUTH_SCOPES,
@@ -78,6 +83,7 @@ export function cliInfo(argv) {
78
83
  "Environment:",
79
84
  " TOPLINE_API_URL Required unless --url is passed",
80
85
  " TOPLINE_WORKSPACE_ROOT Defaults to the current working directory",
86
+ " TOPLINE_MCP_SURFACE Defaults to build; harness keeps legacy OAuth compatibility",
81
87
  " TOPLINE_MCP_TOKEN Optional manual token fallback",
82
88
  " TOPLINE_MCP_AUTH_FILE Optional OAuth credential cache path",
83
89
  " TOPLINE_MCP_TIMEOUT_MS Defaults to 120000",
@@ -90,40 +96,86 @@ export function cliInfo(argv) {
90
96
  export function createHarnessMcpStdioServer(opts) {
91
97
  const mcp = new McpServer({ name: SERVER_NAME, version: HARNESS_MCP_VERSION }, { capabilities: { tools: {} } });
92
98
  mcp.setRequestHandler(ListToolsRequestSchema, async () => listTools());
93
- mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
99
+ mcp.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
100
+ const reportsProgress = req.params.name === "platform_deploy_artifact_app" || req.params.name === "platform_wait_artifact_deploy";
94
101
  try {
95
- return await callHarnessTool(opts, req.params.name, req.params.arguments ?? {});
102
+ const result = await callHarnessTool(opts, req.params.name, req.params.arguments ?? {}, reportsProgress
103
+ ? { onProgress: (progress, message) => sendProgress(extra, progress, 1, message) }
104
+ : undefined);
105
+ return result;
96
106
  }
97
107
  catch (err) {
98
- return {
99
- content: [{ type: "text", text: redactSecrets(err instanceof Error ? err.message : String(err), opts) }],
100
- isError: true
101
- };
108
+ if (err instanceof McpError)
109
+ throw err;
110
+ if (reportsProgress)
111
+ await sendProgress(extra, 1, 1, "Failed artifact deployment");
112
+ return structuredToolError(redactSecrets(err instanceof Error ? err.message : String(err), opts));
102
113
  }
103
114
  });
104
115
  return mcp;
105
116
  }
106
- export async function callHarnessTool(opts, name, args) {
117
+ export async function callHarnessTool(opts, name, args, hooks) {
107
118
  const input = isRecord(args) ? args : {};
119
+ const tool = listTools().tools.find((candidate) => candidate.name === name);
120
+ if (!tool)
121
+ throw new McpError(ErrorCode.InvalidParams, `Unknown tool: ${name}`);
122
+ const inputError = validateJsonSchema(tool.inputSchema, input);
123
+ if (inputError)
124
+ return toolError(`Invalid arguments for ${name}: ${inputError}`);
125
+ let result;
108
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;
109
137
  case "platform_deploy_artifact_app":
110
- return toolResult(await deployArtifactApp(opts, input));
138
+ result = toolResult(await deployArtifactApp(opts, input, hooks?.onProgress));
139
+ break;
111
140
  case "platform_artifact_status":
112
- return toolResult(await getArtifactStatus(opts, stringArg(input, "artifactId"), numberArg(input, "limit")));
141
+ result = toolResult(await getArtifactStatus(opts, stringArg(input, "artifactId"), numberArg(input, "limit")));
142
+ break;
113
143
  case "platform_read_artifact_logs":
114
- return toolResult(await readArtifactLogs(opts, input));
144
+ result = toolResult(await readArtifactLogs(opts, input));
145
+ break;
115
146
  case "platform_open_artifact":
116
- return toolResult(await openArtifact(opts, stringArg(input, "artifactId")));
147
+ result = toolResult(await openArtifact(opts, stringArg(input, "artifactId")));
148
+ break;
149
+ case "platform_storybook_access":
150
+ result = toolResult(storybookAccess(opts));
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;
117
158
  default:
118
- return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
159
+ throw new McpError(ErrorCode.InvalidParams, `Unknown tool: ${name}`);
119
160
  }
161
+ const outputError = tool.outputSchema
162
+ ? validateJsonSchema(tool.outputSchema, result.structuredContent)
163
+ : null;
164
+ return outputError ? toolError(`Invalid output for ${name}: ${outputError}`) : result;
120
165
  }
121
- export async function deployArtifactApp(opts, input) {
166
+ export async function deployArtifactApp(opts, input, onProgress) {
167
+ await onProgress?.(0, "Reading artifact manifest");
122
168
  const appDir = stringArg(input, "appDir");
123
169
  const absoluteAppDir = resolveWorkspacePath(opts.workspaceRoot, appDir);
124
- const platformAppDir = workspaceRelativeAppDir(opts.workspaceRoot, absoluteAppDir);
170
+ const workspaceAppDir = workspaceRelativeAppDir(opts.workspaceRoot, absoluteAppDir);
125
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");
126
177
  const bundle = await packArtifactAppDirectory(absoluteAppDir, platformAppDir === "." ? "" : platformAppDir);
178
+ await onProgress?.(0.4, "Creating private artifact draft");
127
179
  const created = await fetchJson(opts, artifactAppsPath(opts), {
128
180
  method: "POST",
129
181
  headers: await jsonHeaders(opts),
@@ -132,6 +184,8 @@ export async function deployArtifactApp(opts, input) {
132
184
  title: optionalString(input, "title"),
133
185
  description: optionalString(input, "description"),
134
186
  projectId: optionalString(input, "projectId"),
187
+ clientRequestId,
188
+ bundleSha256: bundle.sha256,
135
189
  manifest
136
190
  })
137
191
  });
@@ -141,6 +195,7 @@ export async function deployArtifactApp(opts, input) {
141
195
  const artifactId = stringValue(createdRecord.artifactId, "artifactId");
142
196
  const artifactVersionId = stringValue(createdRecord.artifactVersionId, "artifactVersionId");
143
197
  const bearerToken = await getBearerToken(opts);
198
+ await onProgress?.(0.6, "Uploading bundle and waiting for trusted draft deployment");
144
199
  const deployed = await fetchJson(opts, `${artifactAppsPath(opts)}/${encodeURIComponent(artifactId)}/deploy-bundle`, {
145
200
  method: "POST",
146
201
  headers: {
@@ -152,11 +207,16 @@ export async function deployArtifactApp(opts, input) {
152
207
  },
153
208
  body: bundle.body
154
209
  });
210
+ await onProgress?.(1, "Completed trusted draft deployment");
211
+ const deployedRecord = asRecord(deployed);
212
+ const operationId = typeof deployedRecord.taskId === "string" ? deployedRecord.taskId : null;
155
213
  return {
156
214
  ok: true,
157
215
  artifactId,
158
216
  artifactVersionId,
159
217
  manifestId: typeof manifest.id === "string" ? manifest.id : null,
218
+ clientRequestId,
219
+ operationId,
160
220
  bundle: {
161
221
  fileCount: bundle.fileCount,
162
222
  byteCount: bundle.byteCount,
@@ -167,6 +227,103 @@ export async function deployArtifactApp(opts, input) {
167
227
  deploy: deployed
168
228
  };
169
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
+ }
170
327
  export async function getArtifactStatus(opts, artifactId, limit) {
171
328
  const query = new URLSearchParams();
172
329
  if (limit)
@@ -223,6 +380,36 @@ export async function openArtifact(opts, artifactId) {
223
380
  versionUrl: typeof draft?.versionUrl === "string" ? draft.versionUrl : typeof live?.versionUrl === "string" ? live.versionUrl : null
224
381
  };
225
382
  }
383
+ export function storybookAccess(opts) {
384
+ const baseUrl = platformBaseUrl(opts);
385
+ const storybookUrl = `${baseUrl}/api/design-systems/storybook/`;
386
+ const mcpUrl = `${baseUrl}/api/design-systems/storybook/mcp`;
387
+ const mcpServers = { storybook: { url: mcpUrl, type: "http" } };
388
+ const manualBearerMcpServers = {
389
+ storybook: {
390
+ url: mcpUrl,
391
+ type: "http",
392
+ headers: { Authorization: "Bearer <TOPLINE_MCP_TOKEN>" }
393
+ }
394
+ };
395
+ return {
396
+ ok: true,
397
+ storybookUrl,
398
+ previewUrl: storybookUrl,
399
+ mcpUrl,
400
+ mcpServers,
401
+ cursorMcpJson: { mcpServers },
402
+ manualBearerMcpJson: { mcpServers: manualBearerMcpServers },
403
+ auth: {
404
+ mcp: "topline_harness_bearer_with_harness_artifact_read",
405
+ preview: "topline_browser_session",
406
+ resourceMetadataUrl: `${baseUrl}/.well-known/oauth-protected-resource/mcp/${mcpSurface(opts)}`,
407
+ scope: "harness:artifact:read",
408
+ manualBearerEnv: "TOPLINE_MCP_TOKEN"
409
+ },
410
+ operatorAction: "Use cursorMcpJson for MCP clients with HTTP OAuth support. If your client requires static headers, use manualBearerMcpJson with a read-scoped TOPLINE_MCP_TOKEN. Open the preview URL in a browser signed in to Topline."
411
+ };
412
+ }
226
413
  export async function packArtifactAppDirectory(appDir, archivePrefix = "") {
227
414
  const root = path.resolve(appDir);
228
415
  const prefix = archivePrefix ? validateArchivePrefix(archivePrefix) : "";
@@ -380,6 +567,10 @@ async function fetchJson(opts, url, init) {
380
567
  payload = { detail: text.slice(0, 500) };
381
568
  }
382
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
+ }
383
574
  throw new Error(`platform_harness_http_error:${response.status}:${redactSecrets(JSON.stringify(payload), opts)}`);
384
575
  }
385
576
  return payload;
@@ -393,72 +584,359 @@ async function fetchJson(opts, url, init) {
393
584
  clearTimeout(timeout);
394
585
  }
395
586
  }
396
- function listTools() {
587
+ export function listTools() {
397
588
  return {
398
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
+ },
399
626
  {
400
627
  name: "platform_deploy_artifact_app",
401
628
  description: "Package a local artifact app directory, upload it, deploy a private draft, and return status.",
629
+ annotations: mcpToolAnnotations("platform_deploy_artifact_app", { readOnly: false, openWorld: true }),
402
630
  inputSchema: {
403
631
  type: "object",
404
632
  properties: {
405
- appDir: { type: "string" },
633
+ appDir: { type: "string", minLength: 1 },
406
634
  artifactId: { type: "string" },
407
635
  title: { type: "string" },
408
636
  description: { type: "string" },
409
- projectId: { 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" }
410
654
  },
411
- required: ["appDir"]
655
+ required: ["ok", "artifactId", "artifactVersionId", "clientRequestId", "operationId", "bundle", "create", "deploy"]
412
656
  }
413
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
+ },
414
686
  {
415
687
  name: "platform_artifact_status",
416
688
  description: "Read private draft deployment status for a hosted artifact app.",
689
+ annotations: mcpToolAnnotations("platform_artifact_status", { readOnly: true, openWorld: true }),
417
690
  inputSchema: {
418
691
  type: "object",
419
- properties: { artifactId: { type: "string" }, limit: { type: "number" } },
420
- required: ["artifactId"]
692
+ properties: { artifactId: { type: "string", minLength: 1 }, limit: { type: "integer", minimum: 1, maximum: 50 } },
693
+ required: ["artifactId"],
694
+ additionalProperties: false
695
+ },
696
+ outputSchema: {
697
+ type: "object",
698
+ properties: {
699
+ ok: { type: "boolean" }, phase: { type: "string" }, artifactId: { type: "string" },
700
+ deployments: { type: "array", items: { type: "object" } }
701
+ },
702
+ required: ["ok", "phase", "artifactId", "deployments"]
421
703
  }
422
704
  },
423
705
  {
424
706
  name: "platform_read_artifact_logs",
425
707
  description: "Read bounded, redacted deployment logs for a hosted artifact app.",
708
+ annotations: mcpToolAnnotations("platform_read_artifact_logs", { readOnly: true, openWorld: true }),
426
709
  inputSchema: {
427
710
  type: "object",
428
711
  properties: {
429
- artifactId: { type: "string" },
712
+ artifactId: { type: "string", minLength: 1 },
430
713
  deploymentRecordId: { type: "string" },
431
714
  environment: { type: "string", enum: ["draft", "live"] },
432
- limit: { type: "number" },
715
+ limit: { type: "integer", minimum: 1, maximum: 200 },
433
716
  severity: { type: "string" },
434
717
  contains: { type: "string" },
435
- since: { type: "string" },
436
- windowMinutes: { type: "number" },
718
+ since: { type: "string", format: "date-time" },
719
+ windowMinutes: { type: "integer", minimum: 1, maximum: 1440 },
437
720
  trace: { type: "string" },
438
721
  requestId: { type: "string" },
439
722
  route: { type: "string" }
440
723
  },
441
- required: ["artifactId"]
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"]
442
736
  }
443
737
  },
444
738
  {
445
739
  name: "platform_open_artifact",
446
740
  description: "Return the operator URL and latest draft/live URLs for a hosted artifact app.",
741
+ annotations: mcpToolAnnotations("platform_open_artifact", { readOnly: true, openWorld: true }),
447
742
  inputSchema: {
448
743
  type: "object",
449
- properties: { artifactId: { type: "string" } },
450
- required: ["artifactId"]
744
+ properties: { artifactId: { type: "string", minLength: 1 } },
745
+ required: ["artifactId"],
746
+ additionalProperties: false
747
+ },
748
+ outputSchema: {
749
+ type: "object",
750
+ properties: {
751
+ ok: { type: "boolean" }, artifactId: { type: "string" }, openUrl: { type: "string" },
752
+ draftUrl: { type: ["string", "null"] }, liveUrl: { type: ["string", "null"] }, versionUrl: { type: ["string", "null"] }
753
+ },
754
+ required: ["ok", "artifactId", "openUrl", "draftUrl", "liveUrl", "versionUrl"]
755
+ }
756
+ },
757
+ {
758
+ name: "platform_storybook_access",
759
+ description: "Return the tenant Storybook preview URL and HTTP MCP server config.",
760
+ annotations: mcpToolAnnotations("platform_storybook_access", { readOnly: true }),
761
+ inputSchema: {
762
+ type: "object",
763
+ properties: {},
764
+ additionalProperties: false
765
+ },
766
+ outputSchema: {
767
+ type: "object",
768
+ properties: {
769
+ ok: { type: "boolean" }, storybookUrl: { type: "string" }, mcpUrl: { type: "string" }, mcpServers: { type: "object" }
770
+ },
771
+ required: ["ok", "storybookUrl", "mcpUrl", "mcpServers"]
772
+ }
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"]
451
830
  }
452
831
  }
453
- ]
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
+ }
865
+ };
866
+ }
867
+ function mcpToolAnnotations(name, policy) {
868
+ return {
869
+ title: name.split("_").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" "),
870
+ readOnlyHint: policy.readOnly,
871
+ destructiveHint: policy.destructive ?? false,
872
+ ...(policy.idempotent ? { idempotentHint: true } : {}),
873
+ openWorldHint: policy.openWorld ?? false
454
874
  };
455
875
  }
456
876
  function toolResult(value) {
877
+ const structuredContent = value;
878
+ const content = [{ type: "text", text: JSON.stringify(value, null, 2) }];
879
+ const seen = new Set();
880
+ for (const field of ["url", "openUrl", "promotionUrl", "draftUrl", "liveUrl", "versionUrl", "storybookUrl", "mcpUrl"]) {
881
+ const uri = structuredContent[field];
882
+ if (typeof uri !== "string" || !/^https?:\/\//.test(uri) || seen.has(uri))
883
+ continue;
884
+ seen.add(uri);
885
+ content.push({ type: "resource_link", uri, name: field });
886
+ }
887
+ return { content, structuredContent };
888
+ }
889
+ function toolError(message) {
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 } };
457
904
  return {
458
- content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
459
- structuredContent: value
905
+ content: [{ type: "text", text: JSON.stringify(structuredContent, null, 2) }],
906
+ structuredContent,
907
+ isError: true
460
908
  };
461
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";
923
+ }
924
+ const jsonSchemaValidator = new AjvJsonSchemaValidator();
925
+ function validateJsonSchema(schema, value) {
926
+ const result = jsonSchemaValidator.getValidator(schema)(value);
927
+ return result.valid ? null : result.errorMessage;
928
+ }
929
+ async function sendProgress(extra, progress, total, message) {
930
+ const progressToken = extra?._meta?.progressToken;
931
+ if (typeof progressToken !== "string" && typeof progressToken !== "number")
932
+ return;
933
+ try {
934
+ await extra.sendNotification({ method: "notifications/progress", params: { progressToken, progress, total, message } });
935
+ }
936
+ catch {
937
+ // Progress is optional; a disconnected client must not change the tool result.
938
+ }
939
+ }
462
940
  async function jsonHeaders(opts) {
463
941
  const bearerToken = await getBearerToken(opts);
464
942
  return {
@@ -514,7 +992,7 @@ function isRefreshTokenRejected(err) {
514
992
  }
515
993
  async function runOAuthLogin(opts) {
516
994
  const issuer = platformBaseUrl(opts);
517
- const resource = `${issuer}/mcp/harness`;
995
+ const resource = `${issuer}/mcp/${mcpSurface(opts)}`;
518
996
  const callback = await startOAuthCallbackServer();
519
997
  try {
520
998
  const registration = await oauthFetchJson(opts, `${issuer}/oauth/register`, {
@@ -617,6 +1095,11 @@ async function readStoredOAuthCredentials(opts) {
617
1095
  const scopes = Array.isArray(parsed.scopes) ? parsed.scopes.filter((scope) => typeof scope === "string") : DEFAULT_OAUTH_SCOPES;
618
1096
  if (!issuer || !resource || !clientId)
619
1097
  return null;
1098
+ const expectedIssuer = platformBaseUrl(opts);
1099
+ if (issuer !== expectedIssuer || resource !== `${expectedIssuer}/mcp/${mcpSurface(opts)}`)
1100
+ return null;
1101
+ if (!opts.oauthScopes.every((scope) => scopes.includes(scope)))
1102
+ return null;
620
1103
  return {
621
1104
  issuer,
622
1105
  resource,
@@ -720,9 +1203,21 @@ function artifactAppsPath(opts) {
720
1203
  function platformBaseUrl(opts) {
721
1204
  return opts.apiUrl.replace(/\/api\/harness$/, "").replace(/\/$/, "");
722
1205
  }
723
- function defaultAuthFile(apiUrl) {
724
- const key = createHash("sha256").update(platformBaseUrl({ apiUrl })).digest("hex").slice(0, 16);
725
- return path.join(os.homedir(), ".topline", "harness-mcp", `${key}.json`);
1206
+ function mcpSurface(opts) {
1207
+ return opts.surface ?? "build";
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
+ }
1218
+ function defaultAuthFile(apiUrl, surface) {
1219
+ const key = createHash("sha256").update(`${platformBaseUrl({ apiUrl })}:${surface}`).digest("hex").slice(0, 16);
1220
+ return path.join(os.homedir(), ".topline", "build-mcp", `${key}.json`);
726
1221
  }
727
1222
  function normalizeApiUrl(raw) {
728
1223
  let url;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@topline-build/harness-mcp",
3
- "version": "0.1.2",
4
- "description": "Local stdio MCP connector for deploying Topline artifact app drafts.",
3
+ "version": "0.4.0",
4
+ "description": "Topline Build MCP for creating and deploying artifact app drafts.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
@@ -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"