@topline-build/harness-mcp 0.1.2 → 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.
@@ -13,6 +13,7 @@ Required environment:
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`
@@ -43,6 +44,32 @@ npx -y @topline-build/harness-mcp --url https://hawx.topline.build
43
44
  Set `TOPLINE_WORKSPACE_ROOT` when your MCP host starts outside the repo that
44
45
  contains the artifact app.
45
46
 
46
- Topline Developer Access provides harness-specific snippets for Codex and
47
- Claude Code. Those snippets contain no raw token; the first tool call opens a
48
- browser approval flow.
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,7 +29,7 @@ export interface PackedArtifactAppBundle {
27
29
  sha256: string;
28
30
  excluded: string[];
29
31
  }
30
- export declare const HARNESS_MCP_VERSION = "0.1.2";
32
+ export declare const HARNESS_MCP_VERSION = "0.2.0";
31
33
  export declare function parseHarnessOptions(argv: string[], env?: Record<string, string | undefined>): HarnessMcpStdioOptions;
32
34
  export declare function runHarnessMcpStdioCli(): Promise<void>;
33
35
  export declare function cliInfo(argv: string[]): string | null;
@@ -72,7 +74,47 @@ export declare function openArtifact(opts: HarnessMcpStdioOptions, artifactId: s
72
74
  liveUrl: string | null;
73
75
  versionUrl: string | null;
74
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
+ };
75
116
  export declare function packArtifactAppDirectory(appDir: string, archivePrefix?: string): Promise<PackedArtifactAppBundle>;
117
+ export declare function listTools(): ListToolsResult;
76
118
  export declare function getBearerToken(opts: HarnessMcpStdioOptions): Promise<string>;
77
119
  export declare function browserOpenCommand(url: string, platform?: string): {
78
120
  command: string;
package/dist/index.js CHANGED
@@ -8,11 +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
- export const HARNESS_MCP_VERSION = "0.1.2";
15
+ const SERVER_NAME = "topline-build-stdio";
16
+ export const HARNESS_MCP_VERSION = "0.2.0";
16
17
  const DEFAULT_OAUTH_SCOPES = ["harness:artifact:read", "harness:artifact:write", "harness:artifact:deploy"];
17
18
  const BLOCK_SIZE = 512;
18
19
  const MAX_BUNDLE_BYTES = 64 * 1024 * 1024;
@@ -34,6 +35,9 @@ export function parseHarnessOptions(argv, env = process.env) {
34
35
  const token = getArg("--token") || firstEnv(env, "TOPLINE_MCP_TOKEN", "PLATFORM_MCP_TOKEN", "OVERSITE_MCP_TOKEN") || undefined;
35
36
  const timeoutRaw = getArg("--timeout-ms") || firstEnv(env, "TOPLINE_MCP_TIMEOUT_MS", "PLATFORM_MCP_TIMEOUT_MS", "OVERSITE_MCP_TIMEOUT_MS");
36
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");
37
41
  const workspaceRoot = path.resolve(getArg("--workspace-root") || firstEnv(env, "TOPLINE_WORKSPACE_ROOT", "PLATFORM_WORKSPACE_ROOT") || process.cwd());
38
42
  const timeoutMs = timeoutRaw ? Number(timeoutRaw) : DEFAULT_TIMEOUT_MS;
39
43
  if (!apiUrl)
@@ -41,13 +45,14 @@ export function parseHarnessOptions(argv, env = process.env) {
41
45
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
42
46
  throw new Error("TOPLINE_MCP_TIMEOUT_MS must be a positive number");
43
47
  const normalizedApiUrl = normalizeApiUrl(apiUrl);
44
- 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));
45
49
  const scopes = (getArg("--scopes") || firstEnv(env, "TOPLINE_MCP_SCOPES", "PLATFORM_MCP_SCOPES") || DEFAULT_OAUTH_SCOPES.join(" "))
46
50
  .split(/[\s,]+/)
47
51
  .map((scope) => scope.trim())
48
52
  .filter(Boolean);
49
53
  return {
50
54
  apiUrl: normalizedApiUrl,
55
+ surface: surfaceRaw,
51
56
  token,
52
57
  authFile,
53
58
  oauthScopes: scopes.length > 0 ? [...new Set(scopes)] : DEFAULT_OAUTH_SCOPES,
@@ -78,6 +83,7 @@ export function cliInfo(argv) {
78
83
  "Environment:",
79
84
  " TOPLINE_API_URL Required unless --url is passed",
80
85
  " TOPLINE_WORKSPACE_ROOT Defaults to the current working directory",
86
+ " TOPLINE_MCP_SURFACE Defaults to build; harness keeps legacy OAuth compatibility",
81
87
  " TOPLINE_MCP_TOKEN Optional manual token fallback",
82
88
  " TOPLINE_MCP_AUTH_FILE Optional OAuth credential cache path",
83
89
  " TOPLINE_MCP_TIMEOUT_MS Defaults to 120000",
@@ -90,11 +96,21 @@ export function cliInfo(argv) {
90
96
  export function createHarnessMcpStdioServer(opts) {
91
97
  const mcp = new McpServer({ name: SERVER_NAME, version: HARNESS_MCP_VERSION }, { capabilities: { tools: {} } });
92
98
  mcp.setRequestHandler(ListToolsRequestSchema, async () => listTools());
93
- 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");
94
103
  try {
95
- 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;
96
108
  }
97
109
  catch (err) {
110
+ if (err instanceof McpError)
111
+ throw err;
112
+ if (reportsProgress)
113
+ await sendProgress(extra, 1, 1, "Failed artifact deployment");
98
114
  return {
99
115
  content: [{ type: "text", text: redactSecrets(err instanceof Error ? err.message : String(err), opts) }],
100
116
  isError: true
@@ -105,18 +121,36 @@ export function createHarnessMcpStdioServer(opts) {
105
121
  }
106
122
  export async function callHarnessTool(opts, name, args) {
107
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;
108
131
  switch (name) {
109
132
  case "platform_deploy_artifact_app":
110
- return toolResult(await deployArtifactApp(opts, input));
133
+ result = toolResult(await deployArtifactApp(opts, input));
134
+ break;
111
135
  case "platform_artifact_status":
112
- 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;
113
138
  case "platform_read_artifact_logs":
114
- return toolResult(await readArtifactLogs(opts, input));
139
+ result = toolResult(await readArtifactLogs(opts, input));
140
+ break;
115
141
  case "platform_open_artifact":
116
- 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;
117
147
  default:
118
- return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
148
+ throw new McpError(ErrorCode.InvalidParams, `Unknown tool: ${name}`);
119
149
  }
150
+ const outputError = tool.outputSchema
151
+ ? validateJsonSchema(tool.outputSchema, result.structuredContent)
152
+ : null;
153
+ return outputError ? toolError(`Invalid output for ${name}: ${outputError}`) : result;
120
154
  }
121
155
  export async function deployArtifactApp(opts, input) {
122
156
  const appDir = stringArg(input, "appDir");
@@ -223,6 +257,36 @@ export async function openArtifact(opts, artifactId) {
223
257
  versionUrl: typeof draft?.versionUrl === "string" ? draft.versionUrl : typeof live?.versionUrl === "string" ? live.versionUrl : null
224
258
  };
225
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
+ }
226
290
  export async function packArtifactAppDirectory(appDir, archivePrefix = "") {
227
291
  const root = path.resolve(appDir);
228
292
  const prefix = archivePrefix ? validateArchivePrefix(archivePrefix) : "";
@@ -393,72 +457,170 @@ async function fetchJson(opts, url, init) {
393
457
  clearTimeout(timeout);
394
458
  }
395
459
  }
396
- function listTools() {
460
+ export function listTools() {
397
461
  return {
398
462
  tools: [
399
463
  {
400
464
  name: "platform_deploy_artifact_app",
401
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 }),
402
467
  inputSchema: {
403
468
  type: "object",
404
469
  properties: {
405
- appDir: { type: "string" },
470
+ appDir: { type: "string", minLength: 1 },
406
471
  artifactId: { type: "string" },
407
472
  title: { type: "string" },
408
473
  description: { type: "string" },
409
474
  projectId: { type: "string" }
410
475
  },
411
- 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"]
412
490
  }
413
491
  },
414
492
  {
415
493
  name: "platform_artifact_status",
416
494
  description: "Read private draft deployment status for a hosted artifact app.",
495
+ annotations: mcpToolAnnotations("platform_artifact_status", { readOnly: true, openWorld: true }),
417
496
  inputSchema: {
418
497
  type: "object",
419
- properties: { artifactId: { type: "string" }, limit: { type: "number" } },
420
- 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"]
421
509
  }
422
510
  },
423
511
  {
424
512
  name: "platform_read_artifact_logs",
425
513
  description: "Read bounded, redacted deployment logs for a hosted artifact app.",
514
+ annotations: mcpToolAnnotations("platform_read_artifact_logs", { readOnly: true, openWorld: true }),
426
515
  inputSchema: {
427
516
  type: "object",
428
517
  properties: {
429
- artifactId: { type: "string" },
518
+ artifactId: { type: "string", minLength: 1 },
430
519
  deploymentRecordId: { type: "string" },
431
520
  environment: { type: "string", enum: ["draft", "live"] },
432
- limit: { type: "number" },
521
+ limit: { type: "integer", minimum: 1, maximum: 200 },
433
522
  severity: { type: "string" },
434
523
  contains: { type: "string" },
435
- since: { type: "string" },
436
- windowMinutes: { type: "number" },
524
+ since: { type: "string", format: "date-time" },
525
+ windowMinutes: { type: "integer", minimum: 1, maximum: 1440 },
437
526
  trace: { type: "string" },
438
527
  requestId: { type: "string" },
439
528
  route: { type: "string" }
440
529
  },
441
- 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"]
442
542
  }
443
543
  },
444
544
  {
445
545
  name: "platform_open_artifact",
446
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 }),
447
567
  inputSchema: {
448
568
  type: "object",
449
- properties: { artifactId: { type: "string" } },
450
- 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"]
451
578
  }
452
579
  }
453
580
  ]
454
581
  };
455
582
  }
456
- function toolResult(value) {
583
+ function mcpToolAnnotations(name, policy) {
457
584
  return {
458
- content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
459
- 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
460
590
  };
461
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
+ }
462
624
  async function jsonHeaders(opts) {
463
625
  const bearerToken = await getBearerToken(opts);
464
626
  return {
@@ -514,7 +676,7 @@ function isRefreshTokenRejected(err) {
514
676
  }
515
677
  async function runOAuthLogin(opts) {
516
678
  const issuer = platformBaseUrl(opts);
517
- const resource = `${issuer}/mcp/harness`;
679
+ const resource = `${issuer}/mcp/${mcpSurface(opts)}`;
518
680
  const callback = await startOAuthCallbackServer();
519
681
  try {
520
682
  const registration = await oauthFetchJson(opts, `${issuer}/oauth/register`, {
@@ -617,6 +779,11 @@ async function readStoredOAuthCredentials(opts) {
617
779
  const scopes = Array.isArray(parsed.scopes) ? parsed.scopes.filter((scope) => typeof scope === "string") : DEFAULT_OAUTH_SCOPES;
618
780
  if (!issuer || !resource || !clientId)
619
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;
620
787
  return {
621
788
  issuer,
622
789
  resource,
@@ -720,9 +887,12 @@ function artifactAppsPath(opts) {
720
887
  function platformBaseUrl(opts) {
721
888
  return opts.apiUrl.replace(/\/api\/harness$/, "").replace(/\/$/, "");
722
889
  }
723
- function defaultAuthFile(apiUrl) {
724
- const key = createHash("sha256").update(platformBaseUrl({ apiUrl })).digest("hex").slice(0, 16);
725
- 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`);
726
896
  }
727
897
  function normalizeApiUrl(raw) {
728
898
  let url;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@topline-build/harness-mcp",
3
- "version": "0.1.2",
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",