@topline-build/harness-mcp 0.1.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 ADDED
@@ -0,0 +1,37 @@
1
+ # Topline Harness MCP
2
+
3
+ Local stdio MCP connector for deploying Topline artifact app drafts from Codex,
4
+ Claude Code, or another local MCP host.
5
+
6
+ The normal auth path is OAuth with browser approval. The helper stores OAuth
7
+ refresh tokens in the user's local Topline config directory, outside the project
8
+ repo.
9
+
10
+ Required environment:
11
+
12
+ - `TOPLINE_API_URL`
13
+
14
+ Optional environment:
15
+
16
+ - `TOPLINE_MCP_PROTOCOL_VERSION`
17
+ - `TOPLINE_WORKSPACE_ROOT`
18
+ - `TOPLINE_MCP_AUTH_FILE`
19
+ - `TOPLINE_MCP_SCOPES`
20
+ - `TOPLINE_MCP_TOKEN` for advanced manual-token fallback
21
+
22
+ Compatibility aliases are still accepted for older local setups:
23
+
24
+ - `PLATFORM_API_URL`
25
+ - `PLATFORM_MCP_TOKEN`
26
+ - `PLATFORM_WORKSPACE_ROOT`
27
+ - `PLATFORM_MCP_PROTOCOL_VERSION`
28
+
29
+ ## Usage
30
+
31
+ ```bash
32
+ npx -y @topline-build/harness-mcp
33
+ ```
34
+
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.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ import { runHarnessMcpStdioCli } from "./index.js";
3
+ runHarnessMcpStdioCli().catch((err) => {
4
+ console.error(err instanceof Error ? err.message : String(err));
5
+ process.exit(1);
6
+ });
@@ -0,0 +1,74 @@
1
+ import { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import { type CallToolResult } from "@modelcontextprotocol/sdk/types.js";
3
+ export interface HarnessMcpStdioOptions {
4
+ apiUrl: string;
5
+ token?: string;
6
+ authFile: string;
7
+ oauthScopes: string[];
8
+ timeoutMs: number;
9
+ protocolVersion: string;
10
+ workspaceRoot: string;
11
+ fetchImpl?: typeof fetch;
12
+ openUrl?: (url: string) => void | Promise<void>;
13
+ }
14
+ export interface StoredOAuthCredentials {
15
+ issuer: string;
16
+ resource: string;
17
+ clientId: string;
18
+ accessToken?: string;
19
+ accessTokenExpiresAt?: string;
20
+ refreshToken?: string;
21
+ scopes: string[];
22
+ }
23
+ export interface PackedArtifactAppBundle {
24
+ body: Buffer;
25
+ fileCount: number;
26
+ byteCount: number;
27
+ sha256: string;
28
+ excluded: string[];
29
+ }
30
+ export declare function parseHarnessOptions(argv: string[], env?: Record<string, string | undefined>): HarnessMcpStdioOptions;
31
+ export declare function runHarnessMcpStdioCli(): Promise<void>;
32
+ export declare function createHarnessMcpStdioServer(opts: HarnessMcpStdioOptions): McpServer;
33
+ export declare function callHarnessTool(opts: HarnessMcpStdioOptions, name: string, args: unknown): Promise<CallToolResult>;
34
+ export declare function deployArtifactApp(opts: HarnessMcpStdioOptions, input: Record<string, unknown>): Promise<{
35
+ ok: boolean;
36
+ artifactId: string;
37
+ artifactVersionId: string;
38
+ manifestId: string | null;
39
+ bundle: {
40
+ fileCount: number;
41
+ byteCount: number;
42
+ sha256: string;
43
+ excluded: string[];
44
+ };
45
+ create: unknown;
46
+ deploy: unknown;
47
+ }>;
48
+ export declare function getArtifactStatus(opts: HarnessMcpStdioOptions, artifactId: string, limit?: number): Promise<unknown>;
49
+ export declare function readArtifactLogs(opts: HarnessMcpStdioOptions, input: Record<string, unknown>): Promise<unknown>;
50
+ export declare function openArtifact(opts: HarnessMcpStdioOptions, artifactId: string): Promise<{
51
+ ok: boolean;
52
+ artifactId: string;
53
+ openUrl: string;
54
+ promotionUrl: string;
55
+ livePromotion: {
56
+ exposedInMcp: boolean;
57
+ operatorAction: string;
58
+ };
59
+ sourceHandoff: {
60
+ readOnly: boolean;
61
+ url: string;
62
+ operatorAction: string;
63
+ };
64
+ scheduleHandoff: {
65
+ readOnly: boolean;
66
+ url: string;
67
+ operatorAction: string;
68
+ };
69
+ draftUrl: string | null;
70
+ liveUrl: string | null;
71
+ versionUrl: string | null;
72
+ }>;
73
+ export declare function packArtifactAppDirectory(appDir: string): Promise<PackedArtifactAppBundle>;
74
+ export declare function getBearerToken(opts: HarnessMcpStdioOptions): Promise<string>;
package/dist/index.js ADDED
@@ -0,0 +1,711 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { existsSync } from "node:fs";
4
+ import { mkdir, readdir, readFile, lstat, writeFile } from "node:fs/promises";
5
+ import { createServer } from "node:http";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { gzipSync } from "node:zlib";
9
+ import { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js";
10
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
12
+ const DEFAULT_MCP_PROTOCOL_VERSION = "2025-11-25";
13
+ const DEFAULT_TIMEOUT_MS = 120_000;
14
+ const SERVER_NAME = "topline-harness-stdio";
15
+ const DEFAULT_OAUTH_SCOPES = ["harness:artifact:read", "harness:artifact:write", "harness:artifact:deploy"];
16
+ const BLOCK_SIZE = 512;
17
+ const MAX_BUNDLE_BYTES = 64 * 1024 * 1024;
18
+ const MAX_UNPACKED_BYTES = 512 * 1024 * 1024;
19
+ const MAX_FILES = 25_000;
20
+ const MAX_DEPTH = 64;
21
+ const FORBIDDEN_DIRS = new Set([".git", "node_modules", ".next", ".vite", ".turbo", ".cache", "dist", "build", "coverage"]);
22
+ export function parseHarnessOptions(argv, env = process.env) {
23
+ const getArg = (name) => {
24
+ const index = argv.indexOf(name);
25
+ if (index < 0)
26
+ return undefined;
27
+ const value = argv[index + 1]?.trim();
28
+ if (!value || value.startsWith("--"))
29
+ throw new Error(`missing_platform_harness_arg:${name}`);
30
+ return value;
31
+ };
32
+ const apiUrl = getArg("--url") || firstEnv(env, "TOPLINE_API_URL", "PLATFORM_API_URL", "OVERSITE_API_URL");
33
+ const token = getArg("--token") || firstEnv(env, "TOPLINE_MCP_TOKEN", "PLATFORM_MCP_TOKEN", "OVERSITE_MCP_TOKEN") || undefined;
34
+ const timeoutRaw = getArg("--timeout-ms") || firstEnv(env, "TOPLINE_MCP_TIMEOUT_MS", "PLATFORM_MCP_TIMEOUT_MS", "OVERSITE_MCP_TIMEOUT_MS");
35
+ const protocolVersion = getArg("--protocol-version") || firstEnv(env, "TOPLINE_MCP_PROTOCOL_VERSION", "PLATFORM_MCP_PROTOCOL_VERSION") || DEFAULT_MCP_PROTOCOL_VERSION;
36
+ const workspaceRoot = path.resolve(getArg("--workspace-root") || firstEnv(env, "TOPLINE_WORKSPACE_ROOT", "PLATFORM_WORKSPACE_ROOT") || process.cwd());
37
+ const timeoutMs = timeoutRaw ? Number(timeoutRaw) : DEFAULT_TIMEOUT_MS;
38
+ if (!apiUrl)
39
+ throw new Error("missing_platform_api_url");
40
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
41
+ throw new Error("invalid_platform_mcp_timeout");
42
+ const normalizedApiUrl = normalizeApiUrl(apiUrl);
43
+ const authFile = path.resolve(getArg("--auth-file") || firstEnv(env, "TOPLINE_MCP_AUTH_FILE", "PLATFORM_MCP_AUTH_FILE") || defaultAuthFile(normalizedApiUrl));
44
+ const scopes = (getArg("--scopes") || firstEnv(env, "TOPLINE_MCP_SCOPES", "PLATFORM_MCP_SCOPES") || DEFAULT_OAUTH_SCOPES.join(" "))
45
+ .split(/[\s,]+/)
46
+ .map((scope) => scope.trim())
47
+ .filter(Boolean);
48
+ return {
49
+ apiUrl: normalizedApiUrl,
50
+ token,
51
+ authFile,
52
+ oauthScopes: scopes.length > 0 ? [...new Set(scopes)] : DEFAULT_OAUTH_SCOPES,
53
+ timeoutMs: Math.floor(timeoutMs),
54
+ protocolVersion,
55
+ workspaceRoot
56
+ };
57
+ }
58
+ export async function runHarnessMcpStdioCli() {
59
+ const opts = parseHarnessOptions(process.argv.slice(2));
60
+ const mcp = createHarnessMcpStdioServer(opts);
61
+ await mcp.connect(new StdioServerTransport());
62
+ }
63
+ export function createHarnessMcpStdioServer(opts) {
64
+ const mcp = new McpServer({ name: SERVER_NAME, version: "0.1.0" }, { capabilities: { tools: {} } });
65
+ mcp.setRequestHandler(ListToolsRequestSchema, async () => listTools());
66
+ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
67
+ try {
68
+ return await callHarnessTool(opts, req.params.name, req.params.arguments ?? {});
69
+ }
70
+ catch (err) {
71
+ return {
72
+ content: [{ type: "text", text: redactSecrets(err instanceof Error ? err.message : String(err), opts) }],
73
+ isError: true
74
+ };
75
+ }
76
+ });
77
+ return mcp;
78
+ }
79
+ export async function callHarnessTool(opts, name, args) {
80
+ const input = isRecord(args) ? args : {};
81
+ switch (name) {
82
+ case "platform_deploy_artifact_app":
83
+ return toolResult(await deployArtifactApp(opts, input));
84
+ case "platform_artifact_status":
85
+ return toolResult(await getArtifactStatus(opts, stringArg(input, "artifactId"), numberArg(input, "limit")));
86
+ case "platform_read_artifact_logs":
87
+ return toolResult(await readArtifactLogs(opts, input));
88
+ case "platform_open_artifact":
89
+ return toolResult(await openArtifact(opts, stringArg(input, "artifactId")));
90
+ default:
91
+ return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
92
+ }
93
+ }
94
+ export async function deployArtifactApp(opts, input) {
95
+ const appDir = stringArg(input, "appDir");
96
+ const absoluteAppDir = resolveWorkspacePath(opts.workspaceRoot, appDir);
97
+ const manifest = await readManifest(absoluteAppDir);
98
+ const created = await fetchJson(opts, artifactAppsPath(opts), {
99
+ method: "POST",
100
+ headers: await jsonHeaders(opts),
101
+ body: JSON.stringify({
102
+ artifactId: optionalString(input, "artifactId"),
103
+ title: optionalString(input, "title"),
104
+ description: optionalString(input, "description"),
105
+ projectId: optionalString(input, "projectId"),
106
+ manifest
107
+ })
108
+ });
109
+ const createdRecord = asRecord(created);
110
+ if (createdRecord.ok !== true)
111
+ throw new Error(`artifact_app_create_failed:${JSON.stringify(createdRecord)}`);
112
+ const artifactId = stringValue(createdRecord.artifactId, "artifactId");
113
+ const artifactVersionId = stringValue(createdRecord.artifactVersionId, "artifactVersionId");
114
+ const bundle = await packArtifactAppDirectory(absoluteAppDir);
115
+ const bearerToken = await getBearerToken(opts);
116
+ const deployed = await fetchJson(opts, `${artifactAppsPath(opts)}/${encodeURIComponent(artifactId)}/deploy-bundle`, {
117
+ method: "POST",
118
+ headers: {
119
+ authorization: `Bearer ${bearerToken}`,
120
+ "content-type": "application/gzip",
121
+ "MCP-Protocol-Version": opts.protocolVersion,
122
+ "x-platform-artifact-version-id": artifactVersionId,
123
+ "x-platform-app-dir": "."
124
+ },
125
+ body: bundle.body
126
+ });
127
+ return {
128
+ ok: true,
129
+ artifactId,
130
+ artifactVersionId,
131
+ manifestId: typeof manifest.id === "string" ? manifest.id : null,
132
+ bundle: {
133
+ fileCount: bundle.fileCount,
134
+ byteCount: bundle.byteCount,
135
+ sha256: bundle.sha256,
136
+ excluded: bundle.excluded
137
+ },
138
+ create: created,
139
+ deploy: deployed
140
+ };
141
+ }
142
+ export async function getArtifactStatus(opts, artifactId, limit) {
143
+ const query = new URLSearchParams();
144
+ if (limit)
145
+ query.set("limit", String(limit));
146
+ const suffix = query.toString() ? `?${query}` : "";
147
+ return fetchJson(opts, `${artifactAppsPath(opts)}/${encodeURIComponent(artifactId)}/deployments${suffix}`, {
148
+ method: "GET",
149
+ headers: await jsonHeaders(opts)
150
+ });
151
+ }
152
+ export async function readArtifactLogs(opts, input) {
153
+ const artifactId = stringArg(input, "artifactId");
154
+ const query = new URLSearchParams();
155
+ for (const key of ["deploymentRecordId", "environment", "limit", "severity", "contains", "since", "windowMinutes", "trace", "requestId", "route"]) {
156
+ const value = input[key];
157
+ if (typeof value === "string" && value.trim())
158
+ query.set(key, value.trim());
159
+ if (typeof value === "number" && Number.isFinite(value))
160
+ query.set(key, String(value));
161
+ }
162
+ const suffix = query.toString() ? `?${query}` : "";
163
+ return fetchJson(opts, `${artifactAppsPath(opts)}/${encodeURIComponent(artifactId)}/logs${suffix}`, {
164
+ method: "GET",
165
+ headers: await jsonHeaders(opts)
166
+ });
167
+ }
168
+ export async function openArtifact(opts, artifactId) {
169
+ const status = asRecord(await getArtifactStatus(opts, artifactId, 10));
170
+ const deployments = Array.isArray(status.deployments) ? status.deployments.filter(isRecord) : [];
171
+ const draft = deployments.find((deployment) => deployment.environment === "draft") ?? null;
172
+ const live = deployments.find((deployment) => deployment.environment === "live" && deployment.deploymentState === "promoted") ?? null;
173
+ const baseUrl = platformBaseUrl(opts);
174
+ return {
175
+ ok: true,
176
+ artifactId,
177
+ openUrl: `${baseUrl}/artifacts/${encodeURIComponent(artifactId)}`,
178
+ promotionUrl: `${baseUrl}/artifacts/${encodeURIComponent(artifactId)}`,
179
+ livePromotion: {
180
+ exposedInMcp: false,
181
+ operatorAction: "Review the draft in Topline and publish/promote from the artifact UI."
182
+ },
183
+ sourceHandoff: {
184
+ readOnly: true,
185
+ url: `${baseUrl}/settings/sources`,
186
+ operatorAction: "Review or change source bindings in the Topline sources UI."
187
+ },
188
+ scheduleHandoff: {
189
+ readOnly: true,
190
+ url: `${baseUrl}/automations?tab=schedules`,
191
+ operatorAction: "Review or change artifact schedules in the Topline automations UI."
192
+ },
193
+ draftUrl: typeof draft?.draftUrl === "string" ? draft.draftUrl : null,
194
+ liveUrl: typeof live?.liveUrl === "string" ? live.liveUrl : null,
195
+ versionUrl: typeof draft?.versionUrl === "string" ? draft.versionUrl : typeof live?.versionUrl === "string" ? live.versionUrl : null
196
+ };
197
+ }
198
+ export async function packArtifactAppDirectory(appDir) {
199
+ const root = path.resolve(appDir);
200
+ const files = [];
201
+ const excluded = [];
202
+ await walk(root, root, files, excluded);
203
+ if (files.length === 0)
204
+ throw new Error("artifact_app_bundle_empty");
205
+ let byteCount = 0;
206
+ const chunks = [];
207
+ for (const file of files.sort((a, b) => a.relative.localeCompare(b.relative))) {
208
+ byteCount += file.size;
209
+ if (byteCount > MAX_UNPACKED_BYTES)
210
+ throw new Error(`artifact_app_bundle_unpacked_too_large:${MAX_UNPACKED_BYTES}`);
211
+ const content = await readFile(file.absolute);
212
+ chunks.push(tarHeader(file.relative, content.length));
213
+ chunks.push(content);
214
+ chunks.push(Buffer.alloc(padSize(content.length)));
215
+ }
216
+ chunks.push(Buffer.alloc(BLOCK_SIZE * 2));
217
+ const body = gzipSync(Buffer.concat(chunks));
218
+ if (body.length > MAX_BUNDLE_BYTES)
219
+ throw new Error(`artifact_app_bundle_too_large:${MAX_BUNDLE_BYTES}`);
220
+ return {
221
+ body,
222
+ fileCount: files.length,
223
+ byteCount,
224
+ sha256: createHash("sha256").update(body).digest("hex"),
225
+ excluded
226
+ };
227
+ }
228
+ async function walk(root, current, files, excluded) {
229
+ const entries = await readdir(current, { withFileTypes: true });
230
+ for (const entry of entries) {
231
+ const absolute = path.join(current, entry.name);
232
+ const relative = toPosix(path.relative(root, absolute));
233
+ validateRelativePath(relative);
234
+ const stat = await lstat(absolute);
235
+ if (stat.isSymbolicLink())
236
+ throw new Error(`artifact_app_bundle_symlink_forbidden:${relative}`);
237
+ if (shouldExclude(relative, entry.isDirectory())) {
238
+ excluded.push(relative);
239
+ continue;
240
+ }
241
+ if (entry.isDirectory()) {
242
+ await walk(root, absolute, files, excluded);
243
+ }
244
+ else if (entry.isFile()) {
245
+ files.push({ absolute, relative, size: stat.size });
246
+ if (files.length > MAX_FILES)
247
+ throw new Error(`artifact_app_bundle_file_limit:${MAX_FILES}`);
248
+ }
249
+ }
250
+ }
251
+ async function readManifest(appDir) {
252
+ const manifestPath = path.join(appDir, "artifact-app.json");
253
+ const parsed = JSON.parse(await readFile(manifestPath, "utf8"));
254
+ if (!isRecord(parsed))
255
+ throw new Error("artifact_app_manifest_must_be_object");
256
+ return parsed;
257
+ }
258
+ function resolveWorkspacePath(workspaceRoot, inputPath) {
259
+ const resolved = path.resolve(workspaceRoot, inputPath);
260
+ const relative = path.relative(workspaceRoot, resolved);
261
+ if (relative.startsWith("..") || path.isAbsolute(relative))
262
+ throw new Error("appDir_must_be_inside_workspace_root");
263
+ return resolved;
264
+ }
265
+ function validateRelativePath(relative) {
266
+ if (!relative || relative.startsWith("/") || /^[A-Za-z]:\//.test(relative))
267
+ throw new Error("artifact_app_bundle_path_invalid");
268
+ const parts = relative.split("/").filter(Boolean);
269
+ if (parts.some((part) => part === ".."))
270
+ throw new Error(`artifact_app_bundle_path_escape:${relative}`);
271
+ if (parts.length > MAX_DEPTH)
272
+ throw new Error(`artifact_app_bundle_depth_limit:${MAX_DEPTH}`);
273
+ }
274
+ function shouldExclude(relative, isDirectory) {
275
+ const parts = relative.split("/").filter(Boolean);
276
+ const name = parts[parts.length - 1] ?? "";
277
+ if (name.startsWith(".env"))
278
+ return true;
279
+ if (parts.some((part) => part === ".git" || part === "node_modules"))
280
+ return true;
281
+ if (isDirectory && FORBIDDEN_DIRS.has(name))
282
+ return true;
283
+ return false;
284
+ }
285
+ function tarHeader(name, size) {
286
+ const header = Buffer.alloc(BLOCK_SIZE);
287
+ const { fileName, prefix } = splitTarName(name);
288
+ header.write(fileName, 0, 100, "utf8");
289
+ writeOctal(header, 100, 8, 0o644);
290
+ writeOctal(header, 108, 8, 0);
291
+ writeOctal(header, 116, 8, 0);
292
+ writeOctal(header, 124, 12, size);
293
+ writeOctal(header, 136, 12, Math.floor(Date.now() / 1000));
294
+ header.fill(0x20, 148, 156);
295
+ header.write("0", 156, 1, "utf8");
296
+ header.write("ustar", 257, 5, "utf8");
297
+ header.write("00", 263, 2, "utf8");
298
+ if (prefix)
299
+ header.write(prefix, 345, 155, "utf8");
300
+ let checksum = 0;
301
+ for (const byte of header)
302
+ checksum += byte;
303
+ const checksumText = checksum.toString(8).padStart(6, "0");
304
+ header.write(checksumText, 148, 6, "ascii");
305
+ header[154] = 0;
306
+ header[155] = 0x20;
307
+ return header;
308
+ }
309
+ function splitTarName(name) {
310
+ if (Buffer.byteLength(name) <= 100)
311
+ return { fileName: name, prefix: "" };
312
+ const parts = name.split("/");
313
+ for (let i = parts.length - 1; i > 0; i -= 1) {
314
+ const fileName = parts.slice(i).join("/");
315
+ const prefix = parts.slice(0, i).join("/");
316
+ if (Buffer.byteLength(fileName) <= 100 && Buffer.byteLength(prefix) <= 155) {
317
+ return { fileName, prefix };
318
+ }
319
+ }
320
+ throw new Error(`artifact_app_bundle_path_too_long:${name}`);
321
+ }
322
+ function writeOctal(header, offset, length, value) {
323
+ const text = value.toString(8).padStart(length - 1, "0").slice(-(length - 1));
324
+ header.write(text, offset, length - 1, "ascii");
325
+ header[offset + length - 1] = 0;
326
+ }
327
+ function padSize(size) {
328
+ const remainder = size % BLOCK_SIZE;
329
+ return remainder === 0 ? 0 : BLOCK_SIZE - remainder;
330
+ }
331
+ async function fetchJson(opts, url, init) {
332
+ const controller = new AbortController();
333
+ const timeout = setTimeout(() => controller.abort(), opts.timeoutMs);
334
+ try {
335
+ const response = await (opts.fetchImpl ?? fetch)(url, { ...init, signal: controller.signal });
336
+ const text = await response.text();
337
+ let payload = {};
338
+ try {
339
+ payload = text ? JSON.parse(text) : {};
340
+ }
341
+ catch {
342
+ payload = { detail: text.slice(0, 500) };
343
+ }
344
+ if (!response.ok) {
345
+ throw new Error(`platform_harness_http_error:${response.status}:${redactSecrets(JSON.stringify(payload), opts)}`);
346
+ }
347
+ return payload;
348
+ }
349
+ catch (err) {
350
+ if (controller.signal.aborted)
351
+ throw new Error("platform_harness_timeout");
352
+ throw err;
353
+ }
354
+ finally {
355
+ clearTimeout(timeout);
356
+ }
357
+ }
358
+ function listTools() {
359
+ return {
360
+ tools: [
361
+ {
362
+ name: "platform_deploy_artifact_app",
363
+ description: "Package a local artifact app directory, upload it, deploy a private draft, and return status.",
364
+ inputSchema: {
365
+ type: "object",
366
+ properties: {
367
+ appDir: { type: "string" },
368
+ artifactId: { type: "string" },
369
+ title: { type: "string" },
370
+ description: { type: "string" },
371
+ projectId: { type: "string" }
372
+ },
373
+ required: ["appDir"]
374
+ }
375
+ },
376
+ {
377
+ name: "platform_artifact_status",
378
+ description: "Read private draft deployment status for a hosted artifact app.",
379
+ inputSchema: {
380
+ type: "object",
381
+ properties: { artifactId: { type: "string" }, limit: { type: "number" } },
382
+ required: ["artifactId"]
383
+ }
384
+ },
385
+ {
386
+ name: "platform_read_artifact_logs",
387
+ description: "Read bounded, redacted deployment logs for a hosted artifact app.",
388
+ inputSchema: {
389
+ type: "object",
390
+ properties: {
391
+ artifactId: { type: "string" },
392
+ deploymentRecordId: { type: "string" },
393
+ environment: { type: "string", enum: ["draft", "live"] },
394
+ limit: { type: "number" },
395
+ severity: { type: "string" },
396
+ contains: { type: "string" },
397
+ since: { type: "string" },
398
+ windowMinutes: { type: "number" },
399
+ trace: { type: "string" },
400
+ requestId: { type: "string" },
401
+ route: { type: "string" }
402
+ },
403
+ required: ["artifactId"]
404
+ }
405
+ },
406
+ {
407
+ name: "platform_open_artifact",
408
+ description: "Return the operator URL and latest draft/live URLs for a hosted artifact app.",
409
+ inputSchema: {
410
+ type: "object",
411
+ properties: { artifactId: { type: "string" } },
412
+ required: ["artifactId"]
413
+ }
414
+ }
415
+ ]
416
+ };
417
+ }
418
+ function toolResult(value) {
419
+ return {
420
+ content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
421
+ structuredContent: value
422
+ };
423
+ }
424
+ async function jsonHeaders(opts) {
425
+ const bearerToken = await getBearerToken(opts);
426
+ return {
427
+ accept: "application/json",
428
+ authorization: `Bearer ${bearerToken}`,
429
+ "content-type": "application/json",
430
+ "MCP-Protocol-Version": opts.protocolVersion
431
+ };
432
+ }
433
+ export async function getBearerToken(opts) {
434
+ if (opts.token?.trim())
435
+ return opts.token.trim();
436
+ const stored = await readStoredOAuthCredentials(opts);
437
+ if (stored?.accessToken && stored.accessTokenExpiresAt && new Date(stored.accessTokenExpiresAt).getTime() - Date.now() > 60_000) {
438
+ return stored.accessToken;
439
+ }
440
+ if (stored?.refreshToken && stored.clientId) {
441
+ try {
442
+ const refreshed = await refreshOAuthToken(opts, stored);
443
+ await writeStoredOAuthCredentials(opts, refreshed);
444
+ return refreshed.accessToken;
445
+ }
446
+ catch {
447
+ // Fall through to an interactive login; the stored refresh token may have been revoked.
448
+ }
449
+ }
450
+ const loggedIn = await runOAuthLogin(opts);
451
+ await writeStoredOAuthCredentials(opts, loggedIn);
452
+ return loggedIn.accessToken;
453
+ }
454
+ async function refreshOAuthToken(opts, stored) {
455
+ if (!stored.refreshToken)
456
+ throw new Error("missing_topline_refresh_token");
457
+ const body = new URLSearchParams({
458
+ grant_type: "refresh_token",
459
+ client_id: stored.clientId,
460
+ refresh_token: stored.refreshToken,
461
+ resource: stored.resource,
462
+ scope: stored.scopes.join(" ")
463
+ });
464
+ const token = await oauthFetchJson(opts, `${stored.issuer}/oauth/token`, {
465
+ method: "POST",
466
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
467
+ body
468
+ });
469
+ return credentialsFromTokenResponse(stored.issuer, stored.resource, stored.clientId, stored.scopes, token);
470
+ }
471
+ async function runOAuthLogin(opts) {
472
+ const issuer = platformBaseUrl(opts);
473
+ const resource = `${issuer}/mcp/harness`;
474
+ const callback = await startOAuthCallbackServer();
475
+ try {
476
+ const registration = await oauthFetchJson(opts, `${issuer}/oauth/register`, {
477
+ method: "POST",
478
+ headers: { "content-type": "application/json", accept: "application/json" },
479
+ body: JSON.stringify({
480
+ client_name: "Topline local MCP helper",
481
+ redirect_uris: [callback.redirectUri],
482
+ grant_types: ["authorization_code", "refresh_token"],
483
+ response_types: ["code"],
484
+ token_endpoint_auth_method: "none"
485
+ })
486
+ });
487
+ const clientId = stringValue(asRecord(registration).client_id, "client_id");
488
+ const verifier = randomBytes(32).toString("base64url");
489
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
490
+ const state = randomBytes(24).toString("base64url");
491
+ const authUrl = new URL(`${issuer}/oauth/authorize`);
492
+ authUrl.searchParams.set("response_type", "code");
493
+ authUrl.searchParams.set("client_id", clientId);
494
+ authUrl.searchParams.set("redirect_uri", callback.redirectUri);
495
+ authUrl.searchParams.set("scope", opts.oauthScopes.join(" "));
496
+ authUrl.searchParams.set("resource", resource);
497
+ authUrl.searchParams.set("code_challenge", challenge);
498
+ authUrl.searchParams.set("code_challenge_method", "S256");
499
+ authUrl.searchParams.set("state", state);
500
+ await openBrowser(opts, authUrl.toString());
501
+ const code = await callback.waitForCode(state);
502
+ const token = await oauthFetchJson(opts, `${issuer}/oauth/token`, {
503
+ method: "POST",
504
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
505
+ body: new URLSearchParams({
506
+ grant_type: "authorization_code",
507
+ client_id: clientId,
508
+ code,
509
+ redirect_uri: callback.redirectUri,
510
+ code_verifier: verifier,
511
+ resource
512
+ })
513
+ });
514
+ return credentialsFromTokenResponse(issuer, resource, clientId, opts.oauthScopes, token);
515
+ }
516
+ finally {
517
+ await callback.close();
518
+ }
519
+ }
520
+ async function oauthFetchJson(opts, url, init) {
521
+ const controller = new AbortController();
522
+ const timeout = setTimeout(() => controller.abort(), opts.timeoutMs);
523
+ try {
524
+ const response = await (opts.fetchImpl ?? fetch)(url, { ...init, signal: controller.signal });
525
+ const text = await response.text();
526
+ const payload = text ? JSON.parse(text) : {};
527
+ if (!response.ok)
528
+ throw new Error(`topline_oauth_http_error:${response.status}:${redactSecrets(JSON.stringify(payload), opts)}`);
529
+ return payload;
530
+ }
531
+ finally {
532
+ clearTimeout(timeout);
533
+ }
534
+ }
535
+ function credentialsFromTokenResponse(issuer, resource, clientId, requestedScopes, token) {
536
+ const record = asRecord(token);
537
+ const accessToken = stringValue(record.access_token, "access_token");
538
+ const refreshToken = stringValue(record.refresh_token, "refresh_token");
539
+ const expiresIn = typeof record.expires_in === "number" && Number.isFinite(record.expires_in) ? record.expires_in : 3600;
540
+ const scopeText = typeof record.scope === "string" && record.scope.trim() ? record.scope.trim() : requestedScopes.join(" ");
541
+ return {
542
+ issuer,
543
+ resource,
544
+ clientId,
545
+ accessToken,
546
+ accessTokenExpiresAt: new Date(Date.now() + Math.max(60, Math.floor(expiresIn)) * 1000).toISOString(),
547
+ refreshToken,
548
+ scopes: scopeText.split(/[\s,]+/).filter(Boolean)
549
+ };
550
+ }
551
+ async function readStoredOAuthCredentials(opts) {
552
+ if (!existsSync(opts.authFile))
553
+ return null;
554
+ try {
555
+ const parsed = JSON.parse(await readFile(opts.authFile, "utf8"));
556
+ if (!isRecord(parsed))
557
+ return null;
558
+ const issuer = typeof parsed.issuer === "string" ? parsed.issuer : "";
559
+ const resource = typeof parsed.resource === "string" ? parsed.resource : "";
560
+ const clientId = typeof parsed.clientId === "string" ? parsed.clientId : "";
561
+ const scopes = Array.isArray(parsed.scopes) ? parsed.scopes.filter((scope) => typeof scope === "string") : DEFAULT_OAUTH_SCOPES;
562
+ if (!issuer || !resource || !clientId)
563
+ return null;
564
+ return {
565
+ issuer,
566
+ resource,
567
+ clientId,
568
+ accessToken: typeof parsed.accessToken === "string" ? parsed.accessToken : undefined,
569
+ accessTokenExpiresAt: typeof parsed.accessTokenExpiresAt === "string" ? parsed.accessTokenExpiresAt : undefined,
570
+ refreshToken: typeof parsed.refreshToken === "string" ? parsed.refreshToken : undefined,
571
+ scopes
572
+ };
573
+ }
574
+ catch {
575
+ return null;
576
+ }
577
+ }
578
+ async function writeStoredOAuthCredentials(opts, credentials) {
579
+ await mkdir(path.dirname(opts.authFile), { recursive: true });
580
+ await writeFile(opts.authFile, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
581
+ }
582
+ async function startOAuthCallbackServer() {
583
+ let resolveCode = null;
584
+ let rejectCode = null;
585
+ let expectedState = "";
586
+ const codePromise = new Promise((resolve, reject) => {
587
+ resolveCode = resolve;
588
+ rejectCode = reject;
589
+ });
590
+ const server = createServer((req, res) => {
591
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
592
+ const code = url.searchParams.get("code");
593
+ const state = url.searchParams.get("state");
594
+ if (url.pathname !== "/callback" || !code || state !== expectedState) {
595
+ res.writeHead(400, { "content-type": "text/html; charset=utf-8" });
596
+ res.end("<!doctype html><meta charset=\"utf-8\"><h1>Topline authorization failed</h1>");
597
+ rejectCode?.(new Error("topline_oauth_callback_invalid"));
598
+ return;
599
+ }
600
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
601
+ res.end("<!doctype html><meta charset=\"utf-8\"><h1>Topline authorization complete</h1><p>You can return to your coding harness.</p>");
602
+ resolveCode?.(code);
603
+ });
604
+ await listen(server);
605
+ const address = server.address();
606
+ if (!address || typeof address === "string")
607
+ throw new Error("topline_oauth_callback_listen_failed");
608
+ return {
609
+ redirectUri: `http://127.0.0.1:${address.port}/callback`,
610
+ waitForCode: async (state) => {
611
+ expectedState = state;
612
+ return await Promise.race([
613
+ codePromise,
614
+ new Promise((_resolve, reject) => setTimeout(() => reject(new Error("topline_oauth_login_timeout")), 5 * 60 * 1000))
615
+ ]);
616
+ },
617
+ close: () => closeServer(server)
618
+ };
619
+ }
620
+ function listen(server) {
621
+ return new Promise((resolve, reject) => {
622
+ server.once("error", reject);
623
+ server.listen(0, "127.0.0.1", () => {
624
+ server.off("error", reject);
625
+ resolve();
626
+ });
627
+ });
628
+ }
629
+ function closeServer(server) {
630
+ return new Promise((resolve) => server.close(() => resolve()));
631
+ }
632
+ async function openBrowser(opts, url) {
633
+ if (opts.openUrl) {
634
+ await opts.openUrl(url);
635
+ return;
636
+ }
637
+ const command = process.platform === "win32" ? "cmd" : process.platform === "darwin" ? "open" : "xdg-open";
638
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
639
+ try {
640
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
641
+ child.unref();
642
+ }
643
+ catch {
644
+ console.error(`Open this URL to authorize Topline MCP:\n${url}`);
645
+ }
646
+ }
647
+ function artifactAppsPath(opts) {
648
+ return opts.apiUrl.endsWith("/api/harness")
649
+ ? `${opts.apiUrl}/artifact-apps`
650
+ : `${opts.apiUrl}/api/harness/artifact-apps`;
651
+ }
652
+ function platformBaseUrl(opts) {
653
+ return opts.apiUrl.replace(/\/api\/harness$/, "").replace(/\/$/, "");
654
+ }
655
+ function defaultAuthFile(apiUrl) {
656
+ const key = createHash("sha256").update(platformBaseUrl({ apiUrl })).digest("hex").slice(0, 16);
657
+ return path.join(os.homedir(), ".topline", "harness-mcp", `${key}.json`);
658
+ }
659
+ function normalizeApiUrl(raw) {
660
+ const url = new URL(raw);
661
+ if (url.protocol !== "http:" && url.protocol !== "https:")
662
+ throw new Error("invalid_platform_api_url_scheme");
663
+ return url.toString().replace(/\/$/g, "");
664
+ }
665
+ function firstEnv(env, ...keys) {
666
+ for (const key of keys) {
667
+ const value = env[key]?.trim();
668
+ if (value)
669
+ return value;
670
+ }
671
+ return null;
672
+ }
673
+ function stringArg(args, key) {
674
+ const value = args[key];
675
+ if (typeof value !== "string" || !value.trim())
676
+ throw new Error(`${key}_required`);
677
+ return value.trim();
678
+ }
679
+ function optionalString(args, key) {
680
+ const value = args[key];
681
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
682
+ }
683
+ function numberArg(args, key) {
684
+ const value = args[key];
685
+ if (typeof value === "number" && Number.isFinite(value))
686
+ return Math.floor(value);
687
+ if (typeof value === "string" && value.trim() && Number.isFinite(Number(value)))
688
+ return Math.floor(Number(value));
689
+ return undefined;
690
+ }
691
+ function stringValue(value, key) {
692
+ if (typeof value !== "string" || !value.trim())
693
+ throw new Error(`${key}_missing_from_platform_response`);
694
+ return value.trim();
695
+ }
696
+ function asRecord(value) {
697
+ return isRecord(value) ? value : {};
698
+ }
699
+ function isRecord(value) {
700
+ return typeof value === "object" && value !== null && !Array.isArray(value);
701
+ }
702
+ function toPosix(value) {
703
+ return value.replace(/\\/g, "/");
704
+ }
705
+ function redactSecrets(value, opts) {
706
+ const token = opts.token?.trim();
707
+ const withoutStatic = token ? value.split(token).join("[redacted]") : value;
708
+ return withoutStatic
709
+ .replace(/ovmcp_[A-Za-z0-9_-]+/g, "[redacted]")
710
+ .replace(/tlmcp_(?:at|rt|code)_[A-Za-z0-9_-]+/g, "[redacted]");
711
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@topline-build/harness-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Local stdio MCP connector for deploying Topline artifact app drafts.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/topline-build/topline-mcp.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/topline-build/topline-mcp/issues"
14
+ },
15
+ "homepage": "https://github.com/topline-build/topline-mcp#readme",
16
+ "files": [
17
+ "dist/",
18
+ "README.md"
19
+ ],
20
+ "bin": {
21
+ "topline-mcp": "dist/cli.js"
22
+ },
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "import": "./dist/index.js"
27
+ }
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "scripts": {
33
+ "build": "npm run clean && tsc -p tsconfig.json",
34
+ "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
35
+ "mcp:stdio": "node dist/cli.js",
36
+ "prepack": "npm run build",
37
+ "test": "vitest run",
38
+ "typecheck": "tsc --noEmit -p tsconfig.json"
39
+ },
40
+ "dependencies": {
41
+ "@modelcontextprotocol/sdk": "^1.29.0"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^22.15.3",
45
+ "typescript": "^5.8.3",
46
+ "vitest": "^4.1.8"
47
+ }
48
+ }