@artifactly/mcp 0.1.7 → 0.1.9

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,43 @@ 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 owner-visible artifact details.
70
+ - `artifactly_update_artifact` accepts either `markdown` or `content`, not both; pass `description: ""` to clear a description.
71
+ - `artifactly_publish_file` infers MIME type from file extension unless `mimeType` is supplied; explicit `mimeType` wins.
72
+ - Files larger than 10 MB are rejected before upload to avoid large in-memory base64 payloads.
73
+ - Requests time out after 30 seconds.
74
+
38
75
  Manual publish from a logged-in maintainer account:
39
76
 
40
77
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@artifactly/mcp",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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;
@@ -57,9 +64,32 @@ export function getDefaultCredentialPath(home = homedir()) {
57
64
  return join(home, ".artifactly", "credentials.json");
58
65
  }
59
66
 
67
+ function normalizeCredentials(value: unknown): ArtifactlyCredentials | null {
68
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
69
+ return null;
70
+ }
71
+ const candidate = value as Record<string, unknown>;
72
+ if (typeof candidate.apiBaseUrl !== "string" || !candidate.apiBaseUrl.trim()) {
73
+ return null;
74
+ }
75
+ if (typeof candidate.apiKey !== "string" || !candidate.apiKey.trim()) {
76
+ return null;
77
+ }
78
+ return {
79
+ apiBaseUrl: candidate.apiBaseUrl.trim(),
80
+ apiKey: candidate.apiKey.trim(),
81
+ ...(typeof candidate.email === "string" && candidate.email.trim()
82
+ ? { email: candidate.email.trim() }
83
+ : {}),
84
+ ...(typeof candidate.workspace === "string" && candidate.workspace.trim()
85
+ ? { workspace: candidate.workspace.trim() }
86
+ : {}),
87
+ };
88
+ }
89
+
60
90
  export function readCredentials(path = getDefaultCredentialPath()): ArtifactlyCredentials | null {
61
91
  try {
62
- return JSON.parse(readFileSync(path, "utf8")) as ArtifactlyCredentials;
92
+ return normalizeCredentials(JSON.parse(readFileSync(path, "utf8")));
63
93
  } catch {
64
94
  return null;
65
95
  }
@@ -86,15 +116,21 @@ async function defaultPrompt(question: string) {
86
116
  }
87
117
  }
88
118
 
89
- 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;
90
125
  return async (path, body, options) => {
91
- const response = await fetch(`${apiBaseUrl}${path}`, {
126
+ const response = await fetcher(`${apiBaseUrl}${path}`, {
92
127
  method: "POST",
93
128
  headers: {
94
129
  "content-type": "application/json",
95
130
  ...(options?.apiKey ? { authorization: `Bearer ${options.apiKey}` } : {}),
96
131
  },
97
132
  body: JSON.stringify(body),
133
+ signal: AbortSignal.timeout(timeoutMs),
98
134
  });
99
135
  const payload = (await response.json().catch(() => ({}))) as unknown;
100
136
  if (!response.ok) {
@@ -0,0 +1,18 @@
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
+ });
18
+ });
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
2
2
 
3
3
  import packageJson from "../../package.json";
