@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 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(`missing_platform_harness_arg:${name}`);
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("missing_platform_api_url");
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("invalid_platform_mcp_timeout");
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 opts = parseHarnessOptions(process.argv.slice(2));
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: "0.1.0" }, { capabilities: { tools: {} } });
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 callback.waitForCode(state);
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
- const payload = text ? JSON.parse(text) : {};
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" || !code || state !== expectedState) {
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 = process.platform === "win32" ? "cmd" : process.platform === "darwin" ? "open" : "xdg-open";
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
- const url = new URL(raw);
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("invalid_platform_api_url_scheme");
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topline-build/harness-mcp",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Local stdio MCP connector for deploying Topline artifact app drafts.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",