@topline-build/harness-mcp 0.1.1 → 0.2.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 +43 -5
- package/dist/index.d.ts +49 -1
- package/dist/index.js +278 -45
- package/package.json +2 -2
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.
|
|
@@ -9,14 +9,16 @@ repo.
|
|
|
9
9
|
|
|
10
10
|
Required environment:
|
|
11
11
|
|
|
12
|
-
- `TOPLINE_API_URL`
|
|
12
|
+
- `TOPLINE_API_URL` or `--url https://your-topline-host`
|
|
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`
|
|
19
20
|
- `TOPLINE_MCP_SCOPES`
|
|
21
|
+
- `TOPLINE_MCP_TIMEOUT_MS`
|
|
20
22
|
- `TOPLINE_MCP_TOKEN` for advanced manual-token fallback
|
|
21
23
|
|
|
22
24
|
Compatibility aliases are still accepted for older local setups:
|
|
@@ -32,6 +34,42 @@ Compatibility aliases are still accepted for older local setups:
|
|
|
32
34
|
npx -y @topline-build/harness-mcp
|
|
33
35
|
```
|
|
34
36
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
For local checks:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npx -y @topline-build/harness-mcp --help
|
|
41
|
+
npx -y @topline-build/harness-mcp --url https://hawx.topline.build
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Set `TOPLINE_WORKSPACE_ROOT` when your MCP host starts outside the repo that
|
|
45
|
+
contains the artifact app.
|
|
46
|
+
|
|
47
|
+
Topline Developer Access provides Build MCP snippets for Codex and Claude Code.
|
|
48
|
+
Those snippets contain no raw token; the first tool call opens a browser
|
|
49
|
+
approval flow scoped to artifact building.
|
|
50
|
+
|
|
51
|
+
## Storybook
|
|
52
|
+
|
|
53
|
+
Use the `platform_storybook_access` tool to get the tenant Storybook preview URL
|
|
54
|
+
and the HTTP MCP client config. For clients with HTTP MCP OAuth support, use
|
|
55
|
+
the returned `cursorMcpJson`:
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"mcpServers": {
|
|
60
|
+
"storybook": {
|
|
61
|
+
"url": "https://your-topline-host/api/design-systems/storybook/mcp",
|
|
62
|
+
"type": "http"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Storybook MCP access uses the harness bearer/OAuth credentials with
|
|
69
|
+
`harness:artifact:read`; OAuth discovery is available at
|
|
70
|
+
`/.well-known/oauth-protected-resource/mcp/build`.
|
|
71
|
+
|
|
72
|
+
If your MCP client does not support HTTP MCP OAuth, use the returned
|
|
73
|
+
`manualBearerMcpJson` template with a read-scoped `TOPLINE_MCP_TOKEN` in the
|
|
74
|
+
client's secret mechanism. Opening the visual preview URL in a browser still
|
|
75
|
+
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,8 +29,10 @@ export interface PackedArtifactAppBundle {
|
|
|
27
29
|
sha256: string;
|
|
28
30
|
excluded: string[];
|
|
29
31
|
}
|
|
32
|
+
export declare const HARNESS_MCP_VERSION = "0.2.0";
|
|
30
33
|
export declare function parseHarnessOptions(argv: string[], env?: Record<string, string | undefined>): HarnessMcpStdioOptions;
|
|
31
34
|
export declare function runHarnessMcpStdioCli(): Promise<void>;
|
|
35
|
+
export declare function cliInfo(argv: string[]): string | null;
|
|
32
36
|
export declare function createHarnessMcpStdioServer(opts: HarnessMcpStdioOptions): McpServer;
|
|
33
37
|
export declare function callHarnessTool(opts: HarnessMcpStdioOptions, name: string, args: unknown): Promise<CallToolResult>;
|
|
34
38
|
export declare function deployArtifactApp(opts: HarnessMcpStdioOptions, input: Record<string, unknown>): Promise<{
|
|
@@ -70,5 +74,49 @@ export declare function openArtifact(opts: HarnessMcpStdioOptions, artifactId: s
|
|
|
70
74
|
liveUrl: string | null;
|
|
71
75
|
versionUrl: string | null;
|
|
72
76
|
}>;
|
|
77
|
+
export declare function storybookAccess(opts: HarnessMcpStdioOptions): {
|
|
78
|
+
ok: boolean;
|
|
79
|
+
storybookUrl: string;
|
|
80
|
+
previewUrl: string;
|
|
81
|
+
mcpUrl: string;
|
|
82
|
+
mcpServers: {
|
|
83
|
+
storybook: {
|
|
84
|
+
url: string;
|
|
85
|
+
type: string;
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
cursorMcpJson: {
|
|
89
|
+
mcpServers: {
|
|
90
|
+
storybook: {
|
|
91
|
+
url: string;
|
|
92
|
+
type: string;
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
manualBearerMcpJson: {
|
|
97
|
+
mcpServers: {
|
|
98
|
+
storybook: {
|
|
99
|
+
url: string;
|
|
100
|
+
type: string;
|
|
101
|
+
headers: {
|
|
102
|
+
Authorization: string;
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
auth: {
|
|
108
|
+
mcp: string;
|
|
109
|
+
preview: string;
|
|
110
|
+
resourceMetadataUrl: string;
|
|
111
|
+
scope: string;
|
|
112
|
+
manualBearerEnv: string;
|
|
113
|
+
};
|
|
114
|
+
operatorAction: string;
|
|
115
|
+
};
|
|
73
116
|
export declare function packArtifactAppDirectory(appDir: string, archivePrefix?: string): Promise<PackedArtifactAppBundle>;
|
|
117
|
+
export declare function listTools(): ListToolsResult;
|
|
74
118
|
export declare function getBearerToken(opts: HarnessMcpStdioOptions): Promise<string>;
|
|
119
|
+
export declare function browserOpenCommand(url: string, platform?: string): {
|
|
120
|
+
command: string;
|
|
121
|
+
args: string[];
|
|
122
|
+
};
|
package/dist/index.js
CHANGED
|
@@ -8,10 +8,12 @@ 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
|
+
const SERVER_NAME = "topline-build-stdio";
|
|
16
|
+
export const HARNESS_MCP_VERSION = "0.2.0";
|
|
15
17
|
const DEFAULT_OAUTH_SCOPES = ["harness:artifact:read", "harness:artifact:write", "harness:artifact:deploy"];
|
|
16
18
|
const BLOCK_SIZE = 512;
|
|
17
19
|
const MAX_BUNDLE_BYTES = 64 * 1024 * 1024;
|
|
@@ -26,27 +28,31 @@ export function parseHarnessOptions(argv, env = process.env) {
|
|
|
26
28
|
return undefined;
|
|
27
29
|
const value = argv[index + 1]?.trim();
|
|
28
30
|
if (!value || value.startsWith("--"))
|
|
29
|
-
throw new Error(`
|
|
31
|
+
throw new Error(`Missing value for ${name}`);
|
|
30
32
|
return value;
|
|
31
33
|
};
|
|
32
34
|
const apiUrl = getArg("--url") || firstEnv(env, "TOPLINE_API_URL", "PLATFORM_API_URL", "OVERSITE_API_URL");
|
|
33
35
|
const token = getArg("--token") || firstEnv(env, "TOPLINE_MCP_TOKEN", "PLATFORM_MCP_TOKEN", "OVERSITE_MCP_TOKEN") || undefined;
|
|
34
36
|
const timeoutRaw = getArg("--timeout-ms") || firstEnv(env, "TOPLINE_MCP_TIMEOUT_MS", "PLATFORM_MCP_TIMEOUT_MS", "OVERSITE_MCP_TIMEOUT_MS");
|
|
35
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");
|
|
36
41
|
const workspaceRoot = path.resolve(getArg("--workspace-root") || firstEnv(env, "TOPLINE_WORKSPACE_ROOT", "PLATFORM_WORKSPACE_ROOT") || process.cwd());
|
|
37
42
|
const timeoutMs = timeoutRaw ? Number(timeoutRaw) : DEFAULT_TIMEOUT_MS;
|
|
38
43
|
if (!apiUrl)
|
|
39
|
-
throw new Error("
|
|
44
|
+
throw new Error("Set TOPLINE_API_URL or pass --url https://your-topline-host");
|
|
40
45
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
|
|
41
|
-
throw new Error("
|
|
46
|
+
throw new Error("TOPLINE_MCP_TIMEOUT_MS must be a positive number");
|
|
42
47
|
const normalizedApiUrl = normalizeApiUrl(apiUrl);
|
|
43
|
-
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));
|
|
44
49
|
const scopes = (getArg("--scopes") || firstEnv(env, "TOPLINE_MCP_SCOPES", "PLATFORM_MCP_SCOPES") || DEFAULT_OAUTH_SCOPES.join(" "))
|
|
45
50
|
.split(/[\s,]+/)
|
|
46
51
|
.map((scope) => scope.trim())
|
|
47
52
|
.filter(Boolean);
|
|
48
53
|
return {
|
|
49
54
|
apiUrl: normalizedApiUrl,
|
|
55
|
+
surface: surfaceRaw,
|
|
50
56
|
token,
|
|
51
57
|
authFile,
|
|
52
58
|
oauthScopes: scopes.length > 0 ? [...new Set(scopes)] : DEFAULT_OAUTH_SCOPES,
|
|
@@ -56,18 +62,55 @@ export function parseHarnessOptions(argv, env = process.env) {
|
|
|
56
62
|
};
|
|
57
63
|
}
|
|
58
64
|
export async function runHarnessMcpStdioCli() {
|
|
59
|
-
const
|
|
65
|
+
const argv = process.argv.slice(2);
|
|
66
|
+
const info = cliInfo(argv);
|
|
67
|
+
if (info) {
|
|
68
|
+
console.log(info);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const opts = parseHarnessOptions(argv);
|
|
60
72
|
const mcp = createHarnessMcpStdioServer(opts);
|
|
61
73
|
await mcp.connect(new StdioServerTransport());
|
|
62
74
|
}
|
|
75
|
+
export function cliInfo(argv) {
|
|
76
|
+
if (argv.includes("--version") || argv.includes("-v"))
|
|
77
|
+
return HARNESS_MCP_VERSION;
|
|
78
|
+
if (!argv.includes("--help") && !argv.includes("-h"))
|
|
79
|
+
return null;
|
|
80
|
+
return [
|
|
81
|
+
"Usage: topline-mcp --url https://your-topline-host [--workspace-root <path>]",
|
|
82
|
+
"",
|
|
83
|
+
"Environment:",
|
|
84
|
+
" TOPLINE_API_URL Required unless --url is passed",
|
|
85
|
+
" TOPLINE_WORKSPACE_ROOT Defaults to the current working directory",
|
|
86
|
+
" TOPLINE_MCP_SURFACE Defaults to build; harness keeps legacy OAuth compatibility",
|
|
87
|
+
" TOPLINE_MCP_TOKEN Optional manual token fallback",
|
|
88
|
+
" TOPLINE_MCP_AUTH_FILE Optional OAuth credential cache path",
|
|
89
|
+
" TOPLINE_MCP_TIMEOUT_MS Defaults to 120000",
|
|
90
|
+
"",
|
|
91
|
+
"Examples:",
|
|
92
|
+
" npx -y @topline-build/harness-mcp --url https://hawx.topline.build",
|
|
93
|
+
" TOPLINE_API_URL=https://hawx.topline.build npx -y @topline-build/harness-mcp"
|
|
94
|
+
].join("\n");
|
|
95
|
+
}
|
|
63
96
|
export function createHarnessMcpStdioServer(opts) {
|
|
64
|
-
const mcp = new McpServer({ name: SERVER_NAME, version:
|
|
97
|
+
const mcp = new McpServer({ name: SERVER_NAME, version: HARNESS_MCP_VERSION }, { capabilities: { tools: {} } });
|
|
65
98
|
mcp.setRequestHandler(ListToolsRequestSchema, async () => listTools());
|
|
66
|
-
mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
99
|
+
mcp.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
|
|
100
|
+
const reportsProgress = req.params.name === "platform_deploy_artifact_app";
|
|
101
|
+
if (reportsProgress)
|
|
102
|
+
await sendProgress(extra, 0, 1, "Starting artifact deployment");
|
|
67
103
|
try {
|
|
68
|
-
|
|
104
|
+
const result = await callHarnessTool(opts, req.params.name, req.params.arguments ?? {});
|
|
105
|
+
if (reportsProgress)
|
|
106
|
+
await sendProgress(extra, 1, 1, "Completed artifact deployment");
|
|
107
|
+
return result;
|
|
69
108
|
}
|
|
70
109
|
catch (err) {
|
|
110
|
+
if (err instanceof McpError)
|
|
111
|
+
throw err;
|
|
112
|
+
if (reportsProgress)
|
|
113
|
+
await sendProgress(extra, 1, 1, "Failed artifact deployment");
|
|
71
114
|
return {
|
|
72
115
|
content: [{ type: "text", text: redactSecrets(err instanceof Error ? err.message : String(err), opts) }],
|
|
73
116
|
isError: true
|
|
@@ -78,24 +121,43 @@ export function createHarnessMcpStdioServer(opts) {
|
|
|
78
121
|
}
|
|
79
122
|
export async function callHarnessTool(opts, name, args) {
|
|
80
123
|
const input = isRecord(args) ? args : {};
|
|
124
|
+
const tool = listTools().tools.find((candidate) => candidate.name === name);
|
|
125
|
+
if (!tool)
|
|
126
|
+
throw new McpError(ErrorCode.InvalidParams, `Unknown tool: ${name}`);
|
|
127
|
+
const inputError = validateJsonSchema(tool.inputSchema, input);
|
|
128
|
+
if (inputError)
|
|
129
|
+
return toolError(`Invalid arguments for ${name}: ${inputError}`);
|
|
130
|
+
let result;
|
|
81
131
|
switch (name) {
|
|
82
132
|
case "platform_deploy_artifact_app":
|
|
83
|
-
|
|
133
|
+
result = toolResult(await deployArtifactApp(opts, input));
|
|
134
|
+
break;
|
|
84
135
|
case "platform_artifact_status":
|
|
85
|
-
|
|
136
|
+
result = toolResult(await getArtifactStatus(opts, stringArg(input, "artifactId"), numberArg(input, "limit")));
|
|
137
|
+
break;
|
|
86
138
|
case "platform_read_artifact_logs":
|
|
87
|
-
|
|
139
|
+
result = toolResult(await readArtifactLogs(opts, input));
|
|
140
|
+
break;
|
|
88
141
|
case "platform_open_artifact":
|
|
89
|
-
|
|
142
|
+
result = toolResult(await openArtifact(opts, stringArg(input, "artifactId")));
|
|
143
|
+
break;
|
|
144
|
+
case "platform_storybook_access":
|
|
145
|
+
result = toolResult(storybookAccess(opts));
|
|
146
|
+
break;
|
|
90
147
|
default:
|
|
91
|
-
|
|
148
|
+
throw new McpError(ErrorCode.InvalidParams, `Unknown tool: ${name}`);
|
|
92
149
|
}
|
|
150
|
+
const outputError = tool.outputSchema
|
|
151
|
+
? validateJsonSchema(tool.outputSchema, result.structuredContent)
|
|
152
|
+
: null;
|
|
153
|
+
return outputError ? toolError(`Invalid output for ${name}: ${outputError}`) : result;
|
|
93
154
|
}
|
|
94
155
|
export async function deployArtifactApp(opts, input) {
|
|
95
156
|
const appDir = stringArg(input, "appDir");
|
|
96
157
|
const absoluteAppDir = resolveWorkspacePath(opts.workspaceRoot, appDir);
|
|
97
158
|
const platformAppDir = workspaceRelativeAppDir(opts.workspaceRoot, absoluteAppDir);
|
|
98
159
|
const manifest = await readManifest(absoluteAppDir);
|
|
160
|
+
const bundle = await packArtifactAppDirectory(absoluteAppDir, platformAppDir === "." ? "" : platformAppDir);
|
|
99
161
|
const created = await fetchJson(opts, artifactAppsPath(opts), {
|
|
100
162
|
method: "POST",
|
|
101
163
|
headers: await jsonHeaders(opts),
|
|
@@ -112,7 +174,6 @@ export async function deployArtifactApp(opts, input) {
|
|
|
112
174
|
throw new Error(`artifact_app_create_failed:${JSON.stringify(createdRecord)}`);
|
|
113
175
|
const artifactId = stringValue(createdRecord.artifactId, "artifactId");
|
|
114
176
|
const artifactVersionId = stringValue(createdRecord.artifactVersionId, "artifactVersionId");
|
|
115
|
-
const bundle = await packArtifactAppDirectory(absoluteAppDir, platformAppDir);
|
|
116
177
|
const bearerToken = await getBearerToken(opts);
|
|
117
178
|
const deployed = await fetchJson(opts, `${artifactAppsPath(opts)}/${encodeURIComponent(artifactId)}/deploy-bundle`, {
|
|
118
179
|
method: "POST",
|
|
@@ -196,6 +257,36 @@ export async function openArtifact(opts, artifactId) {
|
|
|
196
257
|
versionUrl: typeof draft?.versionUrl === "string" ? draft.versionUrl : typeof live?.versionUrl === "string" ? live.versionUrl : null
|
|
197
258
|
};
|
|
198
259
|
}
|
|
260
|
+
export function storybookAccess(opts) {
|
|
261
|
+
const baseUrl = platformBaseUrl(opts);
|
|
262
|
+
const storybookUrl = `${baseUrl}/api/design-systems/storybook/`;
|
|
263
|
+
const mcpUrl = `${baseUrl}/api/design-systems/storybook/mcp`;
|
|
264
|
+
const mcpServers = { storybook: { url: mcpUrl, type: "http" } };
|
|
265
|
+
const manualBearerMcpServers = {
|
|
266
|
+
storybook: {
|
|
267
|
+
url: mcpUrl,
|
|
268
|
+
type: "http",
|
|
269
|
+
headers: { Authorization: "Bearer <TOPLINE_MCP_TOKEN>" }
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
return {
|
|
273
|
+
ok: true,
|
|
274
|
+
storybookUrl,
|
|
275
|
+
previewUrl: storybookUrl,
|
|
276
|
+
mcpUrl,
|
|
277
|
+
mcpServers,
|
|
278
|
+
cursorMcpJson: { mcpServers },
|
|
279
|
+
manualBearerMcpJson: { mcpServers: manualBearerMcpServers },
|
|
280
|
+
auth: {
|
|
281
|
+
mcp: "topline_harness_bearer_with_harness_artifact_read",
|
|
282
|
+
preview: "topline_browser_session",
|
|
283
|
+
resourceMetadataUrl: `${baseUrl}/.well-known/oauth-protected-resource/mcp/${mcpSurface(opts)}`,
|
|
284
|
+
scope: "harness:artifact:read",
|
|
285
|
+
manualBearerEnv: "TOPLINE_MCP_TOKEN"
|
|
286
|
+
},
|
|
287
|
+
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."
|
|
288
|
+
};
|
|
289
|
+
}
|
|
199
290
|
export async function packArtifactAppDirectory(appDir, archivePrefix = "") {
|
|
200
291
|
const root = path.resolve(appDir);
|
|
201
292
|
const prefix = archivePrefix ? validateArchivePrefix(archivePrefix) : "";
|
|
@@ -266,7 +357,7 @@ function resolveWorkspacePath(workspaceRoot, inputPath) {
|
|
|
266
357
|
}
|
|
267
358
|
function workspaceRelativeAppDir(workspaceRoot, absoluteAppDir) {
|
|
268
359
|
const relative = toPosix(path.relative(path.resolve(workspaceRoot), path.resolve(absoluteAppDir)));
|
|
269
|
-
return validateArchivePrefix(relative);
|
|
360
|
+
return relative ? validateArchivePrefix(relative) : ".";
|
|
270
361
|
}
|
|
271
362
|
function validateArchivePrefix(prefix) {
|
|
272
363
|
const normalized = toPosix(prefix).replace(/^\/+|\/+$/g, "");
|
|
@@ -366,72 +457,170 @@ async function fetchJson(opts, url, init) {
|
|
|
366
457
|
clearTimeout(timeout);
|
|
367
458
|
}
|
|
368
459
|
}
|
|
369
|
-
function listTools() {
|
|
460
|
+
export function listTools() {
|
|
370
461
|
return {
|
|
371
462
|
tools: [
|
|
372
463
|
{
|
|
373
464
|
name: "platform_deploy_artifact_app",
|
|
374
465
|
description: "Package a local artifact app directory, upload it, deploy a private draft, and return status.",
|
|
466
|
+
annotations: mcpToolAnnotations("platform_deploy_artifact_app", { readOnly: false, openWorld: true }),
|
|
375
467
|
inputSchema: {
|
|
376
468
|
type: "object",
|
|
377
469
|
properties: {
|
|
378
|
-
appDir: { type: "string" },
|
|
470
|
+
appDir: { type: "string", minLength: 1 },
|
|
379
471
|
artifactId: { type: "string" },
|
|
380
472
|
title: { type: "string" },
|
|
381
473
|
description: { type: "string" },
|
|
382
474
|
projectId: { type: "string" }
|
|
383
475
|
},
|
|
384
|
-
required: ["appDir"]
|
|
476
|
+
required: ["appDir"],
|
|
477
|
+
additionalProperties: false
|
|
478
|
+
},
|
|
479
|
+
outputSchema: {
|
|
480
|
+
type: "object",
|
|
481
|
+
properties: {
|
|
482
|
+
ok: { type: "boolean" },
|
|
483
|
+
artifactId: { type: "string" },
|
|
484
|
+
artifactVersionId: { type: "string" },
|
|
485
|
+
bundle: { type: "object" },
|
|
486
|
+
create: { type: "object" },
|
|
487
|
+
deploy: { type: "object" }
|
|
488
|
+
},
|
|
489
|
+
required: ["ok", "artifactId", "artifactVersionId", "bundle", "create", "deploy"]
|
|
385
490
|
}
|
|
386
491
|
},
|
|
387
492
|
{
|
|
388
493
|
name: "platform_artifact_status",
|
|
389
494
|
description: "Read private draft deployment status for a hosted artifact app.",
|
|
495
|
+
annotations: mcpToolAnnotations("platform_artifact_status", { readOnly: true, openWorld: true }),
|
|
390
496
|
inputSchema: {
|
|
391
497
|
type: "object",
|
|
392
|
-
properties: { artifactId: { type: "string" }, limit: { type: "
|
|
393
|
-
required: ["artifactId"]
|
|
498
|
+
properties: { artifactId: { type: "string", minLength: 1 }, limit: { type: "integer", minimum: 1, maximum: 50 } },
|
|
499
|
+
required: ["artifactId"],
|
|
500
|
+
additionalProperties: false
|
|
501
|
+
},
|
|
502
|
+
outputSchema: {
|
|
503
|
+
type: "object",
|
|
504
|
+
properties: {
|
|
505
|
+
ok: { type: "boolean" }, phase: { type: "string" }, artifactId: { type: "string" },
|
|
506
|
+
deployments: { type: "array", items: { type: "object" } }
|
|
507
|
+
},
|
|
508
|
+
required: ["ok", "phase", "artifactId", "deployments"]
|
|
394
509
|
}
|
|
395
510
|
},
|
|
396
511
|
{
|
|
397
512
|
name: "platform_read_artifact_logs",
|
|
398
513
|
description: "Read bounded, redacted deployment logs for a hosted artifact app.",
|
|
514
|
+
annotations: mcpToolAnnotations("platform_read_artifact_logs", { readOnly: true, openWorld: true }),
|
|
399
515
|
inputSchema: {
|
|
400
516
|
type: "object",
|
|
401
517
|
properties: {
|
|
402
|
-
artifactId: { type: "string" },
|
|
518
|
+
artifactId: { type: "string", minLength: 1 },
|
|
403
519
|
deploymentRecordId: { type: "string" },
|
|
404
520
|
environment: { type: "string", enum: ["draft", "live"] },
|
|
405
|
-
limit: { type: "
|
|
521
|
+
limit: { type: "integer", minimum: 1, maximum: 200 },
|
|
406
522
|
severity: { type: "string" },
|
|
407
523
|
contains: { type: "string" },
|
|
408
|
-
since: { type: "string" },
|
|
409
|
-
windowMinutes: { type: "
|
|
524
|
+
since: { type: "string", format: "date-time" },
|
|
525
|
+
windowMinutes: { type: "integer", minimum: 1, maximum: 1440 },
|
|
410
526
|
trace: { type: "string" },
|
|
411
527
|
requestId: { type: "string" },
|
|
412
528
|
route: { type: "string" }
|
|
413
529
|
},
|
|
414
|
-
required: ["artifactId"]
|
|
530
|
+
required: ["artifactId"],
|
|
531
|
+
additionalProperties: false
|
|
532
|
+
},
|
|
533
|
+
outputSchema: {
|
|
534
|
+
type: "object",
|
|
535
|
+
properties: {
|
|
536
|
+
ok: { type: "boolean" },
|
|
537
|
+
phase: { type: "string" },
|
|
538
|
+
artifactId: { type: "string" },
|
|
539
|
+
entries: { type: "array", items: { type: "object" } }
|
|
540
|
+
},
|
|
541
|
+
required: ["ok", "phase", "artifactId"]
|
|
415
542
|
}
|
|
416
543
|
},
|
|
417
544
|
{
|
|
418
545
|
name: "platform_open_artifact",
|
|
419
546
|
description: "Return the operator URL and latest draft/live URLs for a hosted artifact app.",
|
|
547
|
+
annotations: mcpToolAnnotations("platform_open_artifact", { readOnly: true, openWorld: true }),
|
|
548
|
+
inputSchema: {
|
|
549
|
+
type: "object",
|
|
550
|
+
properties: { artifactId: { type: "string", minLength: 1 } },
|
|
551
|
+
required: ["artifactId"],
|
|
552
|
+
additionalProperties: false
|
|
553
|
+
},
|
|
554
|
+
outputSchema: {
|
|
555
|
+
type: "object",
|
|
556
|
+
properties: {
|
|
557
|
+
ok: { type: "boolean" }, artifactId: { type: "string" }, openUrl: { type: "string" },
|
|
558
|
+
draftUrl: { type: ["string", "null"] }, liveUrl: { type: ["string", "null"] }, versionUrl: { type: ["string", "null"] }
|
|
559
|
+
},
|
|
560
|
+
required: ["ok", "artifactId", "openUrl", "draftUrl", "liveUrl", "versionUrl"]
|
|
561
|
+
}
|
|
562
|
+
},
|
|
563
|
+
{
|
|
564
|
+
name: "platform_storybook_access",
|
|
565
|
+
description: "Return the tenant Storybook preview URL and HTTP MCP server config.",
|
|
566
|
+
annotations: mcpToolAnnotations("platform_storybook_access", { readOnly: true }),
|
|
420
567
|
inputSchema: {
|
|
421
568
|
type: "object",
|
|
422
|
-
properties: {
|
|
423
|
-
|
|
569
|
+
properties: {},
|
|
570
|
+
additionalProperties: false
|
|
571
|
+
},
|
|
572
|
+
outputSchema: {
|
|
573
|
+
type: "object",
|
|
574
|
+
properties: {
|
|
575
|
+
ok: { type: "boolean" }, storybookUrl: { type: "string" }, mcpUrl: { type: "string" }, mcpServers: { type: "object" }
|
|
576
|
+
},
|
|
577
|
+
required: ["ok", "storybookUrl", "mcpUrl", "mcpServers"]
|
|
424
578
|
}
|
|
425
579
|
}
|
|
426
580
|
]
|
|
427
581
|
};
|
|
428
582
|
}
|
|
429
|
-
function
|
|
583
|
+
function mcpToolAnnotations(name, policy) {
|
|
430
584
|
return {
|
|
431
|
-
|
|
432
|
-
|
|
585
|
+
title: name.split("_").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" "),
|
|
586
|
+
readOnlyHint: policy.readOnly,
|
|
587
|
+
destructiveHint: policy.destructive ?? false,
|
|
588
|
+
...(policy.idempotent ? { idempotentHint: true } : {}),
|
|
589
|
+
openWorldHint: policy.openWorld ?? false
|
|
433
590
|
};
|
|
434
591
|
}
|
|
592
|
+
function toolResult(value) {
|
|
593
|
+
const structuredContent = value;
|
|
594
|
+
const content = [{ type: "text", text: JSON.stringify(value, null, 2) }];
|
|
595
|
+
const seen = new Set();
|
|
596
|
+
for (const field of ["url", "openUrl", "promotionUrl", "draftUrl", "liveUrl", "versionUrl", "storybookUrl", "mcpUrl"]) {
|
|
597
|
+
const uri = structuredContent[field];
|
|
598
|
+
if (typeof uri !== "string" || !/^https?:\/\//.test(uri) || seen.has(uri))
|
|
599
|
+
continue;
|
|
600
|
+
seen.add(uri);
|
|
601
|
+
content.push({ type: "resource_link", uri, name: field });
|
|
602
|
+
}
|
|
603
|
+
return { content, structuredContent };
|
|
604
|
+
}
|
|
605
|
+
function toolError(message) {
|
|
606
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
607
|
+
}
|
|
608
|
+
const jsonSchemaValidator = new AjvJsonSchemaValidator();
|
|
609
|
+
function validateJsonSchema(schema, value) {
|
|
610
|
+
const result = jsonSchemaValidator.getValidator(schema)(value);
|
|
611
|
+
return result.valid ? null : result.errorMessage;
|
|
612
|
+
}
|
|
613
|
+
async function sendProgress(extra, progress, total, message) {
|
|
614
|
+
const progressToken = extra?._meta?.progressToken;
|
|
615
|
+
if (typeof progressToken !== "string" && typeof progressToken !== "number")
|
|
616
|
+
return;
|
|
617
|
+
try {
|
|
618
|
+
await extra.sendNotification({ method: "notifications/progress", params: { progressToken, progress, total, message } });
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
// Progress is optional; a disconnected client must not change the tool result.
|
|
622
|
+
}
|
|
623
|
+
}
|
|
435
624
|
async function jsonHeaders(opts) {
|
|
436
625
|
const bearerToken = await getBearerToken(opts);
|
|
437
626
|
return {
|
|
@@ -454,7 +643,9 @@ export async function getBearerToken(opts) {
|
|
|
454
643
|
await writeStoredOAuthCredentials(opts, refreshed);
|
|
455
644
|
return refreshed.accessToken;
|
|
456
645
|
}
|
|
457
|
-
catch {
|
|
646
|
+
catch (err) {
|
|
647
|
+
if (!isRefreshTokenRejected(err))
|
|
648
|
+
throw err;
|
|
458
649
|
// Fall through to an interactive login; the stored refresh token may have been revoked.
|
|
459
650
|
}
|
|
460
651
|
}
|
|
@@ -479,9 +670,13 @@ async function refreshOAuthToken(opts, stored) {
|
|
|
479
670
|
});
|
|
480
671
|
return credentialsFromTokenResponse(stored.issuer, stored.resource, stored.clientId, stored.scopes, token);
|
|
481
672
|
}
|
|
673
|
+
function isRefreshTokenRejected(err) {
|
|
674
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
675
|
+
return /^topline_oauth_http_error:(400|401):/.test(message) && /invalid_(grant|token)/i.test(message);
|
|
676
|
+
}
|
|
482
677
|
async function runOAuthLogin(opts) {
|
|
483
678
|
const issuer = platformBaseUrl(opts);
|
|
484
|
-
const resource = `${issuer}/mcp
|
|
679
|
+
const resource = `${issuer}/mcp/${mcpSurface(opts)}`;
|
|
485
680
|
const callback = await startOAuthCallbackServer();
|
|
486
681
|
try {
|
|
487
682
|
const registration = await oauthFetchJson(opts, `${issuer}/oauth/register`, {
|
|
@@ -508,8 +703,9 @@ async function runOAuthLogin(opts) {
|
|
|
508
703
|
authUrl.searchParams.set("code_challenge", challenge);
|
|
509
704
|
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
510
705
|
authUrl.searchParams.set("state", state);
|
|
706
|
+
const codePromise = callback.waitForCode(state);
|
|
511
707
|
await openBrowser(opts, authUrl.toString());
|
|
512
|
-
const code = await
|
|
708
|
+
const code = await codePromise;
|
|
513
709
|
const token = await oauthFetchJson(opts, `${issuer}/oauth/token`, {
|
|
514
710
|
method: "POST",
|
|
515
711
|
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
@@ -534,11 +730,22 @@ async function oauthFetchJson(opts, url, init) {
|
|
|
534
730
|
try {
|
|
535
731
|
const response = await (opts.fetchImpl ?? fetch)(url, { ...init, signal: controller.signal });
|
|
536
732
|
const text = await response.text();
|
|
537
|
-
|
|
733
|
+
let payload = {};
|
|
734
|
+
try {
|
|
735
|
+
payload = text ? JSON.parse(text) : {};
|
|
736
|
+
}
|
|
737
|
+
catch {
|
|
738
|
+
payload = { detail: text.slice(0, 500) };
|
|
739
|
+
}
|
|
538
740
|
if (!response.ok)
|
|
539
741
|
throw new Error(`topline_oauth_http_error:${response.status}:${redactSecrets(JSON.stringify(payload), opts)}`);
|
|
540
742
|
return payload;
|
|
541
743
|
}
|
|
744
|
+
catch (err) {
|
|
745
|
+
if (controller.signal.aborted)
|
|
746
|
+
throw new Error("topline_oauth_timeout");
|
|
747
|
+
throw err;
|
|
748
|
+
}
|
|
542
749
|
finally {
|
|
543
750
|
clearTimeout(timeout);
|
|
544
751
|
}
|
|
@@ -572,6 +779,11 @@ async function readStoredOAuthCredentials(opts) {
|
|
|
572
779
|
const scopes = Array.isArray(parsed.scopes) ? parsed.scopes.filter((scope) => typeof scope === "string") : DEFAULT_OAUTH_SCOPES;
|
|
573
780
|
if (!issuer || !resource || !clientId)
|
|
574
781
|
return null;
|
|
782
|
+
const expectedIssuer = platformBaseUrl(opts);
|
|
783
|
+
if (issuer !== expectedIssuer || resource !== `${expectedIssuer}/mcp/${mcpSurface(opts)}`)
|
|
784
|
+
return null;
|
|
785
|
+
if (!opts.oauthScopes.every((scope) => scopes.includes(scope)))
|
|
786
|
+
return null;
|
|
575
787
|
return {
|
|
576
788
|
issuer,
|
|
577
789
|
resource,
|
|
@@ -593,7 +805,7 @@ async function writeStoredOAuthCredentials(opts, credentials) {
|
|
|
593
805
|
async function startOAuthCallbackServer() {
|
|
594
806
|
let resolveCode = null;
|
|
595
807
|
let rejectCode = null;
|
|
596
|
-
let expectedState =
|
|
808
|
+
let expectedState = null;
|
|
597
809
|
const codePromise = new Promise((resolve, reject) => {
|
|
598
810
|
resolveCode = resolve;
|
|
599
811
|
rejectCode = reject;
|
|
@@ -602,7 +814,13 @@ async function startOAuthCallbackServer() {
|
|
|
602
814
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
603
815
|
const code = url.searchParams.get("code");
|
|
604
816
|
const state = url.searchParams.get("state");
|
|
605
|
-
if (url.pathname !== "/callback" ||
|
|
817
|
+
if (!expectedState || url.pathname !== "/callback" || state !== expectedState) {
|
|
818
|
+
res.writeHead(400, { "content-type": "text/html; charset=utf-8" });
|
|
819
|
+
res.end("<!doctype html><meta charset=\"utf-8\"><h1>Topline authorization failed</h1>");
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const error = url.searchParams.get("error");
|
|
823
|
+
if (error || !code) {
|
|
606
824
|
res.writeHead(400, { "content-type": "text/html; charset=utf-8" });
|
|
607
825
|
res.end("<!doctype html><meta charset=\"utf-8\"><h1>Topline authorization failed</h1>");
|
|
608
826
|
rejectCode?.(new Error("topline_oauth_callback_invalid"));
|
|
@@ -645,8 +863,7 @@ async function openBrowser(opts, url) {
|
|
|
645
863
|
await opts.openUrl(url);
|
|
646
864
|
return;
|
|
647
865
|
}
|
|
648
|
-
const command
|
|
649
|
-
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
866
|
+
const { command, args } = browserOpenCommand(url);
|
|
650
867
|
try {
|
|
651
868
|
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
652
869
|
child.unref();
|
|
@@ -655,6 +872,13 @@ async function openBrowser(opts, url) {
|
|
|
655
872
|
console.error(`Open this URL to authorize Topline MCP:\n${url}`);
|
|
656
873
|
}
|
|
657
874
|
}
|
|
875
|
+
export function browserOpenCommand(url, platform = process.platform) {
|
|
876
|
+
if (platform === "win32")
|
|
877
|
+
return { command: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] };
|
|
878
|
+
if (platform === "darwin")
|
|
879
|
+
return { command: "open", args: [url] };
|
|
880
|
+
return { command: "xdg-open", args: [url] };
|
|
881
|
+
}
|
|
658
882
|
function artifactAppsPath(opts) {
|
|
659
883
|
return opts.apiUrl.endsWith("/api/harness")
|
|
660
884
|
? `${opts.apiUrl}/artifact-apps`
|
|
@@ -663,14 +887,23 @@ function artifactAppsPath(opts) {
|
|
|
663
887
|
function platformBaseUrl(opts) {
|
|
664
888
|
return opts.apiUrl.replace(/\/api\/harness$/, "").replace(/\/$/, "");
|
|
665
889
|
}
|
|
666
|
-
function
|
|
667
|
-
|
|
668
|
-
|
|
890
|
+
function mcpSurface(opts) {
|
|
891
|
+
return opts.surface ?? "build";
|
|
892
|
+
}
|
|
893
|
+
function defaultAuthFile(apiUrl, surface) {
|
|
894
|
+
const key = createHash("sha256").update(`${platformBaseUrl({ apiUrl })}:${surface}`).digest("hex").slice(0, 16);
|
|
895
|
+
return path.join(os.homedir(), ".topline", "build-mcp", `${key}.json`);
|
|
669
896
|
}
|
|
670
897
|
function normalizeApiUrl(raw) {
|
|
671
|
-
|
|
898
|
+
let url;
|
|
899
|
+
try {
|
|
900
|
+
url = new URL(raw);
|
|
901
|
+
}
|
|
902
|
+
catch {
|
|
903
|
+
throw new Error("TOPLINE_API_URL must be an http(s) URL");
|
|
904
|
+
}
|
|
672
905
|
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
673
|
-
throw new Error("
|
|
906
|
+
throw new Error("TOPLINE_API_URL must be an http(s) URL");
|
|
674
907
|
return url.toString().replace(/\/$/g, "");
|
|
675
908
|
}
|
|
676
909
|
function firstEnv(env, ...keys) {
|
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.2.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",
|