@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 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(`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 {
@@ -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 callback.waitForCode(state);
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
- 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
+ }
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" || !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) {
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 = process.platform === "win32" ? "cmd" : process.platform === "darwin" ? "open" : "xdg-open";
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
- 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
+ }
661
735
  if (url.protocol !== "http:" && url.protocol !== "https:")
662
- throw new Error("invalid_platform_api_url_scheme");
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topline-build/harness-mcp",
3
- "version": "0.1.0",
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",