@artifactly/mcp 0.1.7 → 0.1.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@artifactly/mcp",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "private": false,
5
5
  "files": [
6
6
  "bin",
@@ -57,9 +57,32 @@ export function getDefaultCredentialPath(home = homedir()) {
57
57
  return join(home, ".artifactly", "credentials.json");
58
58
  }
59
59
 
60
+ function normalizeCredentials(value: unknown): ArtifactlyCredentials | null {
61
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
62
+ return null;
63
+ }
64
+ const candidate = value as Record<string, unknown>;
65
+ if (typeof candidate.apiBaseUrl !== "string" || !candidate.apiBaseUrl.trim()) {
66
+ return null;
67
+ }
68
+ if (typeof candidate.apiKey !== "string" || !candidate.apiKey.trim()) {
69
+ return null;
70
+ }
71
+ return {
72
+ apiBaseUrl: candidate.apiBaseUrl.trim(),
73
+ apiKey: candidate.apiKey.trim(),
74
+ ...(typeof candidate.email === "string" && candidate.email.trim()
75
+ ? { email: candidate.email.trim() }
76
+ : {}),
77
+ ...(typeof candidate.workspace === "string" && candidate.workspace.trim()
78
+ ? { workspace: candidate.workspace.trim() }
79
+ : {}),
80
+ };
81
+ }
82
+
60
83
  export function readCredentials(path = getDefaultCredentialPath()): ArtifactlyCredentials | null {
61
84
  try {
62
- return JSON.parse(readFileSync(path, "utf8")) as ArtifactlyCredentials;
85
+ return normalizeCredentials(JSON.parse(readFileSync(path, "utf8")));
63
86
  } catch {
64
87
  return null;
65
88
  }
@@ -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,25 @@ 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
+ });
59
+
40
60
  it("maps Artifactly tools to MCP-compatible tool definitions", () => {
41
61
  expect(listArtifactlyMcpServerTools().map((tool) => tool.name)).toEqual([
42
62
  "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(),
@@ -107,7 +110,7 @@ const updateArtifactSchema = z.object({
107
110
  title: z.string().min(1).optional(),
108
111
  markdown: z.string().min(1).optional(),
109
112
  content: z.string().min(1).optional(),
110
- description: z.string().min(1).optional(),
113
+ description: z.string().max(500).optional(),
111
114
  visibility: z.enum(["public", "unlisted", "private", "gated"]).optional(),
112
115
  accessList: z.array(z.string().email()).optional(),
113
116
  });
@@ -137,7 +140,7 @@ const toolSchemas = {
137
140
  export function createArtifactlyMcpServer() {
138
141
  const server = new McpServer({
139
142
  name: "artifactly",
140
- version: "0.1.1",
143
+ version: packageJson.version,
141
144
  });
142
145
 
143
146
  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,34 @@ describe("Artifactly MCP tools", () => {
159
174
  rmSync(dir, { recursive: true, force: true });
160
175
  });
161
176
 
177
+ it("preserves markdown body whitespace when publishing inline markdown", async () => {
178
+ const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
179
+ const credentialPath = join(dir, "credentials.json");
180
+ let publishedBody: unknown;
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 callArtifactlyMcpTool({
192
+ name: "artifactly_publish_markdown",
193
+ credentialPath,
194
+ arguments: { title: "Whitespace", markdown: " # Hello\n\n" },
195
+ postJson: async (_path, body) => {
196
+ publishedBody = body;
197
+ return { id: "artifact_1", url: "https://artifactly.example/share/agent/hello" };
198
+ },
199
+ });
200
+
201
+ expect(publishedBody).toMatchObject({ content: " # Hello\n\n" });
202
+ rmSync(dir, { recursive: true, force: true });
203
+ });
204
+
162
205
  it("lists, gets, updates, and deletes artifacts through authenticated API requests", async () => {
163
206
  const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
164
207
  const credentialPath = join(dir, "credentials.json");
@@ -234,6 +277,29 @@ describe("Artifactly MCP tools", () => {
234
277
  rmSync(dir, { recursive: true, force: true });
235
278
  });
236
279
 
280
+ it("rejects conflicting markdown and content update arguments with a specific error", async () => {
281
+ const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
282
+ const credentialPath = join(dir, "credentials.json");
283
+ writeCredentials(
284
+ {
285
+ apiBaseUrl: "https://artifactly.example",
286
+ apiKey: "art_t...et",
287
+ email: "agent@example.com",
288
+ workspace: "agent",
289
+ },
290
+ credentialPath,
291
+ );
292
+
293
+ await expect(
294
+ callArtifactlyMcpTool({
295
+ name: "artifactly_update_artifact",
296
+ credentialPath,
297
+ arguments: { artifactId: "artifact_1", markdown: "# Markdown", content: "Plain" },
298
+ }),
299
+ ).rejects.toThrow(/Use either markdown or content, not both/);
300
+ rmSync(dir, { recursive: true, force: true });
301
+ });
302
+
237
303
  it("publishes local HTML and PDF files through the artifact API", async () => {
238
304
  const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
239
305
  const credentialPath = join(dir, "credentials.json");
@@ -295,6 +361,39 @@ describe("Artifactly MCP tools", () => {
295
361
  rmSync(dir, { recursive: true, force: true });
296
362
  });
297
363
 
364
+ it("uses explicit MIME type instead of extension when publishing files", async () => {
365
+ const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
366
+ const credentialPath = join(dir, "credentials.json");
367
+ const markdownPath = join(dir, "report.md");
368
+ let publishedBody: unknown;
369
+ writeFileSync(markdownPath, "# Report");
370
+ writeCredentials(
371
+ {
372
+ apiBaseUrl: "https://artifactly.example",
373
+ apiKey: "art_t...et",
374
+ email: "agent@example.com",
375
+ workspace: "agent",
376
+ },
377
+ credentialPath,
378
+ );
379
+
380
+ await callArtifactlyMcpTool({
381
+ name: "artifactly_publish_file",
382
+ credentialPath,
383
+ arguments: { path: markdownPath, mimeType: "application/pdf" },
384
+ postJson: async (_path, body) => {
385
+ publishedBody = body;
386
+ return { id: "artifact_1", url: "https://artifactly.example/share/agent/report" };
387
+ },
388
+ });
389
+
390
+ expect(publishedBody).toMatchObject({
391
+ kind: "file",
392
+ storageUrl: "data:application/pdf;base64,IyBSZXBvcnQ=",
393
+ });
394
+ rmSync(dir, { recursive: true, force: true });
395
+ });
396
+
298
397
  it("returns a friendly error when publishing a missing file", async () => {
299
398
  const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
300
399
  const credentialPath = join(dir, "credentials.json");
@@ -84,6 +84,17 @@ function requireStringArg(
84
84
  return value;
85
85
  }
86
86
 
87
+ function requireRawStringArg(
88
+ args: Record<string, unknown> | undefined,
89
+ key: string,
90
+ ): string {
91
+ const value = args?.[key];
92
+ if (typeof value !== "string" || !value.trim()) {
93
+ throw new Error(`Missing required argument: ${key}`);
94
+ }
95
+ return value;
96
+ }
97
+
87
98
  function stringArrayArg(
88
99
  args: Record<string, unknown> | undefined,
89
100
  key: string,
@@ -139,12 +150,11 @@ function inferMimeType(path: string): string {
139
150
  return "application/octet-stream";
140
151
  }
141
152
 
142
- function kindForMime(path: string, mimeType: string) {
143
- const extension = extname(path).toLowerCase();
153
+ function kindForMime(_path: string, mimeType: string) {
144
154
  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";
155
+ if (normalized.includes("html")) return "html";
156
+ if (normalized.includes("json")) return "json";
157
+ if (normalized.includes("markdown")) return "markdown";
148
158
  return "file";
149
159
  }
150
160
 
@@ -152,7 +162,8 @@ export function listArtifactlyMcpTools(): ArtifactlyMcpTool[] {
152
162
  return [
153
163
  {
154
164
  name: "artifactly_auth_status",
155
- description: "Check whether local Artifactly credentials are configured.",
165
+ description:
166
+ "Check local Artifactly credentials. Pass validate: true to confirm the saved API key with the Artifactly server.",
156
167
  inputSchema: { type: "object", properties: { validate: { type: "boolean" } } },
157
168
  },
158
169
  {
@@ -181,7 +192,8 @@ export function listArtifactlyMcpTools(): ArtifactlyMcpTool[] {
181
192
  },
182
193
  {
183
194
  name: "artifactly_publish_markdown",
184
- description: "Publish markdown through the Artifactly API using stored credentials.",
195
+ description:
196
+ "Publish markdown through the Artifactly API using stored credentials. Defaults to unlisted visibility; visibility can be public, unlisted, private, or gated.",
185
197
  inputSchema: {
186
198
  type: "object",
187
199
  properties: {
@@ -208,7 +220,8 @@ export function listArtifactlyMcpTools(): ArtifactlyMcpTool[] {
208
220
  },
209
221
  {
210
222
  name: "artifactly_update_artifact",
211
- description: "Update an existing Artifactly artifact, including visibility and gated access emails.",
223
+ description:
224
+ "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
225
  inputSchema: {
213
226
  type: "object",
214
227
  properties: {
@@ -234,7 +247,8 @@ export function listArtifactlyMcpTools(): ArtifactlyMcpTool[] {
234
247
  },
235
248
  {
236
249
  name: "artifactly_publish_file",
237
- description: "Publish a local PDF, HTML, JSON, Markdown, or other file through the Artifactly API.",
250
+ 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.",
238
252
  inputSchema: {
239
253
  type: "object",
240
254
  properties: {
@@ -328,7 +342,7 @@ export async function callArtifactlyMcpTool(input: CallToolInput) {
328
342
  throw new Error(LOGIN_GUIDANCE);
329
343
  }
330
344
  const title = requireStringArg(input.arguments, "title");
331
- const content = requireStringArg(input.arguments, "markdown");
345
+ const content = requireRawStringArg(input.arguments, "markdown");
332
346
  const visibility = stringArg(input.arguments, "visibility") ?? "unlisted";
333
347
  return objectResult(
334
348
  await postJson(
@@ -360,8 +374,11 @@ export async function callArtifactlyMcpTool(input: CallToolInput) {
360
374
  }
361
375
  const artifactId = requireStringArg(input.arguments, "artifactId");
362
376
  const title = stringArg(input.arguments, "title");
363
- const markdown = stringArg(input.arguments, "markdown");
364
- const content = stringArg(input.arguments, "content");
377
+ const markdown = optionalStringArg(input.arguments, "markdown");
378
+ const content = optionalStringArg(input.arguments, "content");
379
+ if (markdown !== undefined && content !== undefined) {
380
+ throw new Error("Use either markdown or content, not both.");
381
+ }
365
382
  const description = optionalStringArg(input.arguments, "description");
366
383
  const visibility = stringArg(input.arguments, "visibility");
367
384
  const accessList = stringArrayArg(input.arguments, "accessList");