@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 CHANGED
@@ -1,4 +1,4 @@
1
- # Topline Harness MCP
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
- Topline Developer Access provides harness-specific snippets for Codex and
36
- Claude Code. Those snippets contain no raw token; the first tool call opens a
37
- browser approval flow.
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 { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
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-harness-stdio";
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(`missing_platform_harness_arg:${name}`);
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("missing_platform_api_url");
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("invalid_platform_mcp_timeout");
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 opts = parseHarnessOptions(process.argv.slice(2));
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: "0.1.0" }, { capabilities: { tools: {} } });
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
- return await callHarnessTool(opts, req.params.name, req.params.arguments ?? {});
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
- return toolResult(await deployArtifactApp(opts, input));
133
+ result = toolResult(await deployArtifactApp(opts, input));
134
+ break;
84
135
  case "platform_artifact_status":
85
- return toolResult(await getArtifactStatus(opts, stringArg(input, "artifactId"), numberArg(input, "limit")));
136
+ result = toolResult(await getArtifactStatus(opts, stringArg(input, "artifactId"), numberArg(input, "limit")));
137
+ break;
86
138
  case "platform_read_artifact_logs":
87
- return toolResult(await readArtifactLogs(opts, input));
139
+ result = toolResult(await readArtifactLogs(opts, input));
140
+ break;
88
141
  case "platform_open_artifact":
89
- return toolResult(await openArtifact(opts, stringArg(input, "artifactId")));
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
- return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
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: "number" } },
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: "number" },
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: "number" },
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: { artifactId: { type: "string" } },
423
- required: ["artifactId"]
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 toolResult(value) {
583
+ function mcpToolAnnotations(name, policy) {
430
584
  return {
431
- content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
432
- structuredContent: value
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/harness`;
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 callback.waitForCode(state);
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
- const payload = text ? JSON.parse(text) : {};
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" || !code || state !== expectedState) {
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 = process.platform === "win32" ? "cmd" : process.platform === "darwin" ? "open" : "xdg-open";
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 defaultAuthFile(apiUrl) {
667
- const key = createHash("sha256").update(platformBaseUrl({ apiUrl })).digest("hex").slice(0, 16);
668
- return path.join(os.homedir(), ".topline", "harness-mcp", `${key}.json`);
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
- const url = new URL(raw);
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("invalid_platform_api_url_scheme");
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.1.1",
4
- "description": "Local stdio MCP connector for deploying Topline artifact app drafts.",
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",