@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 +37 -0
- package/package.json +1 -1
- package/src/cli/artifactly-cli.test.ts +20 -0
- package/src/cli/artifactly-cli.ts +39 -3
- package/src/mcp/artifactly-mcp-docs.test.ts +18 -0
- package/src/mcp/artifactly-mcp-server.test.ts +36 -0
- package/src/mcp/artifactly-mcp-server.ts +13 -4
- package/src/mcp/artifactly-mcp.test.ts +190 -3
- package/src/mcp/artifactly-mcp.ts +85 -17
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
|
@@ -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"))
|
|
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(
|
|
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
|
|
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().
|
|
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:
|
|
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
|
|
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({
|
|
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
|
-
{
|
|
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(
|
|
143
|
-
const extension = extname(path).toLowerCase();
|
|
163
|
+
function kindForMime(_path: string, mimeType: string) {
|
|
144
164
|
const normalized = mimeType.toLowerCase();
|
|
145
|
-
if (normalized.includes("html")
|
|
146
|
-
if (normalized.includes("json")
|
|
147
|
-
if (normalized.includes("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:
|
|
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:
|
|
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:
|
|
198
|
-
|
|
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:
|
|
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:
|
|
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 =
|
|
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
|
-
{
|
|
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
|
-
|
|
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 =
|
|
364
|
-
const 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");
|