4
4
  import {
5
+ createArtifactlyMcpServer,
5
6
  createArtifactlyMcpCallResult,
6
7
  getArtifactlyMcpInstallConfig,
7
8
  listArtifactlyMcpServerTools,
@@ -37,6 +38,41 @@ describe("Artifactly MCP server packaging", () => {
37
38
  });
38
39
 
39
40
  describe("Artifactly MCP stdio server helpers", () => {
41
+ it("registers tool schemas and server info from package metadata", () => {
42
+ const server = createArtifactlyMcpServer() as unknown as {
43
+ server: { _serverInfo: { name: string; version: string } };
44
+ _registeredTools: Record<string, { inputSchema: { def: { shape: Record<string, unknown> }; safeParse(value: unknown): { success: boolean } } }>;
45
+ };
46
+
47
+ expect(server.server._serverInfo).toEqual({
48
+ name: "artifactly",
49
+ version: packageJson.version,
50
+ });
51
+ expect(Object.keys(server._registeredTools.artifactly_auth_status.inputSchema.def.shape)).toContain("validate");
52
+ expect(
53
+ server._registeredTools.artifactly_update_artifact.inputSchema.safeParse({
54
+ artifactId: "artifact_1",
55
+ description: "",
56
+ }).success,
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
+ });
75
+
40
76
  it("maps Artifactly tools to MCP-compatible tool definitions", () => {
41
77
  expect(listArtifactlyMcpServerTools().map((tool) => tool.name)).toEqual([
42
78
  "artifactly_auth_status",
@@ -2,6 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
3
  import { z } from "zod";
4
4
 
5
+ import packageJson from "../../package.json";
5
6
  import { callArtifactlyMcpTool, listArtifactlyMcpTools } from "./artifactly-mcp.js";
6
7
 
7
8
  export type ArtifactlyMcpInstallConfig = {
@@ -83,7 +84,9 @@ export async function createArtifactlyMcpCallResult(input: {
83
84
  }
84
85
  }
85
86
 
86
- const authStatusSchema = z.object({});
87
+ const authStatusSchema = z.object({
88
+ validate: z.boolean().optional(),
89
+ });
87
90
  const requestLoginCodeSchema = z.object({
88
91
  email: z.string().email(),
89
92
  name: z.string().optional(),
@@ -97,8 +100,14 @@ const publishMarkdownSchema = z.object({
97
100
  title: z.string().min(1),
98
101
  markdown: z.string().min(1),
99
102
  visibility: z.enum(["public", "unlisted", "private", "gated"]).optional(),
103
+ accessList: z.array(z.string().email()).optional(),
104
+ });
105
+ const listArtifactsSchema = z.object({
106
+ limit: z.number().int().min(1).max(100).optional(),
107
+ cursor: z.string().optional(),
108
+ visibility: z.enum(["public", "unlisted", "private", "gated"]).optional(),
109
+ kind: z.enum(["markdown", "html", "image", "file", "json", "web_app"]).optional(),
100
110
  });
101
- const listArtifactsSchema = z.object({});
102
111
  const getArtifactSchema = z.object({
103
112
  artifactId: z.string().min(1),
104
113
  });
@@ -107,7 +116,7 @@ const updateArtifactSchema = z.object({
107
116
  title: z.string().min(1).optional(),
108
117
  markdown: z.string().min(1).optional(),
109
118
  content: z.string().min(1).optional(),
110
- description: z.string().min(1).optional(),
119
+ description: z.string().max(500).optional(),
111
120
  visibility: z.enum(["public", "unlisted", "private", "gated"]).optional(),
112
121
  accessList: z.array(z.string().email()).optional(),
113
122
  });
@@ -137,7 +146,7 @@ const toolSchemas = {
137
146
  export function createArtifactlyMcpServer() {
138
147
  const server = new McpServer({
139
148
  name: "artifactly",
140
- version: "0.1.1",
149
+ version: packageJson.version,
141
150
  });
142
151
 
143
152
  for (const tool of listArtifactlyMcpTools()) {
@@ -48,6 +48,21 @@ describe("Artifactly MCP tools", () => {
48
48
  rmSync(dir, { recursive: true, force: true });
49
49
  });
50
50
 
51
+ it("does not report malformed credential files as authenticated", async () => {
52
+ const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
53
+ const credentialPath = join(dir, "credentials.json");
54
+ writeFileSync(credentialPath, "{}\n");
55
+
56
+ await expect(
57
+ callArtifactlyMcpTool({ name: "artifactly_auth_status", credentialPath }),
58
+ ).resolves.toEqual({ authenticated: false, apiBaseUrl: "https://artifactly.io" });
59
+ await expect(
60
+ callArtifactlyMcpTool({ name: "artifactly_list_artifacts", credentialPath }),
61
+ ).rejects.toThrow(/artifactly_request_login_code[\s\S]*artifactly_verify_login_code/);
62
+
63
+ rmSync(dir, { recursive: true, force: true });
64
+ });
65
+
51
66
  it("can validate auth status against the Artifactly API", async () => {
52
67
  const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
53
68
  const credentialPath = join(dir, "credentials.json");
@@ -159,6 +174,86 @@ describe("Artifactly MCP tools", () => {
159
174
  rmSync(dir, { recursive: true, force: true });
160
175
  });
161
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
+
229
+ it("preserves markdown body whitespace when publishing inline markdown", async () => {
230
+ const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
231
+ const credentialPath = join(dir, "credentials.json");
232
+ let publishedBody: unknown;
233
+ writeCredentials(
234
+ {
235
+ apiBaseUrl: "https://artifactly.example",
236
+ apiKey: "art_t...et",
237
+ email: "agent@example.com",
238
+ workspace: "agent",
239
+ },
240
+ credentialPath,
241
+ );
242
+
243
+ await callArtifactlyMcpTool({
244
+ name: "artifactly_publish_markdown",
245
+ credentialPath,
246
+ arguments: { title: "Whitespace", markdown: " # Hello\n\n" },
247
+ postJson: async (_path, body) => {
248
+ publishedBody = body;
249
+ return { id: "artifact_1", url: "https://artifactly.example/share/agent/hello" };
250
+ },
251
+ });
252
+
253
+ expect(publishedBody).toMatchObject({ content: " # Hello\n\n" });
254
+ rmSync(dir, { recursive: true, force: true });
255
+ });
256
+
162
257
  it("lists, gets, updates, and deletes artifacts through authenticated API requests", async () => {
163
258
  const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
164
259
  const credentialPath = join(dir, "credentials.json");
@@ -175,7 +270,7 @@ describe("Artifactly MCP tools", () => {
175
270
 
176
271
  const requestJson = async (method: string, path: string, body?: unknown, options?: { apiKey?: string }) => {
177
272
  requests.push({ method, path, body, apiKey: options?.apiKey });
178
- if (method === "GET" && path === "/api/artifacts") {
273
+ if (method === "GET" && path.startsWith("/api/artifacts?")) {
179
274
  return { artifacts: [{ id: "artifact_1", title: "Hello" }] };
180
275
  }
181
276
  if (method === "GET" && path === "/api/artifacts/artifact_1") {
@@ -188,7 +283,12 @@ describe("Artifactly MCP tools", () => {
188
283
  };
189
284
 
190
285
  await expect(
191
- 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
+ }),
192
292
  ).resolves.toEqual({ artifacts: [{ id: "artifact_1", title: "Hello" }] });
193
293
  await expect(
194
294
  callArtifactlyMcpTool({
@@ -221,7 +321,12 @@ describe("Artifactly MCP tools", () => {
221
321
  ).resolves.toEqual({ ok: true, artifactId: "artifact_1" });
222
322
 
223
323
  expect(requests).toEqual([
224
- { 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
+ },
225
330
  { method: "GET", path: "/api/artifacts/artifact_1", apiKey: "art_t...et", body: undefined },
226
331
  {
227
332
  method: "PATCH",
@@ -234,6 +339,29 @@ describe("Artifactly MCP tools", () => {
234
339
  rmSync(dir, { recursive: true, force: true });
235
340
  });
236
341
 
342
+ it("rejects conflicting markdown and content update arguments with a specific error", async () => {
343
+ const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
344
+ const credentialPath = join(dir, "credentials.json");
345
+ writeCredentials(
346
+ {
347
+ apiBaseUrl: "https://artifactly.example",
348
+ apiKey: "art_t...et",
349
+ email: "agent@example.com",
350
+ workspace: "agent",
351
+ },
352
+ credentialPath,
353
+ );
354
+
355
+ await expect(
356
+ callArtifactlyMcpTool({
357
+ name: "artifactly_update_artifact",
358
+ credentialPath,
359
+ arguments: { artifactId: "artifact_1", markdown: "# Markdown", content: "Plain" },
360
+ }),
361
+ ).rejects.toThrow(/Use either markdown or content, not both/);
362
+ rmSync(dir, { recursive: true, force: true });
363
+ });
364
+
237
365
  it("publishes local HTML and PDF files through the artifact API", async () => {
238
366
  const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
239
367
  const credentialPath = join(dir, "credentials.json");
@@ -295,6 +423,39 @@ describe("Artifactly MCP tools", () => {
295
423
  rmSync(dir, { recursive: true, force: true });
296
424
  });
297
425
 
426
+ it("uses explicit MIME type instead of extension when publishing files", async () => {
427
+ const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
428
+ const credentialPath = join(dir, "credentials.json");
429
+ const markdownPath = join(dir, "report.md");
430
+ let publishedBody: unknown;
431
+ writeFileSync(markdownPath, "# Report");
432
+ writeCredentials(
433
+ {
434
+ apiBaseUrl: "https://artifactly.example",
435
+ apiKey: "art_t...et",
436
+ email: "agent@example.com",
437
+ workspace: "agent",
438
+ },
439
+ credentialPath,
440
+ );
441
+
442
+ await callArtifactlyMcpTool({
443
+ name: "artifactly_publish_file",
444
+ credentialPath,
445
+ arguments: { path: markdownPath, mimeType: "application/pdf" },
446
+ postJson: async (_path, body) => {
447
+ publishedBody = body;
448
+ return { id: "artifact_1", url: "https://artifactly.example/share/agent/report" };
449
+ },
450
+ });
451
+
452
+ expect(publishedBody).toMatchObject({
453
+ kind: "file",
454
+ storageUrl: "data:application/pdf;base64,IyBSZXBvcnQ=",
455
+ });
456
+ rmSync(dir, { recursive: true, force: true });
457
+ });
458
+
298
459
  it("returns a friendly error when publishing a missing file", async () => {
299
460
  const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
300
461
  const credentialPath = join(dir, "credentials.json");
@@ -318,4 +479,30 @@ describe("Artifactly MCP tools", () => {
318
479
 
319
480
  rmSync(dir, { recursive: true, force: true });
320
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_t...et",
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
+ });
321
508
  });
@@ -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,9 @@ 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
+
35
38
  function createDefaultRequestJson(apiBaseUrl: string): RequestJson {
36
39
  return async (method, path, body, options) => {
37
40
  const response = await fetch(`${apiBaseUrl}${path}`, {
@@ -41,6 +44,7 @@ function createDefaultRequestJson(apiBaseUrl: string): RequestJson {
41
44
  ...(options?.apiKey ? { authorization: `Bearer ${options.apiKey}` } : {}),
42
45
  },
43
46
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
47
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
44
48
  });
45
49
  const payload = (await response.json().catch(() => ({}))) as unknown;
46
50
  if (!response.ok) {
@@ -84,6 +88,17 @@ function requireStringArg(
84
88
  return value;
85
89
  }
86
90
 
91
+ function requireRawStringArg(
92
+ args: Record<string, unknown> | undefined,
93
+ key: string,
94
+ ): string {
95
+ const value = args?.[key];
96
+ if (typeof value !== "string" || !value.trim()) {
97
+ throw new Error(`Missing required argument: ${key}`);
98
+ }
99
+ return value;
100
+ }
101
+
87
102
  function stringArrayArg(
88
103
  args: Record<string, unknown> | undefined,
89
104
  key: string,
@@ -111,6 +126,12 @@ function booleanArg(args: Record<string, unknown> | undefined, key: string): boo
111
126
 
112
127
  function readFileForPublish(path: string): Buffer {
113
128
  try {
129
+ const stats = statSync(path);
130
+ if (stats.size > MAX_UPLOAD_BYTES) {
131
+ throw new Error(
132
+ `File is too large: ${path}. Maximum supported upload size is 10 MB.`,
133
+ );
134
+ }
114
135
  return readFileSync(path);
115
136
  } catch (error) {
116
137
  const code = typeof error === "object" && error && "code" in error ? String(error.code) : "";
@@ -139,20 +160,39 @@ function inferMimeType(path: string): string {
139
160
  return "application/octet-stream";
140
161
  }
141
162
 
142
- function kindForMime(path: string, mimeType: string) {
143
- const extension = extname(path).toLowerCase();
163
+ function kindForMime(_path: string, mimeType: string) {
144
164
  const normalized = mimeType.toLowerCase();
145
- if (normalized.includes("html") || extension === ".html" || extension === ".htm") return "html";
146
- if (normalized.includes("json") || extension === ".json") return "json";
147
- if (normalized.includes("markdown") || extension === ".md" || extension === ".markdown") return "markdown";
165
+ if (normalized.includes("html")) return "html";
166
+ if (normalized.includes("json")) return "json";
167
+ if (normalized.includes("markdown")) return "markdown";
148
168
  return "file";
149
169
  }
150
170
 
171
+ function queryString(params: Record<string, string | number | undefined>): string {
172
+ const search = new URLSearchParams();
173
+ for (const [key, value] of Object.entries(params)) {
174
+ if (value !== undefined) {
175
+ search.set(key, String(value));
176
+ }
177
+ }
178
+ const serialized = search.toString();
179
+ return serialized ? `?${serialized}` : "";
180
+ }
181
+
182
+ function numberArg(args: Record<string, unknown> | undefined, key: string): number | undefined {
183
+ const value = args?.[key];
184
+ if (typeof value === "number" && Number.isInteger(value)) {
185
+ return value;
186
+ }
187
+ return undefined;
188
+ }
189
+
151
190
  export function listArtifactlyMcpTools(): ArtifactlyMcpTool[] {
152
191
  return [
153
192
  {
154
193
  name: "artifactly_auth_status",
155
- description: "Check whether local Artifactly credentials are configured.",
194
+ description:
195
+ "Check local Artifactly credentials. Pass validate: true to confirm the saved API key with the Artifactly server.",
156
196
  inputSchema: { type: "object", properties: { validate: { type: "boolean" } } },
157
197
  },
158
198
  {
@@ -181,21 +221,32 @@ export function listArtifactlyMcpTools(): ArtifactlyMcpTool[] {
181
221
  },
182
222
  {
183
223
  name: "artifactly_publish_markdown",
184
- description: "Publish markdown through the Artifactly API using stored credentials.",
224
+ description:
225
+ "Publish markdown through the Artifactly API using stored credentials. Defaults to unlisted visibility; visibility can be public, unlisted, private, or gated.",
185
226
  inputSchema: {
186
227
  type: "object",
187
228
  properties: {
188
229
  title: { type: "string" },
189
230
  markdown: { type: "string" },
190
231
  visibility: { type: "string", enum: ["public", "unlisted", "private", "gated"] },
232
+ accessList: { type: "array", items: { type: "string" } },
191
233
  },
192
234
  required: ["title", "markdown"],
193
235
  },
194
236
  },
195
237
  {
196
238
  name: "artifactly_list_artifacts",
197
- description: "List recent Artifactly artifacts when the list API is available.",
198
- inputSchema: { type: "object", properties: {} },
239
+ description:
240
+ "List recent Artifactly artifacts without large content bodies. Supports limit (1-100), cursor, visibility, and kind filters.",
241
+ inputSchema: {
242
+ type: "object",
243
+ properties: {
244
+ limit: { type: "number", minimum: 1, maximum: 100 },
245
+ cursor: { type: "string" },
246
+ visibility: { type: "string", enum: ["public", "unlisted", "private", "gated"] },
247
+ kind: { type: "string", enum: ["markdown", "html", "image", "file", "json", "web_app"] },
248
+ },
249
+ },
199
250
  },
200
251
  {
201
252
  name: "artifactly_get_artifact",
@@ -208,7 +259,8 @@ export function listArtifactlyMcpTools(): ArtifactlyMcpTool[] {
208
259
  },
209
260
  {
210
261
  name: "artifactly_update_artifact",
211
- description: "Update an existing Artifactly artifact, including visibility and gated access emails.",
262
+ description:
263
+ "Update an existing Artifactly artifact, including visibility and gated access emails. Use markdown or content, not both; description may be an empty string to clear it.",
212
264
  inputSchema: {
213
265
  type: "object",
214
266
  properties: {
@@ -234,7 +286,8 @@ export function listArtifactlyMcpTools(): ArtifactlyMcpTool[] {
234
286
  },
235
287
  {
236
288
  name: "artifactly_publish_file",
237
- description: "Publish a local PDF, HTML, JSON, Markdown, or other file through the Artifactly API.",
289
+ description:
290
+ "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.",
238
291
  inputSchema: {
239
292
  type: "object",
240
293
  properties: {
@@ -328,12 +381,19 @@ export async function callArtifactlyMcpTool(input: CallToolInput) {
328
381
  throw new Error(LOGIN_GUIDANCE);
329
382
  }
330
383
  const title = requireStringArg(input.arguments, "title");
331
- const content = requireStringArg(input.arguments, "markdown");
384
+ const content = requireRawStringArg(input.arguments, "markdown");
332
385
  const visibility = stringArg(input.arguments, "visibility") ?? "unlisted";
386
+ const accessList = stringArrayArg(input.arguments, "accessList");
333
387
  return objectResult(
334
388
  await postJson(
335
389
  "/api/artifacts",
336
- { title, content, kind: "markdown", visibility },
390
+ {
391
+ title,
392
+ content,
393
+ kind: "markdown",
394
+ visibility,
395
+ ...(accessList ? { accessList } : {}),
396
+ },
337
397
  { apiKey: credentials.apiKey },
338
398
  ),
339
399
  );
@@ -343,7 +403,12 @@ export async function callArtifactlyMcpTool(input: CallToolInput) {
343
403
  if (!credentials) {
344
404
  throw new Error(LOGIN_GUIDANCE);
345
405
  }
346
- return objectResult(await requestJson("GET", "/api/artifacts", undefined, { apiKey: credentials.apiKey }));
406
+ const limit = numberArg(input.arguments, "limit");
407
+ const cursor = stringArg(input.arguments, "cursor");
408
+ const visibility = stringArg(input.arguments, "visibility");
409
+ const kind = stringArg(input.arguments, "kind");
410
+ const query = queryString({ limit, cursor, visibility, kind });
411
+ return objectResult(await requestJson("GET", `/api/artifacts${query}`, undefined, { apiKey: credentials.apiKey }));
347
412
  }
348
413
 
349
414
  if (input.name === "artifactly_get_artifact") {
@@ -360,8 +425,11 @@ export async function callArtifactlyMcpTool(input: CallToolInput) {
360
425
  }
361
426
  const artifactId = requireStringArg(input.arguments, "artifactId");
362
427
  const title = stringArg(input.arguments, "title");
363
- const markdown = stringArg(input.arguments, "markdown");
364
- const content = stringArg(input.arguments, "content");
428
+ const markdown = optionalStringArg(input.arguments, "markdown");
429
+ const content = optionalStringArg(input.arguments, "content");
430
+ if (markdown !== undefined && content !== undefined) {
431
+ throw new Error("Use either markdown or content, not both.");
432
+ }
365
433
  const description = optionalStringArg(input.arguments, "description");
366
434
  const visibility = stringArg(input.arguments, "visibility");
367
435
  const accessList = stringArrayArg(input.arguments, "accessList");