@artifactly/mcp 0.1.8 → 0.1.10

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
@@ -35,6 +35,47 @@ npx -y --package=@artifactly/mcp artifactly-mcp
35
35
 
36
36
  The package also exposes a human CLI as `artifactly`.
37
37
 
38
+ ## Agent MCP usage
39
+
40
+ Typical MCP client config:
41
+
42
+ ```toml
43
+ [mcp_servers.artifactly]
44
+ command = "npx"
45
+ args = ["-y", "@artifactly/mcp"]
46
+ ```
47
+
48
+ Robust explicit-bin form:
49
+
50
+ ```toml
51
+ [mcp_servers.artifactly]
52
+ command = "npx"
53
+ args = ["-y", "--package=@artifactly/mcp", "artifactly-mcp"]
54
+ ```
55
+
56
+ Authentication flow:
57
+
58
+ 1. Run `artifactly_request_login_code` with an email address.
59
+ 2. Run `artifactly_verify_login_code` with the emailed code.
60
+ 3. Use `artifactly_auth_status` to inspect local credentials, or pass `validate: true` to confirm the saved API key with the Artifactly server.
61
+ 4. Run `artifactly_logout` to clear local credentials.
62
+
63
+ Artifact workflow notes:
64
+
65
+ - Default visibility is `unlisted` for agent-created artifacts.
66
+ - Supported visibility values are `public`, `unlisted`, `private`, and `gated`.
67
+ - `gated` artifacts can be published with an `accessList` of allowed email addresses or updated later with `artifactly_update_artifact`.
68
+ - `artifactly_list_artifacts` supports `limit`, `cursor`, `visibility`, and `kind` filters and omits large artifact content by default.
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.
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"`.
75
+ - Files larger than 10 MB are rejected before upload to avoid large in-memory base64 payloads.
76
+ - Requests time out after 30 seconds.
77
+ - This package currently exposes MCP tools only; resources, resource templates, and prompts are intentionally unsupported.
78
+
38
79
  Manual publish from a logged-in maintainer account:
39
80
 
40
81
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@artifactly/mcp",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "private": false,
5
5
  "files": [
6
6
  "bin",
@@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest";
6
6
 
7
7
  import {
8
8
  createArtifactlyCli,
9
+ createDefaultPostJson,
9
10
  getDefaultCredentialPath,
10
11
  readCredentials,
11
12
  } from "./artifactly-cli";
@@ -107,4 +108,23 @@ describe("artifactly CLI", () => {
107
108
  "/Users/alice/.artifactly/credentials.json",
108
109
  );
109
110
  });
111
+
112
+ it("uses an explicit timeout signal for default POST requests", async () => {
113
+ const calls: Array<{ url: string; signal?: AbortSignal }> = [];
114
+ const postJson = createDefaultPostJson("https://artifactly.example", {
115
+ timeoutMs: 1234,
116
+ fetcher: async (url, init) => {
117
+ calls.push({ url: String(url), signal: init?.signal ?? undefined });
118
+ return new Response(JSON.stringify({ ok: true }), {
119
+ status: 200,
120
+ headers: { "content-type": "application/json" },
121
+ });
122
+ },
123
+ });
124
+
125
+ await expect(postJson("/api/test", { ok: true })).resolves.toEqual({ ok: true });
126
+ expect(calls).toEqual([
127
+ { url: "https://artifactly.example/api/test", signal: expect.any(AbortSignal) },
128
+ ]);
129
+ });
110
130
  });
@@ -17,6 +17,13 @@ export type PostJson = (
17
17
  options?: { apiKey?: string },
18
18
  ) => Promise<unknown>;
19
19
 
20
+ type Fetcher = typeof fetch;
21
+
22
+ type DefaultPostJsonOptions = {
23
+ timeoutMs?: number;
24
+ fetcher?: Fetcher;
25
+ };
26
+
20
27
  type ArtifactlyCliOptions = {
21
28
  apiBaseUrl?: string;
22
29
  credentialPath?: string;
@@ -109,15 +116,21 @@ async function defaultPrompt(question: string) {
109
116
  }
110
117
  }
111
118
 
