@artifactly/mcp 0.1.8 → 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.8",
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;
@@ -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,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
+ });
@@ -55,6 +55,22 @@ 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);
58
74
  });
59
75
 
60
76
  it("maps Artifactly tools to MCP-compatible tool definitions", () => {
@@ -100,8 +100,14 @@ const publishMarkdownSchema = z.object({
100
100
  title: z.string().min(1),
101
101
  markdown: z.string().min(1),
102
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(),
103
110
  });
104
- const listArtifactsSchema = z.object({});
105
111
  const getArtifactSchema = z.object({
106
112
  artifactId: z.string().min(1),
107
113
  });
@@ -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,30 @@ 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_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
+ });
420
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) {
@@ -122,6 +126,12 @@ function booleanArg(args: Record<string, unknown> | undefined, key: string): boo
122
126
 
123
127
  function readFileForPublish(path: string): Buffer {
124
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
+ }
125
135
  return readFileSync(path);
126
136
  } catch (error) {
127
137
  const code = typeof error === "object" && error && "code" in error ? String(error.code) : "";
@@ -158,6 +168,25 @@ function kindForMime(_path: string, mimeType: string) {
158
168
  return "file";
159
169
  }
160
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
+
161
190
  export function listArtifactlyMcpTools(): ArtifactlyMcpTool[] {
162
191
  return [
163
192
  {
@@ -200,14 +229,24 @@ export function listArtifactlyMcpTools(): ArtifactlyMcpTool[] {
200
229
  title: { type: "string" },
201
230
  markdown: { type: "string" },
202
231
  visibility: { type: "string", enum: ["public", "unlisted", "private", "gated"] },
232
+ accessList: { type: "array", items: { type: "string" } },
203
233
  },
204
234
  required: ["title", "markdown"],
205
235
  },
206
236
  },
207
237
  {
208
238
  name: "artifactly_list_artifacts",
209
- description: "List recent Artifactly artifacts when the list API is available.",
210
- 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
+ },
211
250
  },
212
251
  {
213
252
  name: "artifactly_get_artifact",
@@ -248,7 +287,7 @@ export function listArtifactlyMcpTools(): ArtifactlyMcpTool[] {
248
287
  {
249
288
  name: "artifactly_publish_file",
250
289
  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.",
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.",
252
291
  inputSchema: {
253
292
  type: "object",
254
293
  properties: {
@@ -344,10 +383,17 @@ export async function callArtifactlyMcpTool(input: CallToolInput) {
344
383
  const title = requireStringArg(input.arguments, "title");
345
384
  const content = requireRawStringArg(input.arguments, "markdown");
346
385
  const visibility = stringArg(input.arguments, "visibility") ?? "unlisted";
386
+ const accessList = stringArrayArg(input.arguments, "accessList");
347
387
  return objectResult(
348
388
  await postJson(
349
389
  "/api/artifacts",
350
- { title, content, kind: "markdown", visibility },
390
+ {
391
+ title,
392
+ content,
393
+ kind: "markdown",
394
+ visibility,
395
+ ...(accessList ? { accessList } : {}),
396
+ },
351
397
  { apiKey: credentials.apiKey },
352
398
  ),
353
399
  );
@@ -357,7 +403,12 @@ export async function callArtifactlyMcpTool(input: CallToolInput) {
357
403
  if (!credentials) {
358
404
  throw new Error(LOGIN_GUIDANCE);
359
405
  }
360
- 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 }));
361
412
  }
362
413
 
363
414
  if (input.name === "artifactly_get_artifact") {