@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 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 owner-visible artifact details.
70
- - `artifactly_update_artifact` accepts either `markdown` or `content`, not both; pass `description: ""` to clear a description.
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@artifactly/mcp",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "private": false,
5
5
  "files": [
6
6
  "bin",
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdtempSync, rmSync } from "node:fs";
1
+ import { chmodSync, existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
 
@@ -9,6 +9,7 @@ import {
9
9
  createDefaultPostJson,
10
10
  getDefaultCredentialPath,
11
11
  readCredentials,
12
+ writeCredentials,
12
13
  } from "./artifactly-cli";
13
14
 
14
15
  describe("artifactly CLI", () => {
@@ -54,6 +55,49 @@ describe("artifactly CLI", () => {
54
55
  rmSync(dir, { recursive: true, force: true });
55
56
  });
56
57
 
58
+ it("tightens existing credential files to 0600 on every write", () => {
59
+ const dir = mkdtempSync(join(tmpdir(), "artifactly-cli-"));
60
+ const credentialPath = join(dir, "credentials.json");
61
+ writeCredentials(
62
+ {
63
+ apiBaseUrl: "https://artifactly.example",
64
+ apiKey: "art_old_secret",
65
+ email: "agent@example.com",
66
+ },
67
+ credentialPath,
68
+ );
69
+ chmodSync(credentialPath, 0o644);
70
+
71
+ writeCredentials(
72
+ {
73
+ apiBaseUrl: "https://artifactly.example",
74
+ apiKey: "art_new_secret",
75
+ email: "agent@example.com",
76
+ },
77
+ credentialPath,
78
+ );
79
+
80
+ expect(statSync(credentialPath).mode & 0o777).toBe(0o600);
81
+ rmSync(dir, { recursive: true, force: true });
82
+ });
83
+
84
+ it("preserves HTTP status and details for failed default POST requests", async () => {
85
+ const postJson = createDefaultPostJson("https://artifactly.example", {
86
+ fetcher: async () =>
87
+ new Response(JSON.stringify({ error: "Invalid login code", retryAfterSeconds: 30 }), {
88
+ status: 429,
89
+ headers: { "content-type": "application/json" },
90
+ }),
91
+ });
92
+
93
+ await expect(postJson("/api/auth/verify-code", { code: "bad" })).rejects.toMatchObject({
94
+ name: "ArtifactlyApiError",
95
+ message: "Invalid login code",
96
+ status: 429,
97
+ details: { error: "Invalid login code", retryAfterSeconds: 30 },
98
+ });
99
+ });
100
+
57
101
  it("publishes markdown using stored credentials", async () => {
58
102
  const dir = mkdtempSync(join(tmpdir(), "artifactly-cli-"));
59
103
  const credentialPath = join(dir, "credentials.json");
@@ -1,4 +1,4 @@
1
- import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import { createInterface } from "node:readline/promises";
@@ -19,6 +19,18 @@ export type PostJson = (
19
19
 
20
20
  type Fetcher = typeof fetch;
21
21
 
22
+ export class ArtifactlyApiError extends Error {
23
+ status: number;
24
+ details: unknown;
25
+
26
+ constructor(message: string, status: number, details: unknown) {
27
+ super(message);
28
+ this.name = "ArtifactlyApiError";
29
+ this.status = status;
30
+ this.details = details;
31
+ }
32
+ }
33
+
22
34
  type DefaultPostJsonOptions = {
23
35
  timeoutMs?: number;
24
36
  fetcher?: Fetcher;
@@ -101,6 +113,7 @@ export function writeCredentials(
101
113
  ) {
102
114
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
103
115
  writeFileSync(path, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
116
+ chmodSync(path, 0o600);
104
117
  }
105
118
 
106
119
  export function clearCredentials(path = getDefaultCredentialPath()) {
@@ -141,7 +154,7 @@ export function createDefaultPostJson(
141
154
  typeof payload.error === "string"
142
155
  ? payload.error
143
156
  : `Artifactly API request failed with ${response.status}`;
144
- throw new Error(message);
157
+ throw new ArtifactlyApiError(message, response.status, payload);
145
158
  }
146
159
  return payload;
147
160
  };
@@ -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: "art_test_secret",
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 === "/api/artifacts/artifact_1") {
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: "art_t...et", body: undefined },
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: "art_t...et",
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: "art_t...et", body: undefined },
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: "art_t...et",
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 Error(message);
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 metadata when the lookup API is available.",
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
- return objectResult(await requestJson("GET", `/api/artifacts/${artifactId}`, undefined, { apiKey: credentials.apiKey }));
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 = optionalStringArg(input.arguments, "markdown");
429
- const content = optionalStringArg(input.arguments, "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
- kind === "file"
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 }),