@artifactly/mcp 0.1.9 → 0.1.11
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 -2
- package/package.json +1 -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 +4 -0
- package/src/mcp/artifactly-mcp-server.test.ts +47 -1
- package/src/mcp/artifactly-mcp-server.ts +25 -0
- package/src/mcp/artifactly-mcp.test.ts +142 -8
- package/src/mcp/artifactly-mcp.ts +61 -9
package/README.md
CHANGED
|
@@ -66,11 +66,15 @@ Artifact workflow notes:
|
|
|
66
66
|
- Supported visibility values are `public`, `unlisted`, `private`, and `gated`.
|
|
67
67
|
- `gated` artifacts can be published with an `accessList` of allowed email addresses or updated later with `artifactly_update_artifact`.
|
|
68
68
|
- `artifactly_list_artifacts` supports `limit`, `cursor`, `visibility`, and `kind` filters and omits large artifact content by default.
|
|
69
|
-
- `artifactly_get_artifact` returns full
|
|
70
|
-
- `artifactly_update_artifact` accepts either `markdown` or `content`, not both
|
|
69
|
+
- `artifactly_get_artifact` returns full content by default, including base64/data URL file payloads. Pass `metadataOnly: true` to omit `content` and `storageUrl` for bounded metadata reads.
|
|
70
|
+
- `artifactly_update_artifact` accepts either `markdown` or `content`, not both, and preserves text whitespace exactly while rejecting whitespace-only content updates.
|
|
71
|
+
- To replace binary/file content, call `artifactly_update_artifact` with `filePath` and optional `mimeType`; the replacement file becomes the viewer source and clears old inline text content.
|
|
72
|
+
- Pass `description: ""` to clear a description.
|
|
71
73
|
- `artifactly_publish_file` infers MIME type from file extension unless `mimeType` is supplied; explicit `mimeType` wins.
|
|
74
|
+
- Image MIME types such as `image/png` publish as `kind: "image"` and can be listed with `kind: "image"`.
|
|
72
75
|
- Files larger than 10 MB are rejected before upload to avoid large in-memory base64 payloads.
|
|
73
76
|
- Requests time out after 30 seconds.
|
|
77
|
+
- This package currently exposes MCP tools only; resources, resource templates, and prompts are intentionally unsupported.
|
|
74
78
|
|
|
75
79
|
Manual publish from a logged-in maintainer account:
|
|
76
80
|
|
package/package.json
CHANGED
|
@@ -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
|
};
|
|
@@ -14,5 +14,9 @@ describe("Artifactly MCP package documentation", () => {
|
|
|
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
16
|
expect(readme).toContain("Requests time out after 30 seconds");
|
|
17
|
+
expect(readme).toContain("`artifactly_get_artifact` returns full content by default");
|
|
18
|
+
expect(readme).toContain("`metadataOnly: true`");
|
|
19
|
+
expect(readme).toContain("This package currently exposes MCP tools only");
|
|
20
|
+
expect(readme).toContain("resources, resource templates, and prompts are intentionally unsupported");
|
|
17
21
|
});
|
|
18
22
|
});
|
|
@@ -41,7 +41,7 @@ describe("Artifactly MCP stdio server helpers", () => {
|
|
|
41
41
|
it("registers tool schemas and server info from package metadata", () => {
|
|
42
42
|
const server = createArtifactlyMcpServer() as unknown as {
|
|
43
43
|
server: { _serverInfo: { name: string; version: string } };
|
|
44
|
-
_registeredTools: Record<string, { inputSchema: { def: { shape: Record<string, unknown> }; safeParse(value: unknown): { success: boolean } } }>;
|
|
44
|
+
_registeredTools: Record<string, { inputSchema: { def: { shape: Record<string, unknown> }; safeParse(value: unknown): { success: boolean } }; annotations?: Record<string, unknown>; description: string }>;
|
|
45
45
|
};
|
|
46
46
|
|
|
47
47
|
expect(server.server._serverInfo).toEqual({
|
|
@@ -71,6 +71,38 @@ describe("Artifactly MCP stdio server helpers", () => {
|
|
|
71
71
|
kind: "html",
|
|
72
72
|
}).success,
|
|
73
73
|
).toBe(true);
|
|
74
|
+
expect(
|
|
75
|
+
server._registeredTools.artifactly_publish_file.inputSchema.safeParse({
|
|
76
|
+
path: "/tmp/chart.png",
|
|
77
|
+
visibility: "gated",
|
|
78
|
+
accessList: ["agent@example.com"],
|
|
79
|
+
}).success,
|
|
80
|
+
).toBe(true);
|
|
81
|
+
expect(
|
|
82
|
+
server._registeredTools.artifactly_update_artifact.inputSchema.safeParse({
|
|
83
|
+
artifactId: "artifact_1",
|
|
84
|
+
filePath: "/tmp/replacement.pdf",
|
|
85
|
+
mimeType: "application/pdf",
|
|
86
|
+
}).success,
|
|
87
|
+
).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("registers MCP tool annotations and accurate content descriptions", () => {
|
|
91
|
+
const server = createArtifactlyMcpServer() as unknown as {
|
|
92
|
+
_registeredTools: Record<string, { annotations?: Record<string, unknown>; description: string }>;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
expect(server._registeredTools.artifactly_list_artifacts.annotations).toMatchObject({
|
|
96
|
+
readOnlyHint: true,
|
|
97
|
+
});
|
|
98
|
+
expect(server._registeredTools.artifactly_get_artifact.annotations).toMatchObject({
|
|
99
|
+
readOnlyHint: true,
|
|
100
|
+
});
|
|
101
|
+
expect(server._registeredTools.artifactly_delete_artifact.annotations).toMatchObject({
|
|
102
|
+
destructiveHint: true,
|
|
103
|
+
});
|
|
104
|
+
expect(server._registeredTools.artifactly_get_artifact.description).toMatch(/full content/i);
|
|
105
|
+
expect(server._registeredTools.artifactly_get_artifact.description).toMatch(/metadataOnly/i);
|
|
74
106
|
});
|
|
75
107
|
|
|
76
108
|
it("maps Artifactly tools to MCP-compatible tool definitions", () => {
|
|
@@ -97,6 +129,20 @@ describe("Artifactly MCP stdio server helpers", () => {
|
|
|
97
129
|
expect(result.content).toHaveLength(1);
|
|
98
130
|
expect(result.content[0]).toMatchObject({ type: "text" });
|
|
99
131
|
expect(result.content[0].text).toContain('"authenticated": false');
|
|
132
|
+
expect(result.structuredContent).toEqual({ authenticated: false, apiBaseUrl: "https://artifactly.io" });
|
|
100
133
|
expect(result.content[0].text).not.toContain("art_");
|
|
101
134
|
});
|
|
135
|
+
|
|
136
|
+
it("returns typed MCP error content", async () => {
|
|
137
|
+
const result = await createArtifactlyMcpCallResult({
|
|
138
|
+
name: "artifactly_list_artifacts",
|
|
139
|
+
credentialPath: "/tmp/does-not-exist-artifactly-credentials.json",
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(result.isError).toBe(true);
|
|
143
|
+
expect(result.structuredContent).toMatchObject({
|
|
144
|
+
error: expect.stringContaining("artifactly_request_login_code"),
|
|
145
|
+
errorType: "Error",
|
|
146
|
+
});
|
|
147
|
+
});
|
|
102
148
|
});
|
|
@@ -27,6 +27,7 @@ type McpTextContent = {
|
|
|
27
27
|
|
|
28
28
|
export type ArtifactlyMcpCallResult = {
|
|
29
29
|
content: McpTextContent[];
|
|
30
|
+
structuredContent?: Record<string, unknown>;
|
|
30
31
|
isError?: boolean;
|
|
31
32
|
};
|
|
32
33
|
|
|
@@ -53,7 +54,12 @@ export function listArtifactlyMcpServerTools() {
|
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
function resultToText(value: unknown): ArtifactlyMcpCallResult {
|
|
57
|
+
const structuredContent =
|
|
58
|
+
value && typeof value === "object" && !Array.isArray(value)
|
|
59
|
+
? (value as Record<string, unknown>)
|
|
60
|
+
: undefined;
|
|
56
61
|
return {
|
|
62
|
+
...(structuredContent ? { structuredContent } : {}),
|
|
57
63
|
content: [
|
|
58
64
|
{
|
|
59
65
|
type: "text",
|
|
@@ -74,6 +80,12 @@ export async function createArtifactlyMcpCallResult(input: {
|
|
|
74
80
|
} catch (error) {
|
|
75
81
|
return {
|
|
76
82
|
isError: true,
|
|
83
|
+
structuredContent: {
|
|
84
|
+
error: error instanceof Error ? error.message : String(error),
|
|
85
|
+
errorType: error instanceof Error ? error.name : "Error",
|
|
86
|
+
...(error instanceof Error && "status" in error ? { status: (error as { status?: unknown }).status } : {}),
|
|
87
|
+
...(error instanceof Error && "details" in error ? { details: (error as { details?: unknown }).details } : {}),
|
|
88
|
+
},
|
|
77
89
|
content: [
|
|
78
90
|
{
|
|
79
91
|
type: "text",
|
|
@@ -110,12 +122,15 @@ const listArtifactsSchema = z.object({
|
|
|
110
122
|
});
|
|
111
123
|
const getArtifactSchema = z.object({
|
|
112
124
|
artifactId: z.string().min(1),
|
|
125
|
+
metadataOnly: z.boolean().optional(),
|
|
113
126
|
});
|
|
114
127
|
const updateArtifactSchema = z.object({
|
|
115
128
|
artifactId: z.string().min(1),
|
|
116
129
|
title: z.string().min(1).optional(),
|
|
117
130
|
markdown: z.string().min(1).optional(),
|
|
118
131
|
content: z.string().min(1).optional(),
|
|
132
|
+
filePath: z.string().min(1).optional(),
|
|
133
|
+
mimeType: z.string().min(1).optional(),
|
|
119
134
|
description: z.string().max(500).optional(),
|
|
120
135
|
visibility: z.enum(["public", "unlisted", "private", "gated"]).optional(),
|
|
121
136
|
accessList: z.array(z.string().email()).optional(),
|
|
@@ -128,6 +143,7 @@ const publishFileSchema = z.object({
|
|
|
128
143
|
title: z.string().min(1).optional(),
|
|
129
144
|
mimeType: z.string().min(1).optional(),
|
|
130
145
|
visibility: z.enum(["public", "unlisted", "private", "gated"]).optional(),
|
|
146
|
+
accessList: z.array(z.string().email()).optional(),
|
|
131
147
|
});
|
|
132
148
|
|
|
133
149
|
const toolSchemas = {
|
|
@@ -143,6 +159,14 @@ const toolSchemas = {
|
|
|
143
159
|
artifactly_publish_file: publishFileSchema,
|
|
144
160
|
} as const;
|
|
145
161
|
|
|
162
|
+
const toolAnnotations: Record<string, Record<string, boolean>> = {
|
|
163
|
+
artifactly_auth_status: { readOnlyHint: true },
|
|
164
|
+
artifactly_list_artifacts: { readOnlyHint: true },
|
|
165
|
+
artifactly_get_artifact: { readOnlyHint: true },
|
|
166
|
+
artifactly_delete_artifact: { destructiveHint: true },
|
|
167
|
+
artifactly_logout: { destructiveHint: true },
|
|
168
|
+
};
|
|
169
|
+
|
|
146
170
|
export function createArtifactlyMcpServer() {
|
|
147
171
|
const server = new McpServer({
|
|
148
172
|
name: "artifactly",
|
|
@@ -157,6 +181,7 @@ export function createArtifactlyMcpServer() {
|
|
|
157
181
|
title: tool.name,
|
|
158
182
|
description: tool.description,
|
|
159
183
|
inputSchema: schema,
|
|
184
|
+
...(toolAnnotations[tool.name] ? { annotations: toolAnnotations[tool.name] } : {}),
|
|
160
185
|
},
|
|
161
186
|
async (args: Record<string, unknown>) =>
|
|
162
187
|
createArtifactlyMcpCallResult({ name: tool.name, arguments: args }),
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
|
|
@@ -160,7 +160,7 @@ describe("Artifactly MCP tools", () => {
|
|
|
160
160
|
expect(requests).toEqual([
|
|
161
161
|
{
|
|
162
162
|
path: "/api/artifacts",
|
|
163
|
-
apiKey:
|
|
163
|
+
apiKey: expect.any(String),
|
|
164
164
|
body: {
|
|
165
165
|
title: "Hello",
|
|
166
166
|
content: "# Hello",
|
|
@@ -273,7 +273,7 @@ describe("Artifactly MCP tools", () => {
|
|
|
273
273
|
if (method === "GET" && path.startsWith("/api/artifacts?")) {
|
|
274
274
|
return { artifacts: [{ id: "artifact_1", title: "Hello" }] };
|
|
275
275
|
}
|
|
276
|
-
if (method === "GET" && path
|
|
276
|
+
if (method === "GET" && path.startsWith("/api/artifacts/artifact_1")) {
|
|
277
277
|
return { artifact: { id: "artifact_1", title: "Hello" } };
|
|
278
278
|
}
|
|
279
279
|
if (method === "PATCH" && path === "/api/artifacts/artifact_1") {
|
|
@@ -294,7 +294,7 @@ describe("Artifactly MCP tools", () => {
|
|
|
294
294
|
callArtifactlyMcpTool({
|
|
295
295
|
name: "artifactly_get_artifact",
|
|
296
296
|
credentialPath,
|
|
297
|
-
arguments: { artifactId: "artifact_1" },
|
|
297
|
+
arguments: { artifactId: "artifact_1", metadataOnly: true },
|
|
298
298
|
requestJson,
|
|
299
299
|
}),
|
|
300
300
|
).resolves.toEqual({ artifact: { id: "artifact_1", title: "Hello" } });
|
|
@@ -327,14 +327,14 @@ describe("Artifactly MCP tools", () => {
|
|
|
327
327
|
apiKey: "art_t...et",
|
|
328
328
|
body: undefined,
|
|
329
329
|
},
|
|
330
|
-
{ method: "GET", path: "/api/artifacts/artifact_1", apiKey:
|
|
330
|
+
{ method: "GET", path: "/api/artifacts/artifact_1?metadataOnly=true", apiKey: expect.any(String), body: undefined },
|
|
331
331
|
{
|
|
332
332
|
method: "PATCH",
|
|
333
333
|
path: "/api/artifacts/artifact_1",
|
|
334
|
-
apiKey:
|
|
334
|
+
apiKey: expect.any(String),
|
|
335
335
|
body: { title: "Updated", visibility: "private", accessList: ["agent@example.com"] },
|
|
336
336
|
},
|
|
337
|
-
{ method: "DELETE", path: "/api/artifacts/artifact_1", apiKey:
|
|
337
|
+
{ method: "DELETE", path: "/api/artifacts/artifact_1", apiKey: expect.any(String), body: undefined },
|
|
338
338
|
]);
|
|
339
339
|
rmSync(dir, { recursive: true, force: true });
|
|
340
340
|
});
|
|
@@ -487,7 +487,7 @@ describe("Artifactly MCP tools", () => {
|
|
|
487
487
|
writeCredentials(
|
|
488
488
|
{
|
|
489
489
|
apiBaseUrl: "https://artifactly.example",
|
|
490
|
-
apiKey: "
|
|
490
|
+
apiKey: "art_test_secret",
|
|
491
491
|
email: "agent@example.com",
|
|
492
492
|
workspace: "agent",
|
|
493
493
|
},
|
|
@@ -505,4 +505,138 @@ describe("Artifactly MCP tools", () => {
|
|
|
505
505
|
|
|
506
506
|
rmSync(dir, { recursive: true, force: true });
|
|
507
507
|
});
|
|
508
|
+
|
|
509
|
+
it("rejects directory paths with a friendly file error", async () => {
|
|
510
|
+
const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
|
|
511
|
+
const credentialPath = join(dir, "credentials.json");
|
|
512
|
+
const directoryPath = join(dir, "folder");
|
|
513
|
+
mkdirSync(directoryPath);
|
|
514
|
+
writeCredentials(
|
|
515
|
+
{
|
|
516
|
+
apiBaseUrl: "https://artifactly.example",
|
|
517
|
+
apiKey: "art_test_secret",
|
|
518
|
+
email: "agent@example.com",
|
|
519
|
+
workspace: "agent",
|
|
520
|
+
},
|
|
521
|
+
credentialPath,
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
await expect(
|
|
525
|
+
callArtifactlyMcpTool({
|
|
526
|
+
name: "artifactly_publish_file",
|
|
527
|
+
credentialPath,
|
|
528
|
+
arguments: { path: directoryPath },
|
|
529
|
+
}),
|
|
530
|
+
).rejects.toThrow(/Path must point to a file/);
|
|
531
|
+
|
|
532
|
+
rmSync(dir, { recursive: true, force: true });
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("publishes gated image files with access lists and image kind", async () => {
|
|
536
|
+
const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
|
|
537
|
+
const credentialPath = join(dir, "credentials.json");
|
|
538
|
+
const pngPath = join(dir, "chart.png");
|
|
539
|
+
let publishedBody: unknown;
|
|
540
|
+
writeFileSync(pngPath, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
|
541
|
+
writeCredentials(
|
|
542
|
+
{
|
|
543
|
+
apiBaseUrl: "https://artifactly.example",
|
|
544
|
+
apiKey: "art_test_secret",
|
|
545
|
+
email: "agent@example.com",
|
|
546
|
+
workspace: "agent",
|
|
547
|
+
},
|
|
548
|
+
credentialPath,
|
|
549
|
+
);
|
|
550
|
+
|
|
551
|
+
await callArtifactlyMcpTool({
|
|
552
|
+
name: "artifactly_publish_file",
|
|
553
|
+
credentialPath,
|
|
554
|
+
arguments: {
|
|
555
|
+
path: pngPath,
|
|
556
|
+
mimeType: "image/png",
|
|
557
|
+
visibility: "gated",
|
|
558
|
+
accessList: ["One@Example.com"],
|
|
559
|
+
},
|
|
560
|
+
postJson: async (_path, body) => {
|
|
561
|
+
publishedBody = body;
|
|
562
|
+
return { id: "artifact_1", accessList: ["one@example.com"] };
|
|
563
|
+
},
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
expect(publishedBody).toMatchObject({
|
|
567
|
+
kind: "image",
|
|
568
|
+
storageUrl: "data:image/png;base64,iVBORw==",
|
|
569
|
+
visibility: "gated",
|
|
570
|
+
accessList: ["One@Example.com"],
|
|
571
|
+
});
|
|
572
|
+
rmSync(dir, { recursive: true, force: true });
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it("preserves update whitespace and rejects whitespace-only updates locally", async () => {
|
|
576
|
+
const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
|
|
577
|
+
const credentialPath = join(dir, "credentials.json");
|
|
578
|
+
const requests: unknown[] = [];
|
|
579
|
+
writeCredentials(
|
|
580
|
+
{
|
|
581
|
+
apiBaseUrl: "https://artifactly.example",
|
|
582
|
+
apiKey: "art_test_secret",
|
|
583
|
+
email: "agent@example.com",
|
|
584
|
+
workspace: "agent",
|
|
585
|
+
},
|
|
586
|
+
credentialPath,
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
await callArtifactlyMcpTool({
|
|
590
|
+
name: "artifactly_update_artifact",
|
|
591
|
+
credentialPath,
|
|
592
|
+
arguments: { artifactId: "artifact_1", markdown: " # Title\n" },
|
|
593
|
+
requestJson: async (_method, _path, body) => {
|
|
594
|
+
requests.push(body);
|
|
595
|
+
return { artifact: { id: "artifact_1" } };
|
|
596
|
+
},
|
|
597
|
+
});
|
|
598
|
+
expect(requests).toEqual([{ markdown: " # Title\n" }]);
|
|
599
|
+
await expect(
|
|
600
|
+
callArtifactlyMcpTool({
|
|
601
|
+
name: "artifactly_update_artifact",
|
|
602
|
+
credentialPath,
|
|
603
|
+
arguments: { artifactId: "artifact_1", content: " " },
|
|
604
|
+
}),
|
|
605
|
+
).rejects.toThrow(/Content cannot be whitespace-only/);
|
|
606
|
+
|
|
607
|
+
rmSync(dir, { recursive: true, force: true });
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it("replaces artifact files explicitly through update", async () => {
|
|
611
|
+
const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
|
|
612
|
+
const credentialPath = join(dir, "credentials.json");
|
|
613
|
+
const pdfPath = join(dir, "replacement.pdf");
|
|
614
|
+
let bodySent: unknown;
|
|
615
|
+
writeFileSync(pdfPath, Buffer.from("%PDF-1.4"));
|
|
616
|
+
writeCredentials(
|
|
617
|
+
{
|
|
618
|
+
apiBaseUrl: "https://artifactly.example",
|
|
619
|
+
apiKey: "art_test_secret",
|
|
620
|
+
email: "agent@example.com",
|
|
621
|
+
workspace: "agent",
|
|
622
|
+
},
|
|
623
|
+
credentialPath,
|
|
624
|
+
);
|
|
625
|
+
|
|
626
|
+
await callArtifactlyMcpTool({
|
|
627
|
+
name: "artifactly_update_artifact",
|
|
628
|
+
credentialPath,
|
|
629
|
+
arguments: { artifactId: "artifact_1", filePath: pdfPath, mimeType: "application/pdf" },
|
|
630
|
+
requestJson: async (_method, _path, body) => {
|
|
631
|
+
bodySent = body;
|
|
632
|
+
return { artifact: { id: "artifact_1", kind: "file" } };
|
|
633
|
+
},
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
expect(bodySent).toMatchObject({
|
|
637
|
+
kind: "file",
|
|
638
|
+
storageUrl: "data:application/pdf;base64,JVBERi0xLjQ=",
|
|
639
|
+
});
|
|
640
|
+
rmSync(dir, { recursive: true, force: true });
|
|
641
|
+
});
|
|
508
642
|
});
|
|
@@ -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,
|
|
@@ -55,7 +56,7 @@ function createDefaultRequestJson(apiBaseUrl: string): RequestJson {
|
|
|
55
56
|
typeof payload.error === "string"
|
|
56
57
|
? payload.error
|
|
57
58
|
: `Artifactly API request failed with ${response.status}`;
|
|
58
|
-
throw new
|
|
59
|
+
throw new ArtifactlyApiError(message, response.status, payload);
|
|
59
60
|
}
|
|
60
61
|
return payload;
|
|
61
62
|
};
|
|
@@ -77,6 +78,14 @@ function optionalStringArg(
|
|
|
77
78
|
return typeof value === "string" ? value.trim() : undefined;
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
function optionalRawStringArg(
|
|
82
|
+
args: Record<string, unknown> | undefined,
|
|
83
|
+
key: string,
|
|
84
|
+
): string | undefined {
|
|
85
|
+
const value = args?.[key];
|
|
86
|
+
return typeof value === "string" ? value : undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
80
89
|
function requireStringArg(
|
|
81
90
|
args: Record<string, unknown> | undefined,
|
|
82
91
|
key: string,
|
|
@@ -127,6 +136,9 @@ function booleanArg(args: Record<string, unknown> | undefined, key: string): boo
|
|
|
127
136
|
function readFileForPublish(path: string): Buffer {
|
|
128
137
|
try {
|
|
129
138
|
const stats = statSync(path);
|
|
139
|
+
if (!stats.isFile()) {
|
|
140
|
+
throw new Error(`Path must point to a file: ${path}`);
|
|
141
|
+
}
|
|
130
142
|
if (stats.size > MAX_UPLOAD_BYTES) {
|
|
131
143
|
throw new Error(
|
|
132
144
|
`File is too large: ${path}. Maximum supported upload size is 10 MB.`,
|
|
@@ -157,6 +169,10 @@ function inferMimeType(path: string): string {
|
|
|
157
169
|
if (extension === ".json") return "application/json";
|
|
158
170
|
if (extension === ".md" || extension === ".markdown") return "text/markdown";
|
|
159
171
|
if (extension === ".txt") return "text/plain";
|
|
172
|
+
if (extension === ".png") return "image/png";
|
|
173
|
+
if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg";
|
|
174
|
+
if (extension === ".gif") return "image/gif";
|
|
175
|
+
if (extension === ".webp") return "image/webp";
|
|
160
176
|
return "application/octet-stream";
|
|
161
177
|
}
|
|
162
178
|
|
|
@@ -165,6 +181,7 @@ function kindForMime(_path: string, mimeType: string) {
|
|
|
165
181
|
if (normalized.includes("html")) return "html";
|
|
166
182
|
if (normalized.includes("json")) return "json";
|
|
167
183
|
if (normalized.includes("markdown")) return "markdown";
|
|
184
|
+
if (normalized.startsWith("image/")) return "image";
|
|
168
185
|
return "file";
|
|
169
186
|
}
|
|
170
187
|
|
|
@@ -250,10 +267,10 @@ export function listArtifactlyMcpTools(): ArtifactlyMcpTool[] {
|
|
|
250
267
|
},
|
|
251
268
|
{
|
|
252
269
|
name: "artifactly_get_artifact",
|
|
253
|
-
description: "Get Artifactly artifact
|
|
270
|
+
description: "Get an Artifactly artifact. Returns full content by default, including base64/data URL file payloads; pass metadataOnly: true to omit content and storageUrl.",
|
|
254
271
|
inputSchema: {
|
|
255
272
|
type: "object",
|
|
256
|
-
properties: { artifactId: { type: "string" } },
|
|
273
|
+
properties: { artifactId: { type: "string" }, metadataOnly: { type: "boolean" } },
|
|
257
274
|
required: ["artifactId"],
|
|
258
275
|
},
|
|
259
276
|
},
|
|
@@ -268,6 +285,8 @@ export function listArtifactlyMcpTools(): ArtifactlyMcpTool[] {
|
|
|
268
285
|
title: { type: "string" },
|
|
269
286
|
markdown: { type: "string" },
|
|
270
287
|
content: { type: "string" },
|
|
288
|
+
filePath: { type: "string" },
|
|
289
|
+
mimeType: { type: "string" },
|
|
271
290
|
description: { type: "string" },
|
|
272
291
|
visibility: { type: "string", enum: ["public", "unlisted", "private", "gated"] },
|
|
273
292
|
accessList: { type: "array", items: { type: "string" } },
|
|
@@ -295,6 +314,7 @@ export function listArtifactlyMcpTools(): ArtifactlyMcpTool[] {
|
|
|
295
314
|
title: { type: "string" },
|
|
296
315
|
mimeType: { type: "string" },
|
|
297
316
|
visibility: { type: "string", enum: ["public", "unlisted", "private", "gated"] },
|
|
317
|
+
accessList: { type: "array", items: { type: "string" } },
|
|
298
318
|
},
|
|
299
319
|
required: ["path"],
|
|
300
320
|
},
|
|
@@ -416,7 +436,16 @@ export async function callArtifactlyMcpTool(input: CallToolInput) {
|
|
|
416
436
|
throw new Error(LOGIN_GUIDANCE);
|
|
417
437
|
}
|
|
418
438
|
const artifactId = requireStringArg(input.arguments, "artifactId");
|
|
419
|
-
|
|
439
|
+
const metadataOnly = booleanArg(input.arguments, "metadataOnly");
|
|
440
|
+
const query = queryString({ metadataOnly: metadataOnly ? "true" : undefined });
|
|
441
|
+
const result = objectResult(await requestJson("GET", `/api/artifacts/${artifactId}${query}`, undefined, { apiKey: credentials.apiKey }));
|
|
442
|
+
if (metadataOnly && result.artifact && typeof result.artifact === "object" && !Array.isArray(result.artifact)) {
|
|
443
|
+
const { content: _content, storageUrl: _storageUrl, ...metadata } = result.artifact as Record<string, unknown>;
|
|
444
|
+
void _content;
|
|
445
|
+
void _storageUrl;
|
|
446
|
+
return { ...result, artifact: metadata };
|
|
447
|
+
}
|
|
448
|
+
return result;
|
|
420
449
|
}
|
|
421
450
|
|
|
422
451
|
if (input.name === "artifactly_update_artifact") {
|
|
@@ -425,22 +454,41 @@ export async function callArtifactlyMcpTool(input: CallToolInput) {
|
|
|
425
454
|
}
|
|
426
455
|
const artifactId = requireStringArg(input.arguments, "artifactId");
|
|
427
456
|
const title = stringArg(input.arguments, "title");
|
|
428
|
-
const markdown =
|
|
429
|
-
const content =
|
|
457
|
+
const markdown = optionalRawStringArg(input.arguments, "markdown");
|
|
458
|
+
const content = optionalRawStringArg(input.arguments, "content");
|
|
459
|
+
const filePath = stringArg(input.arguments, "filePath");
|
|
430
460
|
if (markdown !== undefined && content !== undefined) {
|
|
431
461
|
throw new Error("Use either markdown or content, not both.");
|
|
432
462
|
}
|
|
463
|
+
if ((markdown !== undefined || content !== undefined) && filePath) {
|
|
464
|
+
throw new Error("Use either filePath or text content, not both.");
|
|
465
|
+
}
|
|
466
|
+
const textContent = markdown ?? content;
|
|
467
|
+
if (textContent !== undefined && !textContent.trim()) {
|
|
468
|
+
throw new Error("Content cannot be whitespace-only.");
|
|
469
|
+
}
|
|
433
470
|
const description = optionalStringArg(input.arguments, "description");
|
|
434
471
|
const visibility = stringArg(input.arguments, "visibility");
|
|
435
472
|
const accessList = stringArrayArg(input.arguments, "accessList");
|
|
473
|
+
let replacement: Record<string, unknown> = {};
|
|
474
|
+
if (filePath) {
|
|
475
|
+
const mimeType = stringArg(input.arguments, "mimeType") ?? inferMimeType(filePath);
|
|
476
|
+
const kind = kindForMime(filePath, mimeType);
|
|
477
|
+
const file = readFileForPublish(filePath);
|
|
478
|
+
replacement = {
|
|
479
|
+
kind,
|
|
480
|
+
storageUrl: `data:${mimeType};base64,${file.toString("base64")}`,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
436
483
|
return objectResult(
|
|
437
484
|
await requestJson(
|
|
438
485
|
"PATCH",
|
|
439
486
|
`/api/artifacts/${artifactId}`,
|
|
440
487
|
{
|
|
441
488
|
...(title ? { title } : {}),
|
|
442
|
-
...(markdown ? { markdown } : {}),
|
|
443
|
-
...(content ? { content } : {}),
|
|
489
|
+
...(markdown !== undefined ? { markdown } : {}),
|
|
490
|
+
...(content !== undefined ? { content } : {}),
|
|
491
|
+
...replacement,
|
|
444
492
|
...(description !== undefined ? { description } : {}),
|
|
445
493
|
...(visibility ? { visibility } : {}),
|
|
446
494
|
...(accessList ? { accessList } : {}),
|
|
@@ -466,21 +514,25 @@ export async function callArtifactlyMcpTool(input: CallToolInput) {
|
|
|
466
514
|
const title = stringArg(input.arguments, "title") ?? titleFromPath(path);
|
|
467
515
|
const mimeType = stringArg(input.arguments, "mimeType") ?? inferMimeType(path);
|
|
468
516
|
const visibility = stringArg(input.arguments, "visibility") ?? "unlisted";
|
|
517
|
+
const accessList = stringArrayArg(input.arguments, "accessList");
|
|
469
518
|
const kind = kindForMime(path, mimeType);
|
|
470
519
|
const file = readFileForPublish(path);
|
|
520
|
+
const fileLike = kind === "file" || kind === "image";
|
|
471
521
|
const payload =
|
|
472
|
-
|
|
522
|
+
fileLike
|
|
473
523
|
? {
|
|
474
524
|
title,
|
|
475
525
|
kind,
|
|
476
526
|
storageUrl: `data:${mimeType};base64,${file.toString("base64")}`,
|
|
477
527
|
visibility,
|
|
528
|
+
...(accessList ? { accessList } : {}),
|
|
478
529
|
}
|
|
479
530
|
: {
|
|
480
531
|
title,
|
|
481
532
|
kind,
|
|
482
533
|
content: file.toString("utf8"),
|
|
483
534
|
visibility,
|
|
535
|
+
...(accessList ? { accessList } : {}),
|
|
484
536
|
};
|
|
485
537
|
return objectResult(
|
|
486
538
|
await postJson("/api/artifacts", payload, { apiKey: credentials.apiKey }),
|