@topline-build/harness-mcp 0.1.0 → 0.1.2
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 +12 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.js +93 -19
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ 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
|
|
|
@@ -17,6 +17,7 @@ Optional environment:
|
|
|
17
17
|
- `TOPLINE_WORKSPACE_ROOT`
|
|
18
18
|
- `TOPLINE_MCP_AUTH_FILE`
|
|
19
19
|
- `TOPLINE_MCP_SCOPES`
|
|
20
|
+
- `TOPLINE_MCP_TIMEOUT_MS`
|
|
20
21
|
- `TOPLINE_MCP_TOKEN` for advanced manual-token fallback
|
|
21
22
|
|
|
22
23
|
Compatibility aliases are still accepted for older local setups:
|
|
@@ -32,6 +33,16 @@ Compatibility aliases are still accepted for older local setups:
|
|
|
32
33
|
npx -y @topline-build/harness-mcp
|
|
33
34
|
```
|
|
34
35
|
|
|
36
|
+
For local checks:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npx -y @topline-build/harness-mcp --help
|
|
40
|
+
npx -y @topline-build/harness-mcp --url https://hawx.topline.build
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Set `TOPLINE_WORKSPACE_ROOT` when your MCP host starts outside the repo that
|
|
44
|
+
contains the artifact app.
|
|
45
|
+
|
|
35
46
|
Topline Developer Access provides harness-specific snippets for Codex and
|
|
36
47
|
Claude Code. Those snippets contain no raw token; the first tool call opens a
|
|
37
48
|
browser approval flow.
|
package/dist/index.d.ts
CHANGED
|
@@ -27,8 +27,10 @@ export interface PackedArtifactAppBundle {
|
|
|
27
27
|
sha256: string;
|
|
28
28
|
excluded: string[];
|
|
29
29
|
}
|
|
30
|
+
export declare const HARNESS_MCP_VERSION = "0.1.2";
|
|
30
31
|
export declare function parseHarnessOptions(argv: string[], env?: Record<string, string | undefined>): HarnessMcpStdioOptions;
|
|
31
32
|
export declare function runHarnessMcpStdioCli(): Promise<void>;
|
|
33
|
+
export declare function cliInfo(argv: string[]): string | null;
|
|
32
34
|
export declare function createHarnessMcpStdioServer(opts: HarnessMcpStdioOptions): McpServer;
|
|
33
35
|
export declare function callHarnessTool(opts: HarnessMcpStdioOptions, name: string, args: unknown): Promise<CallToolResult>;
|
|
34
36
|
export declare function deployArtifactApp(opts: HarnessMcpStdioOptions, input: Record<string, unknown>): Promise<{
|
|
@@ -70,5 +72,9 @@ export declare function openArtifact(opts: HarnessMcpStdioOptions, artifactId: s
|
|
|
70
72
|
liveUrl: string | null;
|
|
71
73
|
versionUrl: string | null;
|
|
72
74
|
}>;
|
|
73
|
-
export declare function packArtifactAppDirectory(appDir: string): Promise<PackedArtifactAppBundle>;
|
|
75
|
+
export declare function packArtifactAppDirectory(appDir: string, archivePrefix?: string): Promise<PackedArtifactAppBundle>;
|
|
74
76
|
export declare function getBearerToken(opts: HarnessMcpStdioOptions): Promise<string>;
|
|
77
|
+
export declare function browserOpenCommand(url: string, platform?: string): {
|
|
78
|
+
command: string;
|
|
79
|
+
args: string[];
|
|
80
|
+
};
|
package/dist/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
|
|
|
12
12
|
const DEFAULT_MCP_PROTOCOL_VERSION = "2025-11-25";
|
|
13
13
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
14
14
|
const SERVER_NAME = "topline-harness-stdio";
|
|
15
|
+
export const HARNESS_MCP_VERSION = "0.1.2";
|
|
15
16
|
const DEFAULT_OAUTH_SCOPES = ["harness:artifact:read", "harness:artifact:write", "harness:artifact:deploy"];
|
|
16
17
|
const BLOCK_SIZE = 512;
|
|
17
18
|
const MAX_BUNDLE_BYTES = 64 * 1024 * 1024;
|
|
@@ -26,7 +27,7 @@ export function parseHarnessOptions(argv, env = process.env) {
|
|
|
26
27
|
return undefined;
|
|
27
28
|
const value = argv[index + 1]?.trim();
|
|
28
29
|
if (!value || value.startsWith("--"))
|
|
29
|
-
throw new Error(`
|
|
30
|
+
throw new Error(`Missing value for ${name}`);
|
|
30
31
|
return value;
|
|
31
32
|
};
|
|
32
33
|
const apiUrl = getArg("--url") || firstEnv(env, "TOPLINE_API_URL", "PLATFORM_API_URL", "OVERSITE_API_URL");
|
|
@@ -36,9 +37,9 @@ export function parseHarnessOptions(argv, env = process.env) {
|
|
|
36
37
|
const workspaceRoot = path.resolve(getArg("--workspace-root") || firstEnv(env, "TOPLINE_WORKSPACE_ROOT", "PLATFORM_WORKSPACE_ROOT") || process.cwd());
|
|
37
38
|
const timeoutMs = timeoutRaw ? Number(timeoutRaw) : DEFAULT_TIMEOUT_MS;
|
|
38
39
|
if (!apiUrl)
|
|
39
|
-
throw new Error("
|
|
40
|
+
throw new Error("Set TOPLINE_API_URL or pass --url https://your-topline-host");
|
|
40
41
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
|
|
41
|
-
throw new Error("
|
|
42
|
+
throw new Error("TOPLINE_MCP_TIMEOUT_MS must be a positive number");
|
|
42
43
|
const normalizedApiUrl = normalizeApiUrl(apiUrl);
|
|
43
44
|
const authFile = path.resolve(getArg("--auth-file") || firstEnv(env, "TOPLINE_MCP_AUTH_FILE", "PLATFORM_MCP_AUTH_FILE") || defaultAuthFile(normalizedApiUrl));
|
|
44
45
|
const scopes = (getArg("--scopes") || firstEnv(env, "TOPLINE_MCP_SCOPES", "PLATFORM_MCP_SCOPES") || DEFAULT_OAUTH_SCOPES.join(" "))
|
|
@@ -56,12 +57,38 @@ export function parseHarnessOptions(argv, env = process.env) {
|
|
|
56
57
|
};
|
|
57
58
|
}
|
|
58
59
|
export async function runHarnessMcpStdioCli() {
|
|
59
|
-
const
|
|
60
|
+
const argv = process.argv.slice(2);
|
|
61
|
+
const info = cliInfo(argv);
|
|
62
|
+
if (info) {
|
|
63
|
+
console.log(info);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const opts = parseHarnessOptions(argv);
|
|
60
67
|
const mcp = createHarnessMcpStdioServer(opts);
|
|
61
68
|
await mcp.connect(new StdioServerTransport());
|
|
62
69
|
}
|
|
70
|
+
export function cliInfo(argv) {
|
|
71
|
+
if (argv.includes("--version") || argv.includes("-v"))
|
|
72
|
+
return HARNESS_MCP_VERSION;
|
|
73
|
+
if (!argv.includes("--help") && !argv.includes("-h"))
|
|
74
|
+
return null;
|
|
75
|
+
return [
|
|
76
|
+
"Usage: topline-mcp --url https://your-topline-host [--workspace-root <path>]",
|
|
77
|
+
"",
|
|
78
|
+
"Environment:",
|
|
79
|
+
" TOPLINE_API_URL Required unless --url is passed",
|
|
80
|
+
" TOPLINE_WORKSPACE_ROOT Defaults to the current working directory",
|
|
81
|
+
" TOPLINE_MCP_TOKEN Optional manual token fallback",
|
|
82
|
+
" TOPLINE_MCP_AUTH_FILE Optional OAuth credential cache path",
|
|
83
|
+
" TOPLINE_MCP_TIMEOUT_MS Defaults to 120000",
|
|
84
|
+
"",
|
|
85
|
+
"Examples:",
|
|
86
|
+
" npx -y @topline-build/harness-mcp --url https://hawx.topline.build",
|
|
87
|
+
" TOPLINE_API_URL=https://hawx.topline.build npx -y @topline-build/harness-mcp"
|
|
88
|
+
].join("\n");
|
|
89
|
+
}
|
|
63
90
|
export function createHarnessMcpStdioServer(opts) {
|
|
64
|
-
const mcp = new McpServer({ name: SERVER_NAME, version:
|
|
91
|
+
const mcp = new McpServer({ name: SERVER_NAME, version: HARNESS_MCP_VERSION }, { capabilities: { tools: {} } });
|
|
65
92
|
mcp.setRequestHandler(ListToolsRequestSchema, async () => listTools());
|
|
66
93
|
mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
67
94
|
try {
|
|
@@ -94,7 +121,9 @@ export async function callHarnessTool(opts, name, args) {
|
|
|
94
121
|
export async function deployArtifactApp(opts, input) {
|
|
95
122
|
const appDir = stringArg(input, "appDir");
|
|
96
123
|
const absoluteAppDir = resolveWorkspacePath(opts.workspaceRoot, appDir);
|
|
124
|
+
const platformAppDir = workspaceRelativeAppDir(opts.workspaceRoot, absoluteAppDir);
|
|
97
125
|
const manifest = await readManifest(absoluteAppDir);
|
|
126
|
+
const bundle = await packArtifactAppDirectory(absoluteAppDir, platformAppDir === "." ? "" : platformAppDir);
|
|
98
127
|
const created = await fetchJson(opts, artifactAppsPath(opts), {
|
|
99
128
|
method: "POST",
|
|
100
129
|
headers: await jsonHeaders(opts),
|
|
@@ -111,7 +140,6 @@ export async function deployArtifactApp(opts, input) {
|
|
|
111
140
|
throw new Error(`artifact_app_create_failed:${JSON.stringify(createdRecord)}`);
|
|
112
141
|
const artifactId = stringValue(createdRecord.artifactId, "artifactId");
|
|
113
142
|
const artifactVersionId = stringValue(createdRecord.artifactVersionId, "artifactVersionId");
|
|
114
|
-
const bundle = await packArtifactAppDirectory(absoluteAppDir);
|
|
115
143
|
const bearerToken = await getBearerToken(opts);
|
|
116
144
|
const deployed = await fetchJson(opts, `${artifactAppsPath(opts)}/${encodeURIComponent(artifactId)}/deploy-bundle`, {
|
|
117
145
|
method: "POST",
|
|
@@ -120,7 +148,7 @@ export async function deployArtifactApp(opts, input) {
|
|
|
120
148
|
"content-type": "application/gzip",
|
|
121
149
|
"MCP-Protocol-Version": opts.protocolVersion,
|
|
122
150
|
"x-platform-artifact-version-id": artifactVersionId,
|
|
123
|
-
"x-platform-app-dir":
|
|
151
|
+
"x-platform-app-dir": platformAppDir
|
|
124
152
|
},
|
|
125
153
|
body: bundle.body
|
|
126
154
|
});
|
|
@@ -195,8 +223,9 @@ export async function openArtifact(opts, artifactId) {
|
|
|
195
223
|
versionUrl: typeof draft?.versionUrl === "string" ? draft.versionUrl : typeof live?.versionUrl === "string" ? live.versionUrl : null
|
|
196
224
|
};
|
|
197
225
|
}
|
|
198
|
-
export async function packArtifactAppDirectory(appDir) {
|
|
226
|
+
export async function packArtifactAppDirectory(appDir, archivePrefix = "") {
|
|
199
227
|
const root = path.resolve(appDir);
|
|
228
|
+
const prefix = archivePrefix ? validateArchivePrefix(archivePrefix) : "";
|
|
200
229
|
const files = [];
|
|
201
230
|
const excluded = [];
|
|
202
231
|
await walk(root, root, files, excluded);
|
|
@@ -209,7 +238,7 @@ export async function packArtifactAppDirectory(appDir) {
|
|
|
209
238
|
if (byteCount > MAX_UNPACKED_BYTES)
|
|
210
239
|
throw new Error(`artifact_app_bundle_unpacked_too_large:${MAX_UNPACKED_BYTES}`);
|
|
211
240
|
const content = await readFile(file.absolute);
|
|
212
|
-
chunks.push(tarHeader(file.relative, content.length));
|
|
241
|
+
chunks.push(tarHeader(prefix ? `${prefix}/${file.relative}` : file.relative, content.length));
|
|
213
242
|
chunks.push(content);
|
|
214
243
|
chunks.push(Buffer.alloc(padSize(content.length)));
|
|
215
244
|
}
|
|
@@ -262,11 +291,20 @@ function resolveWorkspacePath(workspaceRoot, inputPath) {
|
|
|
262
291
|
throw new Error("appDir_must_be_inside_workspace_root");
|
|
263
292
|
return resolved;
|
|
264
293
|
}
|
|
294
|
+
function workspaceRelativeAppDir(workspaceRoot, absoluteAppDir) {
|
|
295
|
+
const relative = toPosix(path.relative(path.resolve(workspaceRoot), path.resolve(absoluteAppDir)));
|
|
296
|
+
return relative ? validateArchivePrefix(relative) : ".";
|
|
297
|
+
}
|
|
298
|
+
function validateArchivePrefix(prefix) {
|
|
299
|
+
const normalized = toPosix(prefix).replace(/^\/+|\/+$/g, "");
|
|
300
|
+
validateRelativePath(normalized);
|
|
301
|
+
return normalized;
|
|
302
|
+
}
|
|
265
303
|
function validateRelativePath(relative) {
|
|
266
304
|
if (!relative || relative.startsWith("/") || /^[A-Za-z]:\//.test(relative))
|
|
267
305
|
throw new Error("artifact_app_bundle_path_invalid");
|
|
268
306
|
const parts = relative.split("/").filter(Boolean);
|
|
269
|
-
if (parts.some((part) => part === ".."))
|
|
307
|
+
if (parts.some((part) => part === "." || part === ".."))
|
|
270
308
|
throw new Error(`artifact_app_bundle_path_escape:${relative}`);
|
|
271
309
|
if (parts.length > MAX_DEPTH)
|
|
272
310
|
throw new Error(`artifact_app_bundle_depth_limit:${MAX_DEPTH}`);
|
|
@@ -443,7 +481,9 @@ export async function getBearerToken(opts) {
|
|
|
443
481
|
await writeStoredOAuthCredentials(opts, refreshed);
|
|
444
482
|
return refreshed.accessToken;
|
|
445
483
|
}
|
|
446
|
-
catch {
|
|
484
|
+
catch (err) {
|
|
485
|
+
if (!isRefreshTokenRejected(err))
|
|
486
|
+
throw err;
|
|
447
487
|
// Fall through to an interactive login; the stored refresh token may have been revoked.
|
|
448
488
|
}
|
|
449
489
|
}
|
|
@@ -468,6 +508,10 @@ async function refreshOAuthToken(opts, stored) {
|
|
|
468
508
|
});
|
|
469
509
|
return credentialsFromTokenResponse(stored.issuer, stored.resource, stored.clientId, stored.scopes, token);
|
|
470
510
|
}
|
|
511
|
+
function isRefreshTokenRejected(err) {
|
|
512
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
513
|
+
return /^topline_oauth_http_error:(400|401):/.test(message) && /invalid_(grant|token)/i.test(message);
|
|
514
|
+
}
|
|
471
515
|
async function runOAuthLogin(opts) {
|
|
472
516
|
const issuer = platformBaseUrl(opts);
|
|
473
517
|
const resource = `${issuer}/mcp/harness`;
|
|
@@ -497,8 +541,9 @@ async function runOAuthLogin(opts) {
|
|
|
497
541
|
authUrl.searchParams.set("code_challenge", challenge);
|
|
498
542
|
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
499
543
|
authUrl.searchParams.set("state", state);
|
|
544
|
+
const codePromise = callback.waitForCode(state);
|
|
500
545
|
await openBrowser(opts, authUrl.toString());
|
|
501
|
-
const code = await
|
|
546
|
+
const code = await codePromise;
|
|
502
547
|
const token = await oauthFetchJson(opts, `${issuer}/oauth/token`, {
|
|
503
548
|
method: "POST",
|
|
504
549
|
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
@@ -523,11 +568,22 @@ async function oauthFetchJson(opts, url, init) {
|
|
|
523
568
|
try {
|
|
524
569
|
const response = await (opts.fetchImpl ?? fetch)(url, { ...init, signal: controller.signal });
|
|
525
570
|
const text = await response.text();
|
|
526
|
-
|
|
571
|
+
let payload = {};
|
|
572
|
+
try {
|
|
573
|
+
payload = text ? JSON.parse(text) : {};
|
|
574
|
+
}
|
|
575
|
+
catch {
|
|
576
|
+
payload = { detail: text.slice(0, 500) };
|
|
577
|
+
}
|
|
527
578
|
if (!response.ok)
|
|
528
579
|
throw new Error(`topline_oauth_http_error:${response.status}:${redactSecrets(JSON.stringify(payload), opts)}`);
|
|
529
580
|
return payload;
|
|
530
581
|
}
|
|
582
|
+
catch (err) {
|
|
583
|
+
if (controller.signal.aborted)
|
|
584
|
+
throw new Error("topline_oauth_timeout");
|
|
585
|
+
throw err;
|
|
586
|
+
}
|
|
531
587
|
finally {
|
|
532
588
|
clearTimeout(timeout);
|
|
533
589
|
}
|
|
@@ -582,7 +638,7 @@ async function writeStoredOAuthCredentials(opts, credentials) {
|
|
|
582
638
|
async function startOAuthCallbackServer() {
|
|
583
639
|
let resolveCode = null;
|
|
584
640
|
let rejectCode = null;
|
|
585
|
-
let expectedState =
|
|
641
|
+
let expectedState = null;
|
|
586
642
|
const codePromise = new Promise((resolve, reject) => {
|
|
587
643
|
resolveCode = resolve;
|
|
588
644
|
rejectCode = reject;
|
|
@@ -591,7 +647,13 @@ async function startOAuthCallbackServer() {
|
|
|
591
647
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
592
648
|
const code = url.searchParams.get("code");
|
|
593
649
|
const state = url.searchParams.get("state");
|
|
594
|
-
if (url.pathname !== "/callback" ||
|
|
650
|
+
if (!expectedState || url.pathname !== "/callback" || state !== expectedState) {
|
|
651
|
+
res.writeHead(400, { "content-type": "text/html; charset=utf-8" });
|
|
652
|
+
res.end("<!doctype html><meta charset=\"utf-8\"><h1>Topline authorization failed</h1>");
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
const error = url.searchParams.get("error");
|
|
656
|
+
if (error || !code) {
|
|
595
657
|
res.writeHead(400, { "content-type": "text/html; charset=utf-8" });
|
|
596
658
|
res.end("<!doctype html><meta charset=\"utf-8\"><h1>Topline authorization failed</h1>");
|
|
597
659
|
rejectCode?.(new Error("topline_oauth_callback_invalid"));
|
|
@@ -634,8 +696,7 @@ async function openBrowser(opts, url) {
|
|
|
634
696
|
await opts.openUrl(url);
|
|
635
697
|
return;
|
|
636
698
|
}
|
|
637
|
-
const command
|
|
638
|
-
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
699
|
+
const { command, args } = browserOpenCommand(url);
|
|
639
700
|
try {
|
|
640
701
|
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
641
702
|
child.unref();
|
|
@@ -644,6 +705,13 @@ async function openBrowser(opts, url) {
|
|
|
644
705
|
console.error(`Open this URL to authorize Topline MCP:\n${url}`);
|
|
645
706
|
}
|
|
646
707
|
}
|
|
708
|
+
export function browserOpenCommand(url, platform = process.platform) {
|
|
709
|
+
if (platform === "win32")
|
|
710
|
+
return { command: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] };
|
|
711
|
+
if (platform === "darwin")
|
|
712
|
+
return { command: "open", args: [url] };
|
|
713
|
+
return { command: "xdg-open", args: [url] };
|
|
714
|
+
}
|
|
647
715
|
function artifactAppsPath(opts) {
|
|
648
716
|
return opts.apiUrl.endsWith("/api/harness")
|
|
649
717
|
? `${opts.apiUrl}/artifact-apps`
|
|
@@ -657,9 +725,15 @@ function defaultAuthFile(apiUrl) {
|
|
|
657
725
|
return path.join(os.homedir(), ".topline", "harness-mcp", `${key}.json`);
|
|
658
726
|
}
|
|
659
727
|
function normalizeApiUrl(raw) {
|
|
660
|
-
|
|
728
|
+
let url;
|
|
729
|
+
try {
|
|
730
|
+
url = new URL(raw);
|
|
731
|
+
}
|
|
732
|
+
catch {
|
|
733
|
+
throw new Error("TOPLINE_API_URL must be an http(s) URL");
|
|
734
|
+
}
|
|
661
735
|
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
662
|
-
throw new Error("
|
|
736
|
+
throw new Error("TOPLINE_API_URL must be an http(s) URL");
|
|
663
737
|
return url.toString().replace(/\/$/g, "");
|
|
664
738
|
}
|
|
665
739
|
function firstEnv(env, ...keys) {
|