@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 +84 -4
- package/dist/index.d.ts +61 -4
- package/dist/index.js +534 -39
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Topline
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
|
36
|
-
|
|
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 {
|
|
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-
|
|
15
|
-
export const HARNESS_MCP_VERSION = "0.
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
138
|
+
result = toolResult(await deployArtifactApp(opts, input, hooks?.onProgress));
|
|
139
|
+
break;
|
|
111
140
|
case "platform_artifact_status":
|
|
112
|
-
|
|
141
|
+
result = toolResult(await getArtifactStatus(opts, stringArg(input, "artifactId"), numberArg(input, "limit")));
|
|
142
|
+
break;
|
|
113
143
|
case "platform_read_artifact_logs":
|
|
114
|
-
|
|
144
|
+
result = toolResult(await readArtifactLogs(opts, input));
|
|
145
|
+
break;
|
|
115
146
|
case "platform_open_artifact":
|
|
116
|
-
|
|
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
|
-
|
|
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
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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: ["
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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(
|
|
459
|
-
structuredContent
|
|
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
|
|
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
|
|
724
|
-
|
|
725
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
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"
|