112
- export function createDefaultPostJson(apiBaseUrl: string): PostJson {
119
+ export function createDefaultPostJson(
120
+ apiBaseUrl: string,
121
+ options: DefaultPostJsonOptions = {},
122
+ ): PostJson {
123
+ const fetcher = options.fetcher ?? fetch;
124
+ const timeoutMs = options.timeoutMs ?? 30_000;
113
125
  return async (path, body, options) => {
114
- const response = await fetch(`${apiBaseUrl}${path}`, {
126
+ const response = await fetcher(`${apiBaseUrl}${path}`, {
115
127
  method: "POST",
116
128
  headers: {
117
129
  "content-type": "application/json",
118
130
  ...(options?.apiKey ? { authorization: `Bearer ${options.apiKey}` } : {}),
119
131
  },
120
132
  body: JSON.stringify(body),
133
+ signal: AbortSignal.timeout(timeoutMs),
121
134
  });
122
135
  const payload = (await response.json().catch(() => ({}))) as unknown;
123
136
  if (!response.ok) {
@@ -0,0 +1,22 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ describe("Artifactly MCP package documentation", () => {
6
+ it("documents installed-package behavior for agents", () => {
7
+ const readme = readFileSync("README.md", "utf8");
8
+
9
+ expect(readme).toContain("## Agent MCP usage");
10
+ expect(readme).toContain("artifactly_request_login_code");
11
+ expect(readme).toContain("artifactly_verify_login_code");
12
+ expect(readme).toContain("Default visibility is `unlisted`");
13
+ expect(readme).toContain("public`, `unlisted`, `private`, and `gated`");
14
+ expect(readme).toContain("`artifactly_list_artifacts` supports `limit`, `cursor`, `visibility`, and `kind`");
15
+ expect(readme).toContain("Files larger than 10 MB are rejected before upload");
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");
21
+ });
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({
@@ -55,6 +55,54 @@ describe("Artifactly MCP stdio server helpers", () => {
55
55
  description: "",
56
56
  }).success,
57
57
  ).toBe(true);
58
+ expect(
59
+ server._registeredTools.artifactly_publish_markdown.inputSchema.safeParse({
60
+ title: "Gated",
61
+ markdown: "# Secret",
62
+ visibility: "gated",
63
+ accessList: ["agent@example.com"],
64
+ }).success,
65
+ ).toBe(true);
66
+ expect(
67
+ server._registeredTools.artifactly_list_artifacts.inputSchema.safeParse({
68
+ limit: 10,
69
+ cursor: "2026-02-01T00:00:00.000Z",
70
+ visibility: "gated",
71
+ kind: "html",
72
+ }).success,
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);
58
106
  });
59
107
 
60
108
  it("maps Artifactly tools to MCP-compatible tool definitions", () => {
@@ -81,6 +129,20 @@ describe("Artifactly MCP stdio server helpers", () => {
81
129
  expect(result.content).toHaveLength(1);
82
130
  expect(result.content[0]).toMatchObject({ type: "text" });
83
131
  expect(result.content[0].text).toContain('"authenticated": false');
132
+ expect(result.structuredContent).toEqual({ authenticated: false, apiBaseUrl: "https://artifactly.io" });
84
133
  expect(result.content[0].text).not.toContain("art_");
85
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
+ });
86
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",
@@ -100,16 +112,25 @@ const publishMarkdownSchema = z.object({
100
112
  title: z.string().min(1),
101
113
  markdown: z.string().min(1),
102
114
  visibility: z.enum(["public", "unlisted", "private", "gated"]).optional(),
115
+ accessList: z.array(z.string().email()).optional(),
116
+ });
117
+ const listArtifactsSchema = z.object({
118
+ limit: z.number().int().min(1).max(100).optional(),
119
+ cursor: z.string().optional(),
120
+ visibility: z.enum(["public", "unlisted", "private", "gated"]).optional(),
121
+ kind: z.enum(["markdown", "html", "image", "file", "json", "web_app"]).optional(),
103
122
  });
104
- const listArtifactsSchema = z.object({});
105
123
  const getArtifactSchema = z.object({
106
124
  artifactId: z.string().min(1),
125
+ metadataOnly: z.boolean().optional(),
107
126
  });
108
127
  const updateArtifactSchema = z.object({
109
128
  artifactId: z.string().min(1),
110
129
  title: z.string().min(1).optional(),
111
130
  markdown: z.string().min(1).optional(),
112
131
  content: z.string().min(1).optional(),
132
+ filePath: z.string().min(1).optional(),
133
+ mimeType: z.string().min(1).optional(),
113
134
  description: z.string().max(500).optional(),
114
135
  visibility: z.enum(["public", "unlisted", "private", "gated"]).optional(),
115
136
  accessList: z.array(z.string().email()).optional(),
@@ -122,6 +143,7 @@ const publishFileSchema = z.object({
122
143
  title: z.string().min(1).optional(),
123
144
  mimeType: z.string().min(1).optional(),
124
145
  visibility: z.enum(["public", "unlisted", "private", "gated"]).optional(),
146
+ accessList: z.array(z.string().email()).optional(),
125
147
  });
126
148
 
127
149
  const toolSchemas = {
@@ -137,6 +159,14 @@ const toolSchemas = {
137
159
  artifactly_publish_file: publishFileSchema,
138
160
  } as const;
139
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
+
140
170
  export function createArtifactlyMcpServer() {
141
171
  const server = new McpServer({
142
172
  name: "artifactly",
@@ -151,6 +181,7 @@ export function createArtifactlyMcpServer() {
151
181
  title: tool.name,
152
182
  description: tool.description,
153
183
  inputSchema: schema,
184
+ ...(toolAnnotations[tool.name] ? { annotations: toolAnnotations[tool.name] } : {}),
154
185
  },
155
186
  async (args: Record<string, unknown>) =>
156
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
 
@@ -174,6 +174,58 @@ describe("Artifactly MCP tools", () => {
174
174
  rmSync(dir, { recursive: true, force: true });
175
175
  });
176
176
 
177
+ it("publishes gated markdown with access emails in one call", async () => {
178
+ const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
179
+ const credentialPath = join(dir, "credentials.json");
180
+ const requests: Array<{ path: string; body: unknown; apiKey?: string }> = [];
181
+ writeCredentials(
182
+ {
183
+ apiBaseUrl: "https://artifactly.example",
184
+ apiKey: "art_t...et",
185
+ email: "agent@example.com",
186
+ workspace: "agent",
187
+ },
188
+ credentialPath,
189
+ );
190
+
191
+ await expect(
192
+ callArtifactlyMcpTool({
193
+ name: "artifactly_publish_markdown",
194
+ credentialPath,
195
+ arguments: {
196
+ title: "Gated",
197
+ markdown: "# Secret",
198
+ visibility: "gated",
199
+ accessList: ["One@Example.com", "two@example.com"],
200
+ },
201
+ postJson: async (path, body, options) => {
202
+ requests.push({ path, body, apiKey: options?.apiKey });
203
+ return {
204
+ id: "artifact_1",
205
+ accessList: ["one@example.com", "two@example.com"],
206
+ };
207
+ },
208
+ }),
209
+ ).resolves.toMatchObject({
210
+ id: "artifact_1",
211
+ accessList: ["one@example.com", "two@example.com"],
212
+ });
213
+ expect(requests).toEqual([
214
+ {
215
+ path: "/api/artifacts",
216
+ apiKey: "art_t...et",
217
+ body: {
218
+ title: "Gated",
219
+ content: "# Secret",
220
+ kind: "markdown",
221
+ visibility: "gated",
222
+ accessList: ["One@Example.com", "two@example.com"],
223
+ },
224
+ },
225
+ ]);
226
+ rmSync(dir, { recursive: true, force: true });
227
+ });
228
+
177
229
  it("preserves markdown body whitespace when publishing inline markdown", async () => {
178
230
  const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
179
231
  const credentialPath = join(dir, "credentials.json");
@@ -218,7 +270,7 @@ describe("Artifactly MCP tools", () => {
218
270
 
219
271
  const requestJson = async (method: string, path: string, body?: unknown, options?: { apiKey?: string }) => {
220
272
  requests.push({ method, path, body, apiKey: options?.apiKey });
221
- if (method === "GET" && path === "/api/artifacts") {
273
+ if (method === "GET" && path.startsWith("/api/artifacts?")) {
222
274
  return { artifacts: [{ id: "artifact_1", title: "Hello" }] };
223
275
  }
224
276
  if (method === "GET" && path === "/api/artifacts/artifact_1") {
@@ -231,7 +283,12 @@ describe("Artifactly MCP tools", () => {
231
283
  };
232
284
 
233
285
  await expect(
234
- callArtifactlyMcpTool({ name: "artifactly_list_artifacts", credentialPath, requestJson }),
286
+ callArtifactlyMcpTool({
287
+ name: "artifactly_list_artifacts",
288
+ credentialPath,
289
+ arguments: { limit: 10, visibility: "gated", kind: "html", cursor: "2026-02-01T00:00:00.000Z" },
290
+ requestJson,
291
+ }),
235
292
  ).resolves.toEqual({ artifacts: [{ id: "artifact_1", title: "Hello" }] });
236
293
  await expect(
237
294
  callArtifactlyMcpTool({
@@ -264,7 +321,12 @@ describe("Artifactly MCP tools", () => {
264
321
  ).resolves.toEqual({ ok: true, artifactId: "artifact_1" });
265
322
 
266
323
  expect(requests).toEqual([
267
- { method: "GET", path: "/api/artifacts", apiKey: "art_t...et", body: undefined },
324
+ {
325
+ method: "GET",
326
+ path: "/api/artifacts?limit=10&cursor=2026-02-01T00%3A00%3A00.000Z&visibility=gated&kind=html",
327
+ apiKey: "art_t...et",
328
+ body: undefined,
329
+ },
268
330
  { method: "GET", path: "/api/artifacts/artifact_1", apiKey: "art_t...et", body: undefined },
269
331
  {
270
332
  method: "PATCH",
@@ -417,4 +479,164 @@ describe("Artifactly MCP tools", () => {
417
479
 
418
480
  rmSync(dir, { recursive: true, force: true });
419
481
  });
482
+
483
+ it("rejects oversized files before reading them into base64 payloads", async () => {
484
+ const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
485
+ const credentialPath = join(dir, "credentials.json");
486
+ const bigPath = join(dir, "big.pdf");
487
+ writeCredentials(
488
+ {
489
+ apiBaseUrl: "https://artifactly.example",
490
+ apiKey: "art_test_secret",
491
+ email: "agent@example.com",
492
+ workspace: "agent",
493
+ },
494
+ credentialPath,
495
+ );
496
+ writeFileSync(bigPath, Buffer.alloc(10_000_001));
497
+
498
+ await expect(
499
+ callArtifactlyMcpTool({
500
+ name: "artifactly_publish_file",
501
+ credentialPath,
502
+ arguments: { path: bigPath },
503
+ }),
504
+ ).rejects.toThrow(/File is too large.*10 MB/);
505
+
506
+ rmSync(dir, { recursive: true, force: true });
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
+ });
420
642
  });
@@ -1,4 +1,4 @@
1
- import { readFileSync } from "node:fs";
1
+ import { readFileSync, statSync } from "node:fs";
2
2
  import { basename, extname } from "node:path";
3
3
 
4
4
  import {
@@ -32,6 +32,21 @@ type RequestJson = (
32
32
  options?: { apiKey?: string },
33
33
  ) => Promise<unknown>;
34
34
 
35
+ const REQUEST_TIMEOUT_MS = 30_000;
36
+ const MAX_UPLOAD_BYTES = 10_000_000;
37
+
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
+
35
50
  function createDefaultRequestJson(apiBaseUrl: string): RequestJson {
36
51
  return async (method, path, body, options) => {
37
52
  const response = await fetch(`${apiBaseUrl}${path}`, {
@@ -41,6 +56,7 @@ function createDefaultRequestJson(apiBaseUrl: string): RequestJson {
41
56
  ...(options?.apiKey ? { authorization: `Bearer ${options.apiKey}` } : {}),
42
57
  },
43
58
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
59
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
44
60
  });
45
61
  const payload = (await response.json().catch(() => ({}))) as unknown;
46
62
  if (!response.ok) {
@@ -51,7 +67,7 @@ function createDefaultRequestJson(apiBaseUrl: string): RequestJson {
51
67
  typeof payload.error === "string"
52
68
  ? payload.error
53
69
  : `Artifactly API request failed with ${response.status}`;
54
- throw new Error(message);
70
+ throw new ArtifactlyApiError(message, response.status, payload);
55
71
  }
56
72
  return payload;
57
73
  };
@@ -73,6 +89,14 @@ function optionalStringArg(
73
89
  return typeof value === "string" ? value.trim() : undefined;
74
90
  }
75
91
 
92
+ function optionalRawStringArg(
93
+ args: Record<string, unknown> | undefined,
94
+ key: string,
95
+ ): string | undefined {
96
+ const value = args?.[key];
97
+ return typeof value === "string" ? value : undefined;
98
+ }
99
+
76
100
  function requireStringArg(
77
101
  args: Record<string, unknown> | undefined,
78
102
  key: string,
@@ -122,6 +146,15 @@ function booleanArg(args: Record<string, unknown> | undefined, key: string): boo
122
146
 
123
147
  function readFileForPublish(path: string): Buffer {
124
148
  try {
149
+ const stats = statSync(path);
150
+ if (!stats.isFile()) {
151
+ throw new Error(`Path must point to a file: ${path}`);
152
+ }
153
+ if (stats.size > MAX_UPLOAD_BYTES) {
154
+ throw new Error(
155
+ `File is too large: ${path}. Maximum supported upload size is 10 MB.`,
156
+ );
157
+ }
125
158
  return readFileSync(path);
126
159
  } catch (error) {
127
160
  const code = typeof error === "object" && error && "code" in error ? String(error.code) : "";
@@ -147,6 +180,10 @@ function inferMimeType(path: string): string {
147
180
  if (extension === ".json") return "application/json";
148
181
  if (extension === ".md" || extension === ".markdown") return "text/markdown";
149
182
  if (extension === ".txt") return "text/plain";
183
+ if (extension === ".png") return "image/png";
184
+ if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg";
185
+ if (extension === ".gif") return "image/gif";
186
+ if (extension === ".webp") return "image/webp";
150
187
  return "application/octet-stream";
151
188
  }
152
189
 
@@ -155,9 +192,29 @@ function kindForMime(_path: string, mimeType: string) {
155
192
  if (normalized.includes("html")) return "html";
156
193
  if (normalized.includes("json")) return "json";
157
194
  if (normalized.includes("markdown")) return "markdown";
195
+ if (normalized.startsWith("image/")) return "image";
158
196
  return "file";
159
197
  }
160
198
 
199
+ function queryString(params: Record<string, string | number | undefined>): string {
200
+ const search = new URLSearchParams();
201
+ for (const [key, value] of Object.entries(params)) {
202
+ if (value !== undefined) {
203
+ search.set(key, String(value));
204
+ }
205
+ }
206
+ const serialized = search.toString();
207
+ return serialized ? `?${serialized}` : "";
208
+ }
209
+
210
+ function numberArg(args: Record<string, unknown> | undefined, key: string): number | undefined {
211
+ const value = args?.[key];
212
+ if (typeof value === "number" && Number.isInteger(value)) {
213
+ return value;
214
+ }
215
+ return undefined;
216
+ }
217
+
161
218
  export function listArtifactlyMcpTools(): ArtifactlyMcpTool[] {
162
219
  return [
163
220
  {
@@ -200,21 +257,31 @@ export function listArtifactlyMcpTools(): ArtifactlyMcpTool[] {
200
257
  title: { type: "string" },
201
258
  markdown: { type: "string" },
202
259
  visibility: { type: "string", enum: ["public", "unlisted", "private", "gated"] },
260
+ accessList: { type: "array", items: { type: "string" } },
203
261
  },
204
262
  required: ["title", "markdown"],
205
263
  },
206
264
  },
207
265
  {
208
266
  name: "artifactly_list_artifacts",
209
- description: "List recent Artifactly artifacts when the list API is available.",
210
- inputSchema: { type: "object", properties: {} },
267
+ description:
268
+ "List recent Artifactly artifacts without large content bodies. Supports limit (1-100), cursor, visibility, and kind filters.",
269
+ inputSchema: {
270
+ type: "object",
271
+ properties: {
272
+ limit: { type: "number", minimum: 1, maximum: 100 },
273
+ cursor: { type: "string" },
274
+ visibility: { type: "string", enum: ["public", "unlisted", "private", "gated"] },
275
+ kind: { type: "string", enum: ["markdown", "html", "image", "file", "json", "web_app"] },
276
+ },
277
+ },
211
278
  },
212
279
  {
213
280
  name: "artifactly_get_artifact",
214
- description: "Get Artifactly artifact metadata when the lookup API is available.",
281
+ description: "Get an Artifactly artifact. Returns full content by default, including base64/data URL file payloads; pass metadataOnly: true to omit content and storageUrl.",
215
282
  inputSchema: {
216
283
  type: "object",
217
- properties: { artifactId: { type: "string" } },
284
+ properties: { artifactId: { type: "string" }, metadataOnly: { type: "boolean" } },
218
285
  required: ["artifactId"],
219
286
  },
220
287
  },
@@ -229,6 +296,8 @@ export function listArtifactlyMcpTools(): ArtifactlyMcpTool[] {
229
296
  title: { type: "string" },
230
297
  markdown: { type: "string" },
231
298
  content: { type: "string" },
299
+ filePath: { type: "string" },
300
+ mimeType: { type: "string" },
232
301
  description: { type: "string" },
233
302
  visibility: { type: "string", enum: ["public", "unlisted", "private", "gated"] },
234
303
  accessList: { type: "array", items: { type: "string" } },
@@ -248,7 +317,7 @@ export function listArtifactlyMcpTools(): ArtifactlyMcpTool[] {
248
317
  {
249
318
  name: "artifactly_publish_file",
250
319
  description:
251
- "Publish a local PDF, HTML, JSON, Markdown, or other file through the Artifactly API. Defaults to unlisted visibility; explicit mimeType takes precedence over file extension.",
320
+ "Publish a local PDF, HTML, JSON, Markdown, or other file through the Artifactly API. Defaults to unlisted visibility; explicit mimeType takes precedence over file extension. Files larger than 10 MB are rejected before upload.",
252
321
  inputSchema: {
253
322
  type: "object",
254
323
  properties: {
@@ -256,6 +325,7 @@ export function listArtifactlyMcpTools(): ArtifactlyMcpTool[] {
256
325
  title: { type: "string" },
257
326
  mimeType: { type: "string" },
258
327
  visibility: { type: "string", enum: ["public", "unlisted", "private", "gated"] },
328
+ accessList: { type: "array", items: { type: "string" } },
259
329
  },
260
330
  required: ["path"],
261
331
  },
@@ -344,10 +414,17 @@ export async function callArtifactlyMcpTool(input: CallToolInput) {
344
414
  const title = requireStringArg(input.arguments, "title");
345
415
  const content = requireRawStringArg(input.arguments, "markdown");
346
416
  const visibility = stringArg(input.arguments, "visibility") ?? "unlisted";
417
+ const accessList = stringArrayArg(input.arguments, "accessList");
347
418
  return objectResult(
348
419
  await postJson(
349
420
  "/api/artifacts",
350
- { title, content, kind: "markdown", visibility },
421
+ {
422
+ title,
423
+ content,
424
+ kind: "markdown",
425
+ visibility,
426
+ ...(accessList ? { accessList } : {}),
427
+ },
351
428
  { apiKey: credentials.apiKey },
352
429
  ),
353
430
  );
@@ -357,7 +434,12 @@ export async function callArtifactlyMcpTool(input: CallToolInput) {
357
434
  if (!credentials) {
358
435
  throw new Error(LOGIN_GUIDANCE);
359
436
  }
360
- return objectResult(await requestJson("GET", "/api/artifacts", undefined, { apiKey: credentials.apiKey }));
437
+ const limit = numberArg(input.arguments, "limit");
438
+ const cursor = stringArg(input.arguments, "cursor");
439
+ const visibility = stringArg(input.arguments, "visibility");
440
+ const kind = stringArg(input.arguments, "kind");
441
+ const query = queryString({ limit, cursor, visibility, kind });
442
+ return objectResult(await requestJson("GET", `/api/artifacts${query}`, undefined, { apiKey: credentials.apiKey }));
361
443
  }
362
444
 
363
445
  if (input.name === "artifactly_get_artifact") {
@@ -365,7 +447,15 @@ export async function callArtifactlyMcpTool(input: CallToolInput) {
365
447
  throw new Error(LOGIN_GUIDANCE);
366
448
  }
367
449
  const artifactId = requireStringArg(input.arguments, "artifactId");
368
- return objectResult(await requestJson("GET", `/api/artifacts/${artifactId}`, undefined, { apiKey: credentials.apiKey }));
450
+ const metadataOnly = booleanArg(input.arguments, "metadataOnly");
451
+ const result = objectResult(await requestJson("GET", `/api/artifacts/${artifactId}`, undefined, { apiKey: credentials.apiKey }));
452
+ if (metadataOnly && result.artifact && typeof result.artifact === "object" && !Array.isArray(result.artifact)) {
453
+ const { content: _content, storageUrl: _storageUrl, ...metadata } = result.artifact as Record<string, unknown>;
454
+ void _content;
455
+ void _storageUrl;
456
+ return { ...result, artifact: metadata };
457
+ }
458
+ return result;
369
459
  }
370
460
 
371
461
  if (input.name === "artifactly_update_artifact") {
@@ -374,22 +464,41 @@ export async function callArtifactlyMcpTool(input: CallToolInput) {
374
464
  }
375
465
  const artifactId = requireStringArg(input.arguments, "artifactId");
376
466
  const title = stringArg(input.arguments, "title");
377
- const markdown = optionalStringArg(input.arguments, "markdown");
378
- const content = optionalStringArg(input.arguments, "content");
467
+ const markdown = optionalRawStringArg(input.arguments, "markdown");
468
+ const content = optionalRawStringArg(input.arguments, "content");
469
+ const filePath = stringArg(input.arguments, "filePath");
379
470
  if (markdown !== undefined && content !== undefined) {
380
471
  throw new Error("Use either markdown or content, not both.");
381
472
  }
473
+ if ((markdown !== undefined || content !== undefined) && filePath) {
474
+ throw new Error("Use either filePath or text content, not both.");
475
+ }
476
+ const textContent = markdown ?? content;
477
+ if (textContent !== undefined && !textContent.trim()) {
478
+ throw new Error("Content cannot be whitespace-only.");
479
+ }
382
480
  const description = optionalStringArg(input.arguments, "description");
383
481
  const visibility = stringArg(input.arguments, "visibility");
384
482
  const accessList = stringArrayArg(input.arguments, "accessList");
483
+ let replacement: Record<string, unknown> = {};
484
+ if (filePath) {
485
+ const mimeType = stringArg(input.arguments, "mimeType") ?? inferMimeType(filePath);
486
+ const kind = kindForMime(filePath, mimeType);
487
+ const file = readFileForPublish(filePath);
488
+ replacement = {
489
+ kind,
490
+ storageUrl: `data:${mimeType};base64,${file.toString("base64")}`,
491
+ };
492
+ }
385
493
  return objectResult(
386
494
  await requestJson(
387
495
  "PATCH",
388
496
  `/api/artifacts/${artifactId}`,
389
497
  {
390
498
  ...(title ? { title } : {}),
391
- ...(markdown ? { markdown } : {}),
392
- ...(content ? { content } : {}),
499
+ ...(markdown !== undefined ? { markdown } : {}),
500
+ ...(content !== undefined ? { content } : {}),
501
+ ...replacement,
393
502
  ...(description !== undefined ? { description } : {}),
394
503
  ...(visibility ? { visibility } : {}),
395
504
  ...(accessList ? { accessList } : {}),
@@ -415,21 +524,25 @@ export async function callArtifactlyMcpTool(input: CallToolInput) {
415
524
  const title = stringArg(input.arguments, "title") ?? titleFromPath(path);
416
525
  const mimeType = stringArg(input.arguments, "mimeType") ?? inferMimeType(path);
417
526
  const visibility = stringArg(input.arguments, "visibility") ?? "unlisted";
527
+ const accessList = stringArrayArg(input.arguments, "accessList");
418
528
  const kind = kindForMime(path, mimeType);
419
529
  const file = readFileForPublish(path);
530
+ const fileLike = kind === "file" || kind === "image";
420
531
  const payload =
421
- kind === "file"
532
+ fileLike
422
533
  ? {
423
534
  title,
424
535
  kind,
425
536
  storageUrl: `data:${mimeType};base64,${file.toString("base64")}`,
426
537
  visibility,
538
+ ...(accessList ? { accessList } : {}),
427
539
  }
428
540
  : {
429
541
  title,
430
542
  kind,
431
543
  content: file.toString("utf8"),
432
544
  visibility,
545
+ ...(accessList ? { accessList } : {}),
433
546
  };
434
547
  return objectResult(
435
548
  await postJson("/api/artifacts", payload, { apiKey: credentials.apiKey }),