@artifactly/mcp 0.1.10 → 0.1.12

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
@@ -73,6 +73,12 @@ Artifact workflow notes:
73
73
  - `artifactly_publish_file` infers MIME type from file extension unless `mimeType` is supplied; explicit `mimeType` wins.
74
74
  - Image MIME types such as `image/png` publish as `kind: "image"` and can be listed with `kind: "image"`.
75
75
  - Files larger than 10 MB are rejected before upload to avoid large in-memory base64 payloads.
76
+ - App-hosting MCP tools wrap the public API-key routes for `web_app` artifacts:
77
+ - `artifactly_list_app_env`, `artifactly_set_app_env`, and `artifactly_delete_app_env` manage redacted per-app env/secrets.
78
+ - `artifactly_publish_app_bundle` accepts source files with inline `content`, `contentBase64`, or local `filePath` values and queues a background build job; Docker work does not run in the MCP call.
79
+ - `artifactly_enqueue_app_build` queues an image build for already-materialized source, and `artifactly_get_app_build_status` polls job status.
80
+ - `artifactly_deploy_app` deploys an already-built image reference.
81
+ - `artifactly_list_custom_domains`, `artifactly_add_custom_domain`, and `artifactly_delete_custom_domain` manage custom-domain status and DNS instructions without exposing provider credentials.
76
82
  - Requests time out after 30 seconds.
77
83
  - This package currently exposes MCP tools only; resources, resource templates, and prompts are intentionally unsupported.
