@topline-build/harness-mcp 0.1.1 → 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 +6 -0
- package/dist/index.js +79 -16
- 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<{
|
|
@@ -72,3 +74,7 @@ export declare function openArtifact(opts: HarnessMcpStdioOptions, artifactId: s
|
|
|
72
74
|
}>;
|
|
73
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 {
|
|
@@ -96,6 +123,7 @@ export async function deployArtifactApp(opts, input) {
|
|
|
96
123
|
const absoluteAppDir = resolveWorkspacePath(opts.workspaceRoot, appDir);
|
|
97
124
|
const platformAppDir = workspaceRelativeAppDir(opts.workspaceRoot, absoluteAppDir);
|
|
98
125
|
const manifest = await readManifest(absoluteAppDir);
|
|
126
|
+
const bundle = await packArtifactAppDirectory(absoluteAppDir, platformAppDir === "." ? "" : platformAppDir);
|
|
99
127
|
const created = await fetchJson(opts, artifactAppsPath(opts), {
|
|
100
128
|
method: "POST",
|
|
101
129
|
headers: await jsonHeaders(opts),
|
|
@@ -112,7 +140,6 @@ export async function deployArtifactApp(opts, input) {
|
|
|
112
140
|
throw new Error(`artifact_app_create_failed:${JSON.stringify(createdRecord)}`);
|
|
113
141
|
const artifactId = stringValue(createdRecord.artifactId, "artifactId");
|
|
114
142
|
const artifactVersionId = stringValue(createdRecord.artifactVersionId, "artifactVersionId");
|
|
115
|
-
const bundle = await packArtifactAppDirectory(absoluteAppDir, platformAppDir);
|
|
116
143
|
const bearerToken = await getBearerToken(opts);
|
|
117
144
|
const deployed = await fetchJson(opts, `${artifactAppsPath(opts)}/${encodeURIComponent(artifactId)}/deploy-bundle`, {
|
|
118
145
|
method: "POST",
|
|
@@ -266,7 +293,7 @@ function resolveWorkspacePath(workspaceRoot, inputPath) {
|
|
|
266
293
|
}
|
|
267
294
|
function workspaceRelativeAppDir(workspaceRoot, absoluteAppDir) {
|
|
268
295
|
const relative = toPosix(path.relative(path.resolve(workspaceRoot), path.resolve(absoluteAppDir)));
|
|
269
|
-
return validateArchivePrefix(relative);
|
|
296
|
+
return relative ? validateArchivePrefix(relative) : ".";
|
|
270
297
|
}
|
|
271
298
|
function validateArchivePrefix(prefix) {
|
|
272
299
|
const normalized = toPosix(prefix).replace(/^\/+|\/+$/g, "");
|
|
@@ -454,7 +481,9 @@ export async function getBearerToken(opts) {
|
|
|
454
481
|
await writeStoredOAuthCredentials(opts, refreshed);
|
|
455
482
|
return refreshed.accessToken;
|
|
456
483
|
}
|
|
457
|
-
catch {
|
|
484
|
+
catch (err) {
|
|
485
|
+
if (!isRefreshTokenRejected(err))
|
|
486
|
+
throw err;
|
|
458
487
|
// Fall through to an interactive login; the stored refresh token may have been revoked.
|
|
459
488
|
}
|
|
460
489
|
}
|
|
@@ -479,6 +508,10 @@ async function refreshOAuthToken(opts, stored) {
|
|
|
479
508
|
});
|
|
480
509
|
return credentialsFromTokenResponse(stored.issuer, stored.resource, stored.clientId, stored.scopes, token);
|
|
481
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
|
+
}
|
|
482
515
|
async function runOAuthLogin(opts) {
|
|
483
516
|
const issuer = platformBaseUrl(opts);
|
|
484
517
|
const resource = `${issuer}/mcp/harness`;
|
|
@@ -508,8 +541,9 @@ async function runOAuthLogin(opts) {
|
|
|
508
541
|
authUrl.searchParams.set("code_challenge", challenge);
|
|
509
542
|
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
510
543
|
authUrl.searchParams.set("state", state);
|
|
544
|
+
const codePromise = callback.waitForCode(state);
|
|
511
545
|
await openBrowser(opts, authUrl.toString());
|
|
512
|
-
const code = await
|
|
546
|
+
const code = await codePromise;
|
|
513
547
|
const token = await oauthFetchJson(opts, `${issuer}/oauth/token`, {
|
|
514
548
|
method: "POST",
|
|
515
549
|
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
@@ -534,11 +568,22 @@ async function oauthFetchJson(opts, url, init) {
|
|
|
534
568
|
try {
|
|
535
569
|
const response = await (opts.fetchImpl ?? fetch)(url, { ...init, signal: controller.signal });
|
|
536
570
|
const text = await response.text();
|
|
537
|
-
|
|
571
|
+
let payload = {};
|
|
572
|
+
try {
|
|
573
|
+
payload = text ? JSON.parse(text) : {};
|
|
574
|
+
}
|
|
575
|
+
catch {
|
|
576
|
+
payload = { detail: text.slice(0, 500) };
|
|
577
|
+
}
|
|
538
578
|
if (!response.ok)
|
|
539
579
|
throw new Error(`topline_oauth_http_error:${response.status}:${redactSecrets(JSON.stringify(payload), opts)}`);
|
|
540
580
|
return payload;
|
|
541
581
|
}
|
|
582
|
+
catch (err) {
|
|
583
|
+
if (controller.signal.aborted)
|
|
584
|
+
throw new Error("topline_oauth_timeout");
|
|
585
|
+
throw err;
|
|
586
|
+
}
|
|
542
587
|
finally {
|
|
543
588
|
clearTimeout(timeout);
|
|
544
589
|
}
|
|
@@ -593,7 +638,7 @@ async function writeStoredOAuthCredentials(opts, credentials) {
|
|
|
593
638
|
async function startOAuthCallbackServer() {
|
|
594
639
|
let resolveCode = null;
|
|
595
640
|
let rejectCode = null;
|
|
596
|
-
let expectedState =
|
|
641
|
+
let expectedState = null;
|
|
597
642
|
const codePromise = new Promise((resolve, reject) => {
|
|
598
643
|
resolveCode = resolve;
|
|
599
644
|
rejectCode = reject;
|
|
@@ -602,7 +647,13 @@ async function startOAuthCallbackServer() {
|
|
|
602
647
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
603
648
|
const code = url.searchParams.get("code");
|
|
604
649
|
const state = url.searchParams.get("state");
|
|
605
|
-
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) {
|
|
606
657
|
res.writeHead(400, { "content-type": "text/html; charset=utf-8" });
|
|
607
658
|
res.end("<!doctype html><meta charset=\"utf-8\"><h1>Topline authorization failed</h1>");
|
|
608
659
|
rejectCode?.(new Error("topline_oauth_callback_invalid"));
|
|
@@ -645,8 +696,7 @@ async function openBrowser(opts, url) {
|
|
|
645
696
|
await opts.openUrl(url);
|
|
646
697
|
return;
|
|
647
698
|
}
|
|
648
|
-
const command
|
|
649
|
-
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
699
|
+
const { command, args } = browserOpenCommand(url);
|
|
650
700
|
try {
|
|
651
701
|
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
652
702
|
child.unref();
|
|
@@ -655,6 +705,13 @@ async function openBrowser(opts, url) {
|
|
|
655
705
|
console.error(`Open this URL to authorize Topline MCP:\n${url}`);
|
|
656
706
|
}
|
|
657
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
|
+
}
|
|
658
715
|
function artifactAppsPath(opts) {
|
|
659
716
|
return opts.apiUrl.endsWith("/api/harness")
|
|
660
717
|
? `${opts.apiUrl}/artifact-apps`
|
|
@@ -668,9 +725,15 @@ function defaultAuthFile(apiUrl) {
|
|
|
668
725
|
return path.join(os.homedir(), ".topline", "harness-mcp", `${key}.json`);
|
|
669
726
|
}
|
|
670
727
|
function normalizeApiUrl(raw) {
|
|
671
|
-
|
|
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
|
+
}
|
|
672
735
|
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
673
|
-
throw new Error("
|
|
736
|
+
throw new Error("TOPLINE_API_URL must be an http(s) URL");
|
|
674
737
|
return url.toString().replace(/\/$/g, "");
|
|
675
738
|
}
|
|
676
739
|
function firstEnv(env, ...keys) {
|