@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 +6 -0
- package/package.json +2 -1
- package/src/cli/artifactly-cli.test.ts +45 -1
- package/src/cli/artifactly-cli.ts +15 -2
- package/src/mcp/artifactly-mcp-docs.test.ts +3 -0
- package/src/mcp/artifactly-mcp-server.test.ts +40 -0
- package/src/mcp/artifactly-mcp-server.ts +59 -0
- package/src/mcp/artifactly-mcp.test.ts +141 -6
- package/src/mcp/artifactly-mcp.ts +187 -13
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.
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
}
|