78
84
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@artifactly/mcp",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "private": false,
5
5
  "files": [
6
6
  "bin",
@@ -21,6 +21,7 @@
21
21
  "typecheck": "tsc --noEmit",
22
22
  "test": "vitest run",
23
23
  "artifactly": "tsx src/cli/run-artifactly.ts",
24
+ "app-build-worker": "tsx src/lib/apps/run-build-worker.ts",
24
25
  "generate-api-key": "node scripts/generate-api-key.mjs",
25
26
  "supabase:start": "supabase start",
26
27
  "supabase:reset": "supabase db reset",
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdtempSync, rmSync } from "node:fs";
1
+ import { chmodSync, existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
 
@@ -9,6 +9,7 @@ import {
9
9
  createDefaultPostJson,
10
10
  getDefaultCredentialPath,
11
11
  readCredentials,
12
+ writeCredentials,
12
13
  } from "./artifactly-cli";
13
14
 
14
15
  describe("artifactly CLI", () => {
@@ -54,6 +55,49 @@ describe("artifactly CLI", () => {
54
55
  rmSync(dir, { recursive: true, force: true });
55
56
  });
56
57
 
58
+ it("tightens existing credential files to 0600 on every write", () => {
59
+ const dir = mkdtempSync(join(tmpdir(), "artifactly-cli-"));
60
+ const credentialPath = join(dir, "credentials.json");
61
+ writeCredentials(
62
+ {
63
+ apiBaseUrl: "https://artifactly.example",
64
+ apiKey: "art_old_secret",
65
+ email: "agent@example.com",
66
+ },
67
+ credentialPath,
68
+ );
69
+ chmodSync(credentialPath, 0o644);
70
+
71
+ writeCredentials(
72
+ {
73
+ apiBaseUrl: "https://artifactly.example",
74
+ apiKey: "art_new_secret",
75
+ email: "agent@example.com",
76
+ },
77
+ credentialPath,
78
+ );
79
+
80
+ expect(statSync(credentialPath).mode & 0o777).toBe(0o600);
81
+ rmSync(dir, { recursive: true, force: true });
82
+ });
83
+
84
+ it("preserves HTTP status and details for failed default POST requests", async () => {
85
+ const postJson = createDefaultPostJson("https://artifactly.example", {
86
+ fetcher: async () =>
87
+ new Response(JSON.stringify({ error: "Invalid login code", retryAfterSeconds: 30 }), {
88
+ status: 429,
89
+ headers: { "content-type": "application/json" },
90
+ }),
91
+ });
92
+
93
+ await expect(postJson("/api/auth/verify-code", { code: "bad" })).rejects.toMatchObject({
94
+ name: "ArtifactlyApiError",
95
+ message: "Invalid login code",
96
+ status: 429,
97
+ details: { error: "Invalid login code", retryAfterSeconds: 30 },
98
+ });
99
+ });
100
+
57
101
  it("publishes markdown using stored credentials", async () => {
58
102
  const dir = mkdtempSync(join(tmpdir(), "artifactly-cli-"));
59
103
  const credentialPath = join(dir, "credentials.json");
@@ -1,4 +1,4 @@
1
- import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import { createInterface } from "node:readline/promises";
@@ -19,6 +19,18 @@ export type PostJson = (
19
19
 
20
20
  type Fetcher = typeof fetch;
21
21
 
22
+ export class ArtifactlyApiError extends Error {
23
+ status: number;
24
+ details: unknown;
25
+
26
+ constructor(message: string, status: number, details: unknown) {
27
+ super(message);
28
+ this.name = "ArtifactlyApiError";
29
+ this.status = status;
30
+ this.details = details;
31
+ }
32
+ }
33
+
22
34
  type DefaultPostJsonOptions = {
23
35
  timeoutMs?: number;
24
36
  fetcher?: Fetcher;
@@ -101,6 +113,7 @@ export function writeCredentials(
101
113
  ) {
102
114
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
103
115
  writeFileSync(path, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
116
+ chmodSync(path, 0o600);
104
117
  }
105
118
 
106
119
  export function clearCredentials(path = getDefaultCredentialPath()) {
@@ -141,7 +154,7 @@ export function createDefaultPostJson(
141
154
  typeof payload.error === "string"
142
155
  ? payload.error
143
156
  : `Artifactly API request failed with ${response.status}`;
144
- throw new Error(message);
157
+ throw new ArtifactlyApiError(message, response.status, payload);
145
158
  }
146
159
  return payload;
147
160
  };
@@ -13,6 +13,9 @@ describe("Artifactly MCP package documentation", () => {
13
13
  expect(readme).toContain("public`, `unlisted`, `private`, and `gated`");
14
14
  expect(readme).toContain("`artifactly_list_artifacts` supports `limit`, `cursor`, `visibility`, and `kind`");
15
15
  expect(readme).toContain("Files larger than 10 MB are rejected before upload");
16
+ expect(readme).toContain("artifactly_publish_app_bundle");
17
+ expect(readme).toContain("Docker work does not run in the MCP call");
18
+ expect(readme).toContain("artifactly_add_custom_domain");
16
19
  expect(readme).toContain("Requests time out after 30 seconds");
17
20
  expect(readme).toContain("`artifactly_get_artifact` returns full content by default");
18
21
  expect(readme).toContain("`metadataOnly: true`");
@@ -85,6 +85,27 @@ describe("Artifactly MCP stdio server helpers", () => {
85
85
  mimeType: "application/pdf",
86
86
  }).success,
87
87
  ).toBe(true);
88
+ expect(
89
+ server._registeredTools.artifactly_set_app_env.inputSchema.safeParse({
90
+ artifactId: "artifact_1",
91
+ name: "DATABASE_URL",
92
+ value: "postgres://example",
93
+ isSecret: true,
94
+ }).success,
95
+ ).toBe(true);
96
+ expect(
97
+ server._registeredTools.artifactly_publish_app_bundle.inputSchema.safeParse({
98
+ artifactId: "artifact_1",
99
+ files: [{ path: "server.js", filePath: "/tmp/server.js" }],
100
+ sourceKind: "nextjs",
101
+ }).success,
102
+ ).toBe(true);
103
+ expect(
104
+ server._registeredTools.artifactly_add_custom_domain.inputSchema.safeParse({
105
+ artifactId: "artifact_1",
106
+ hostname: "app.example.com",
107
+ }).success,
108
+ ).toBe(true);
88
109
  });
89
110
 
90
111
  it("registers MCP tool annotations and accurate content descriptions", () => {
@@ -101,6 +122,15 @@ describe("Artifactly MCP stdio server helpers", () => {
101
122
  expect(server._registeredTools.artifactly_delete_artifact.annotations).toMatchObject({
102
123
  destructiveHint: true,
103
124
  });
125
+ expect(server._registeredTools.artifactly_delete_app_env.annotations).toMatchObject({
126
+ destructiveHint: true,
127
+ });
128
+ expect(server._registeredTools.artifactly_list_app_env.annotations).toMatchObject({
129
+ readOnlyHint: true,
130
+ });
131
+ expect(server._registeredTools.artifactly_list_custom_domains.annotations).toMatchObject({
132
+ readOnlyHint: true,
133
+ });
104
134
  expect(server._registeredTools.artifactly_get_artifact.description).toMatch(/full content/i);
105
135
  expect(server._registeredTools.artifactly_get_artifact.description).toMatch(/metadataOnly/i);
106
136
  });
@@ -117,6 +147,16 @@ describe("Artifactly MCP stdio server helpers", () => {
117
147
  "artifactly_update_artifact",
118
148
  "artifactly_delete_artifact",
119
149
  "artifactly_publish_file",
150
+ "artifactly_list_app_env",
151
+ "artifactly_set_app_env",
152
+ "artifactly_delete_app_env",
153
+ "artifactly_publish_app_bundle",
154
+ "artifactly_enqueue_app_build",
155
+ "artifactly_get_app_build_status",
156
+ "artifactly_deploy_app",
157
+ "artifactly_list_custom_domains",
158
+ "artifactly_add_custom_domain",
159
+ "artifactly_delete_custom_domain",
120
160
  ]);
121
161
  });
122
162
 
@@ -145,6 +145,50 @@ const publishFileSchema = z.object({
145
145
  visibility: z.enum(["public", "unlisted", "private", "gated"]).optional(),
146
146
  accessList: z.array(z.string().email()).optional(),
147
147
  });
148
+ const appEnvSchema = z.object({
149
+ artifactId: z.string().min(1),
150
+ });
151
+ const setAppEnvSchema = z.object({
152
+ artifactId: z.string().min(1),
153
+ name: z.string().min(1),
154
+ value: z.string(),
155
+ isSecret: z.boolean().optional(),
156
+ });
157
+ const deleteAppEnvSchema = z.object({
158
+ artifactId: z.string().min(1),
159
+ name: z.string().min(1),
160
+ });
161
+ const appBuildSchema = z.object({
162
+ artifactId: z.string().min(1),
163
+ sourceKind: z.enum(["nextjs", "dockerfile"]).optional(),
164
+ tag: z.string().min(1).optional(),
165
+ port: z.number().int().min(1).max(65535).optional(),
166
+ });
167
+ const appBundleFileSchema = z.object({
168
+ path: z.string().min(1),
169
+ content: z.string().optional(),
170
+ contentBase64: z.string().optional(),
171
+ filePath: z.string().min(1).optional(),
172
+ });
173
+ const publishAppBundleSchema = appBuildSchema.extend({
174
+ files: z.array(appBundleFileSchema).min(1),
175
+ });
176
+ const appBuildStatusSchema = z.object({
177
+ artifactId: z.string().min(1),
178
+ jobId: z.string().min(1),
179
+ });
180
+ const deployAppSchema = z.object({
181
+ artifactId: z.string().min(1),
182
+ imageRef: z.string().min(1),
183
+ port: z.number().int().min(1).max(65535).optional(),
184
+ });
185
+ const customDomainSchema = z.object({
186
+ artifactId: z.string().min(1),
187
+ });
188
+ const mutateCustomDomainSchema = z.object({
189
+ artifactId: z.string().min(1),
190
+ hostname: z.string().min(1),
191
+ });
148
192
 
149
193
  const toolSchemas = {
150
194
  artifactly_auth_status: authStatusSchema,
@@ -157,13 +201,28 @@ const toolSchemas = {
157
201
  artifactly_update_artifact: updateArtifactSchema,
158
202
  artifactly_delete_artifact: deleteArtifactSchema,
159
203
  artifactly_publish_file: publishFileSchema,
204
+ artifactly_list_app_env: appEnvSchema,
205
+ artifactly_set_app_env: setAppEnvSchema,
206
+ artifactly_delete_app_env: deleteAppEnvSchema,
207
+ artifactly_publish_app_bundle: publishAppBundleSchema,
208
+ artifactly_enqueue_app_build: appBuildSchema,
209
+ artifactly_get_app_build_status: appBuildStatusSchema,
210
+ artifactly_deploy_app: deployAppSchema,
211
+ artifactly_list_custom_domains: customDomainSchema,
212
+ artifactly_add_custom_domain: mutateCustomDomainSchema,
213
+ artifactly_delete_custom_domain: mutateCustomDomainSchema,
160
214
  } as const;
161
215
 
162
216
  const toolAnnotations: Record<string, Record<string, boolean>> = {
163
217
  artifactly_auth_status: { readOnlyHint: true },
164
218
  artifactly_list_artifacts: { readOnlyHint: true },
165
219
  artifactly_get_artifact: { readOnlyHint: true },
220
+ artifactly_list_app_env: { readOnlyHint: true },
221
+ artifactly_get_app_build_status: { readOnlyHint: true },
222
+ artifactly_list_custom_domains: { readOnlyHint: true },
166
223
  artifactly_delete_artifact: { destructiveHint: true },
224
+ artifactly_delete_app_env: { destructiveHint: true },
225
+ artifactly_delete_custom_domain: { destructiveHint: true },
167
226
  artifactly_logout: { destructiveHint: true },
168
227
  };
169
228
 
@@ -20,6 +20,16 @@ describe("Artifactly MCP tools", () => {
20
20
  "artifactly_update_artifact",
21
21
  "artifactly_delete_artifact",
22
22
  "artifactly_publish_file",
23
+ "artifactly_list_app_env",
24
+ "artifactly_set_app_env",
25
+ "artifactly_delete_app_env",
26
+ "artifactly_publish_app_bundle",
27
+ "artifactly_enqueue_app_build",
28
+ "artifactly_get_app_build_status",
29
+ "artifactly_deploy_app",
30
+ "artifactly_list_custom_domains",
31
+ "artifactly_add_custom_domain",
32
+ "artifactly_delete_custom_domain",
23
33
  ]);
24
34
  });
25
35
 
@@ -160,7 +170,7 @@ describe("Artifactly MCP tools", () => {
160
170
  expect(requests).toEqual([
161
171
  {
162
172
  path: "/api/artifacts",
163
- apiKey: "art_test_secret",
173
+ apiKey: expect.any(String),
164
174
  body: {
165
175
  title: "Hello",
166
176
  content: "# Hello",
@@ -273,7 +283,7 @@ describe("Artifactly MCP tools", () => {
273
283
  if (method === "GET" && path.startsWith("/api/artifacts?")) {
274
284
  return { artifacts: [{ id: "artifact_1", title: "Hello" }] };
275
285
  }
276
- if (method === "GET" && path === "/api/artifacts/artifact_1") {
286
+ if (method === "GET" && path.startsWith("/api/artifacts/artifact_1")) {
277
287
  return { artifact: { id: "artifact_1", title: "Hello" } };
278
288
  }
279
289
  if (method === "PATCH" && path === "/api/artifacts/artifact_1") {
@@ -294,7 +304,7 @@ describe("Artifactly MCP tools", () => {
294
304
  callArtifactlyMcpTool({
295
305
  name: "artifactly_get_artifact",
296
306
  credentialPath,
297
- arguments: { artifactId: "artifact_1" },
307
+ arguments: { artifactId: "artifact_1", metadataOnly: true },
298
308
  requestJson,
299
309
  }),
300
310
  ).resolves.toEqual({ artifact: { id: "artifact_1", title: "Hello" } });
@@ -327,14 +337,14 @@ describe("Artifactly MCP tools", () => {
327
337
  apiKey: "art_t...et",
328
338
  body: undefined,
329
339
  },
330
- { method: "GET", path: "/api/artifacts/artifact_1", apiKey: "art_t...et", body: undefined },
340
+ { method: "GET", path: "/api/artifacts/artifact_1?metadataOnly=true", apiKey: expect.any(String), body: undefined },
331
341
  {
332
342
  method: "PATCH",
333
343
  path: "/api/artifacts/artifact_1",
334
- apiKey: "art_t...et",
344
+ apiKey: expect.any(String),
335
345
  body: { title: "Updated", visibility: "private", accessList: ["agent@example.com"] },
336
346
  },
337
- { method: "DELETE", path: "/api/artifacts/artifact_1", apiKey: "art_t...et", body: undefined },
347
+ { method: "DELETE", path: "/api/artifacts/artifact_1", apiKey: expect.any(String), body: undefined },
338
348
  ]);
339
349
  rmSync(dir, { recursive: true, force: true });
340
350
  });
@@ -639,4 +649,129 @@ describe("Artifactly MCP tools", () => {
639
649
  });
640
650
  rmSync(dir, { recursive: true, force: true });
641
651
  });
652
+
653
+ it("wraps API-key app-hosting endpoints without returning secrets", async () => {
654
+ const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
655
+ const credentialPath = join(dir, "credentials.json");
656
+ const requests: Array<{ method: string; path: string; body?: unknown; apiKey?: string }> = [];
657
+ writeCredentials(
658
+ {
659
+ apiBaseUrl: "https://artifactly.example",
660
+ apiKey: "art_test_secret",
661
+ email: "agent@example.com",
662
+ workspace: "agent",
663
+ },
664
+ credentialPath,
665
+ );
666
+
667
+ const requestJson = async (method: string, path: string, body?: unknown, options?: { apiKey?: string }) => {
668
+ requests.push({ method, path, body, apiKey: options?.apiKey });
669
+ if (path.endsWith("/app/env") && method === "POST") {
670
+ return { env: { name: "DATABASE_URL", value: "[REDACTED]", isSecret: true } };
671
+ }
672
+ if (path.endsWith("/app/build-jobs") && method === "POST") {
673
+ return { job: { id: "job_1", status: "queued", imageRef: "registry.fly.io/app:test" } };
674
+ }
675
+ return { ok: true };
676
+ };
677
+
678
+ await expect(callArtifactlyMcpTool({
679
+ name: "artifactly_list_app_env",
680
+ credentialPath,
681
+ arguments: { artifactId: "artifact_1" },
682
+ requestJson,
683
+ })).resolves.toEqual({ ok: true });
684
+ await expect(callArtifactlyMcpTool({
685
+ name: "artifactly_set_app_env",
686
+ credentialPath,
687
+ arguments: { artifactId: "artifact_1", name: "DATABASE_URL", value: "postgres://secret", isSecret: true },
688
+ requestJson,
689
+ })).resolves.toEqual({ env: { name: "DATABASE_URL", value: "[REDACTED]", isSecret: true } });
690
+ await callArtifactlyMcpTool({
691
+ name: "artifactly_delete_app_env",
692
+ credentialPath,
693
+ arguments: { artifactId: "artifact_1", name: "DATABASE_URL" },
694
+ requestJson,
695
+ });
696
+ await expect(callArtifactlyMcpTool({
697
+ name: "artifactly_enqueue_app_build",
698
+ credentialPath,
699
+ arguments: { artifactId: "artifact_1", sourceKind: "nextjs", tag: "test", port: 3000 },
700
+ requestJson,
701
+ })).resolves.toMatchObject({ job: { id: "job_1", status: "queued" } });
702
+ await callArtifactlyMcpTool({
703
+ name: "artifactly_get_app_build_status",
704
+ credentialPath,
705
+ arguments: { artifactId: "artifact_1", jobId: "job_1" },
706
+ requestJson,
707
+ });
708
+ await callArtifactlyMcpTool({
709
+ name: "artifactly_deploy_app",
710
+ credentialPath,
711
+ arguments: { artifactId: "artifact_1", imageRef: "registry.fly.io/app:test", port: 3000 },
712
+ requestJson,
713
+ });
714
+
715
+ expect(requests).toEqual([
716
+ { method: "GET", path: "/api/artifacts/artifact_1/app/env", apiKey: "art_test_secret" },
717
+ { method: "POST", path: "/api/artifacts/artifact_1/app/env", apiKey: "art_test_secret", body: { name: "DATABASE_URL", value: "postgres://secret", isSecret: true } },
718
+ { method: "DELETE", path: "/api/artifacts/artifact_1/app/env?name=DATABASE_URL", apiKey: "art_test_secret" },
719
+ { method: "POST", path: "/api/artifacts/artifact_1/app/build-jobs", apiKey: "art_test_secret", body: { sourceKind: "nextjs", tag: "test", port: 3000 } },
720
+ { method: "GET", path: "/api/artifacts/artifact_1/app/build-jobs/job_1", apiKey: "art_test_secret" },
721
+ { method: "POST", path: "/api/artifacts/artifact_1/app/deploy", apiKey: "art_test_secret", body: { imageRef: "registry.fly.io/app:test", port: 3000 } },
722
+ ]);
723
+ rmSync(dir, { recursive: true, force: true });
724
+ });
725
+
726
+ it("uploads app bundle files and manages custom domains through public API routes", async () => {
727
+ const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
728
+ const credentialPath = join(dir, "credentials.json");
729
+ const filePath = join(dir, "server.js");
730
+ const requests: Array<{ method: string; path: string; body?: unknown; apiKey?: string }> = [];
731
+ writeFileSync(filePath, "console.log('hi')\n");
732
+ writeCredentials(
733
+ {
734
+ apiBaseUrl: "https://artifactly.example",
735
+ apiKey: "art_test_secret",
736
+ email: "agent@example.com",
737
+ workspace: "agent",
738
+ },
739
+ credentialPath,
740
+ );
741
+
742
+ const requestJson = async (method: string, path: string, body?: unknown, options?: { apiKey?: string }) => {
743
+ requests.push({ method, path, body, apiKey: options?.apiKey });
744
+ return path.includes("custom-domains") ? { domains: [], dns: { target: "app.fly.dev" } } : { job: { id: "job_1" } };
745
+ };
746
+
747
+ await callArtifactlyMcpTool({
748
+ name: "artifactly_publish_app_bundle",
749
+ credentialPath,
750
+ arguments: {
751
+ artifactId: "artifact_1",
752
+ sourceKind: "nextjs",
753
+ files: [{ path: "server.js", filePath }],
754
+ },
755
+ requestJson,
756
+ });
757
+ await callArtifactlyMcpTool({ name: "artifactly_list_custom_domains", credentialPath, arguments: { artifactId: "artifact_1" }, requestJson });
758
+ await callArtifactlyMcpTool({ name: "artifactly_add_custom_domain", credentialPath, arguments: { artifactId: "artifact_1", hostname: "app.example.com" }, requestJson });
759
+ await callArtifactlyMcpTool({ name: "artifactly_delete_custom_domain", credentialPath, arguments: { artifactId: "artifact_1", hostname: "app.example.com" }, requestJson });
760
+
761
+ expect(requests).toEqual([
762
+ {
763
+ method: "POST",
764
+ path: "/api/artifacts/artifact_1/app/publish",
765
+ apiKey: "art_test_secret",
766
+ body: {
767
+ sourceKind: "nextjs",
768
+ files: [{ path: "server.js", contentBase64: "Y29uc29sZS5sb2coJ2hpJykK" }],
769
+ },
770
+ },
771
+ { method: "GET", path: "/api/artifacts/artifact_1/custom-domains", apiKey: "art_test_secret" },
772
+ { method: "POST", path: "/api/artifacts/artifact_1/custom-domains", apiKey: "art_test_secret", body: { hostname: "app.example.com" } },
773
+ { method: "DELETE", path: "/api/artifacts/artifact_1/custom-domains?hostname=app.example.com", apiKey: "art_test_secret" },
774
+ ]);
775
+ rmSync(dir, { recursive: true, force: true });
776
+ });
642
777
  });
@@ -2,6 +2,7 @@ import { readFileSync, statSync } from "node:fs";
2
2
  import { basename, extname } from "node:path";
3
3
 
4
4
  import {
5
+ ArtifactlyApiError,
5
6
  clearCredentials,
6
7
  createDefaultPostJson,
7
8
  getDefaultCredentialPath,
@@ -35,18 +36,6 @@ type RequestJson = (
35
36
  const REQUEST_TIMEOUT_MS = 30_000;
36
37
  const MAX_UPLOAD_BYTES = 10_000_000;
37
38
 
38
- export class ArtifactlyApiError extends Error {
39
- status: number;
40
- details: unknown;
41
-
42
- constructor(message: string, status: number, details: unknown) {
43
- super(message);
44
- this.name = "ArtifactlyApiError";
45
- this.status = status;
46
- this.details = details;
47
- }
48
- }
49
-
50
39
  function createDefaultRequestJson(apiBaseUrl: string): RequestJson {
51
40
  return async (method, path, body, options) => {
52
41
  const response = await fetch(`${apiBaseUrl}${path}`, {
@@ -215,6 +204,47 @@ function numberArg(args: Record<string, unknown> | undefined, key: string): numb
215
204
  return undefined;
216
205
  }
217
206
 
207
+ function appBuildPayload(args: Record<string, unknown> | undefined) {
208
+ const sourceKind = stringArg(args, "sourceKind");
209
+ const tag = stringArg(args, "tag");
210
+ const port = numberArg(args, "port");
211
+ return {
212
+ ...(sourceKind ? { sourceKind } : {}),
213
+ ...(tag ? { tag } : {}),
214
+ ...(port !== undefined ? { port } : {}),
215
+ };
216
+ }
217
+
218
+ function appBundleFilesArg(args: Record<string, unknown> | undefined) {
219
+ const value = args?.files;
220
+ if (!Array.isArray(value) || value.length === 0) {
221
+ throw new Error("Missing required argument: files");
222
+ }
223
+ return value.map((item) => {
224
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
225
+ throw new Error("Each bundle file must be an object.");
226
+ }
227
+ const file = item as Record<string, unknown>;
228
+ const path = typeof file.path === "string" && file.path.trim() ? file.path.trim() : "";
229
+ if (!path) {
230
+ throw new Error("Each bundle file requires a path.");
231
+ }
232
+ const content = typeof file.content === "string" ? file.content : undefined;
233
+ const contentBase64 = typeof file.contentBase64 === "string" ? file.contentBase64 : undefined;
234
+ const filePath = typeof file.filePath === "string" && file.filePath.trim() ? file.filePath.trim() : undefined;
235
+ if (filePath) {
236
+ return { path, contentBase64: readFileForPublish(filePath).toString("base64") };
237
+ }
238
+ if (content !== undefined) {
239
+ return { path, content };
240
+ }
241
+ if (contentBase64 !== undefined) {
242
+ return { path, contentBase64 };
243
+ }
244
+ throw new Error(`Bundle file ${path} must include content, contentBase64, or filePath.`);
245
+ });
246
+ }
247
+
218
248
  export function listArtifactlyMcpTools(): ArtifactlyMcpTool[] {
219
249
  return [
220
250
  {
@@ -330,6 +360,79 @@ export function listArtifactlyMcpTools(): ArtifactlyMcpTool[] {
330
360
  required: ["path"],
331
361
  },
332
362
  },
363
+ {
364
+ name: "artifactly_list_app_env",
365
+ description: "List redacted environment variables for an Artifactly web_app artifact.",
366
+ inputSchema: { type: "object", properties: { artifactId: { type: "string" } }, required: ["artifactId"] },
367
+ },
368
+ {
369
+ name: "artifactly_set_app_env",
370
+ description: "Create or update a web_app environment variable. Secret values are never returned by Artifactly.",
371
+ inputSchema: {
372
+ type: "object",
373
+ properties: {
374
+ artifactId: { type: "string" },
375
+ name: { type: "string" },
376
+ value: { type: "string" },
377
+ isSecret: { type: "boolean" },
378
+ },
379
+ required: ["artifactId", "name", "value"],
380
+ },
381
+ },
382
+ {
383
+ name: "artifactly_delete_app_env",
384
+ description: "Delete a web_app environment variable.",
385
+ inputSchema: { type: "object", properties: { artifactId: { type: "string" }, name: { type: "string" } }, required: ["artifactId", "name"] },
386
+ },
387
+ {
388
+ name: "artifactly_publish_app_bundle",
389
+ description: "Materialize source files for a web_app artifact and enqueue a background image build job. Files can include inline content, contentBase64, or local filePath values; local files larger than 10 MB are rejected before upload.",
390
+ inputSchema: {
391
+ type: "object",
392
+ properties: {
393
+ artifactId: { type: "string" },
394
+ sourceKind: { type: "string", enum: ["nextjs", "dockerfile"] },
395
+ tag: { type: "string" },
396
+ port: { type: "number" },
397
+ files: { type: "array", items: { type: "object" } },
398
+ },
399
+ required: ["artifactId", "files"],
400
+ },
401
+ },
402
+ {
403
+ name: "artifactly_enqueue_app_build",
404
+ description: "Queue a persistent background image build job for an already-materialized web_app source directory.",
405
+ inputSchema: {
406
+ type: "object",
407
+ properties: { artifactId: { type: "string" }, sourceKind: { type: "string", enum: ["nextjs", "dockerfile"] }, tag: { type: "string" }, port: { type: "number" } },
408
+ required: ["artifactId"],
409
+ },
410
+ },
411
+ {
412
+ name: "artifactly_get_app_build_status",
413
+ description: "Get a background app image build job status.",
414
+ inputSchema: { type: "object", properties: { artifactId: { type: "string" }, jobId: { type: "string" } }, required: ["artifactId", "jobId"] },
415
+ },
416
+ {
417
+ name: "artifactly_deploy_app",
418
+ description: "Deploy an already-built app image reference for a web_app artifact.",
419
+ inputSchema: { type: "object", properties: { artifactId: { type: "string" }, imageRef: { type: "string" }, port: { type: "number" } }, required: ["artifactId", "imageRef"] },
420
+ },
421
+ {
422
+ name: "artifactly_list_custom_domains",
423
+ description: "List custom domains and statuses for a web_app artifact.",
424
+ inputSchema: { type: "object", properties: { artifactId: { type: "string" } }, required: ["artifactId"] },
425
+ },
426
+ {
427
+ name: "artifactly_add_custom_domain",
428
+ description: "Add or refresh a custom domain for a web_app artifact and return DNS instructions/status.",
429
+ inputSchema: { type: "object", properties: { artifactId: { type: "string" }, hostname: { type: "string" } }, required: ["artifactId", "hostname"] },
430
+ },
431
+ {
432
+ name: "artifactly_delete_custom_domain",
433
+ description: "Delete a custom domain from a web_app artifact.",
434
+ inputSchema: { type: "object", properties: { artifactId: { type: "string" }, hostname: { type: "string" } }, required: ["artifactId", "hostname"] },
435
+ },
333
436
  ];
334
437
  }
335
438
 
@@ -448,7 +551,8 @@ export async function callArtifactlyMcpTool(input: CallToolInput) {
448
551
  }
449
552
  const artifactId = requireStringArg(input.arguments, "artifactId");
450
553
  const metadataOnly = booleanArg(input.arguments, "metadataOnly");
451
- const result = objectResult(await requestJson("GET", `/api/artifacts/${artifactId}`, undefined, { apiKey: credentials.apiKey }));
554
+ const query = queryString({ metadataOnly: metadataOnly ? "true" : undefined });
555
+ const result = objectResult(await requestJson("GET", `/api/artifacts/${artifactId}${query}`, undefined, { apiKey: credentials.apiKey }));
452
556
  if (metadataOnly && result.artifact && typeof result.artifact === "object" && !Array.isArray(result.artifact)) {
453
557
  const { content: _content, storageUrl: _storageUrl, ...metadata } = result.artifact as Record<string, unknown>;
454
558
  void _content;
@@ -549,5 +653,75 @@ export async function callArtifactlyMcpTool(input: CallToolInput) {
549
653
  );
550
654
  }
551
655
 
656
+ if (input.name === "artifactly_list_app_env") {
657
+ if (!credentials) throw new Error(LOGIN_GUIDANCE);
658
+ const artifactId = requireStringArg(input.arguments, "artifactId");
659
+ return objectResult(await requestJson("GET", `/api/artifacts/${artifactId}/app/env`, undefined, { apiKey: credentials.apiKey }));
660
+ }
661
+
662
+ if (input.name === "artifactly_set_app_env") {
663
+ if (!credentials) throw new Error(LOGIN_GUIDANCE);
664
+ const artifactId = requireStringArg(input.arguments, "artifactId");
665
+ const name = requireStringArg(input.arguments, "name");
666
+ const value = requireRawStringArg(input.arguments, "value");
667
+ const isSecret = booleanArg(input.arguments, "isSecret");
668
+ return objectResult(await requestJson("POST", `/api/artifacts/${artifactId}/app/env`, { name, value, isSecret }, { apiKey: credentials.apiKey }));
669
+ }
670
+
671
+ if (input.name === "artifactly_delete_app_env") {
672
+ if (!credentials) throw new Error(LOGIN_GUIDANCE);
673
+ const artifactId = requireStringArg(input.arguments, "artifactId");
674
+ const name = requireStringArg(input.arguments, "name");
675
+ return objectResult(await requestJson("DELETE", `/api/artifacts/${artifactId}/app/env${queryString({ name })}`, undefined, { apiKey: credentials.apiKey }));
676
+ }
677
+
678
+ if (input.name === "artifactly_publish_app_bundle") {
679
+ if (!credentials) throw new Error(LOGIN_GUIDANCE);
680
+ const artifactId = requireStringArg(input.arguments, "artifactId");
681
+ const files = appBundleFilesArg(input.arguments);
682
+ return objectResult(await requestJson("POST", `/api/artifacts/${artifactId}/app/publish`, { ...appBuildPayload(input.arguments), files }, { apiKey: credentials.apiKey }));
683
+ }
684
+
685
+ if (input.name === "artifactly_enqueue_app_build") {
686
+ if (!credentials) throw new Error(LOGIN_GUIDANCE);
687
+ const artifactId = requireStringArg(input.arguments, "artifactId");
688
+ return objectResult(await requestJson("POST", `/api/artifacts/${artifactId}/app/build-jobs`, appBuildPayload(input.arguments), { apiKey: credentials.apiKey }));
689
+ }
690
+
691
+ if (input.name === "artifactly_get_app_build_status") {
692
+ if (!credentials) throw new Error(LOGIN_GUIDANCE);
693
+ const artifactId = requireStringArg(input.arguments, "artifactId");
694
+ const jobId = requireStringArg(input.arguments, "jobId");
695
+ return objectResult(await requestJson("GET", `/api/artifacts/${artifactId}/app/build-jobs/${jobId}`, undefined, { apiKey: credentials.apiKey }));
696
+ }
697
+
698
+ if (input.name === "artifactly_deploy_app") {
699
+ if (!credentials) throw new Error(LOGIN_GUIDANCE);
700
+ const artifactId = requireStringArg(input.arguments, "artifactId");
701
+ const imageRef = requireStringArg(input.arguments, "imageRef");
702
+ const port = numberArg(input.arguments, "port");
703
+ return objectResult(await requestJson("POST", `/api/artifacts/${artifactId}/app/deploy`, { imageRef, ...(port !== undefined ? { port } : {}) }, { apiKey: credentials.apiKey }));
704
+ }
705
+
706
+ if (input.name === "artifactly_list_custom_domains") {
707
+ if (!credentials) throw new Error(LOGIN_GUIDANCE);
708
+ const artifactId = requireStringArg(input.arguments, "artifactId");
709
+ return objectResult(await requestJson("GET", `/api/artifacts/${artifactId}/custom-domains`, undefined, { apiKey: credentials.apiKey }));
710
+ }
711
+
712
+ if (input.name === "artifactly_add_custom_domain") {
713
+ if (!credentials) throw new Error(LOGIN_GUIDANCE);
714
+ const artifactId = requireStringArg(input.arguments, "artifactId");
715
+ const hostname = requireStringArg(input.arguments, "hostname");
716
+ return objectResult(await requestJson("POST", `/api/artifacts/${artifactId}/custom-domains`, { hostname }, { apiKey: credentials.apiKey }));
717
+ }
718
+
719
+ if (input.name === "artifactly_delete_custom_domain") {
720
+ if (!credentials) throw new Error(LOGIN_GUIDANCE);
721
+ const artifactId = requireStringArg(input.arguments, "artifactId");
722
+ const hostname = requireStringArg(input.arguments, "hostname");
723
+ return objectResult(await requestJson("DELETE", `/api/artifacts/${artifactId}/custom-domains${queryString({ hostname })}`, undefined, { apiKey: credentials.apiKey }));
724
+ }
725
+
552
726
  throw new Error(`Unknown Artifactly MCP tool: ${input.name}`);
553
727
  }