@artifactly/mcp 0.1.8 → 0.1.10
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 +41 -0
- package/package.json +1 -1
- package/src/cli/artifactly-cli.test.ts +20 -0
- package/src/cli/artifactly-cli.ts +15 -2
- package/src/mcp/artifactly-mcp-docs.test.ts +22 -0
- package/src/mcp/artifactly-mcp-server.test.ts +63 -1
- package/src/mcp/artifactly-mcp-server.ts +32 -1
- package/src/mcp/artifactly-mcp.test.ts +226 -4
- package/src/mcp/artifactly-mcp.ts +128 -15
package/README.md
CHANGED
|
@@ -35,6 +35,47 @@ 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 content by default, including base64/data URL file payloads. Pass `metadataOnly: true` to omit `content` and `storageUrl` for bounded metadata reads.
|
|
70
|
+
- `artifactly_update_artifact` accepts either `markdown` or `content`, not both, and preserves text whitespace exactly while rejecting whitespace-only content updates.
|
|
71
|
+
- To replace binary/file content, call `artifactly_update_artifact` with `filePath` and optional `mimeType`; the replacement file becomes the viewer source and clears old inline text content.
|
|
72
|
+
- Pass `description: ""` to clear a description.
|
|
73
|
+
- `artifactly_publish_file` infers MIME type from file extension unless `mimeType` is supplied; explicit `mimeType` wins.
|
|
74
|
+
- Image MIME types such as `image/png` publish as `kind: "image"` and can be listed with `kind: "image"`.
|
|
75
|
+
- Files larger than 10 MB are rejected before upload to avoid large in-memory base64 payloads.
|
|
76
|
+
- Requests time out after 30 seconds.
|
|
77
|
+
- This package currently exposes MCP tools only; resources, resource templates, and prompts are intentionally unsupported.
|
|
78
|
+
|
|
38
79
|
Manual publish from a logged-in maintainer account:
|
|
39
80
|
|
|
40
81
|
```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;
|
|
@@ -109,15 +116,21 @@ async function defaultPrompt(question: string) {
|
|
|
109
116
|
}
|
|
110
117
|
}
|
|
111
118
|
|
|
112
|
-
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;
|
|
113
125
|
return async (path, body, options) => {
|
|
114
|
-
const response = await
|
|
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,22 @@
|
|
|
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
|
+
expect(readme).toContain("`artifactly_get_artifact` returns full content by default");
|
|
18
|
+
expect(readme).toContain("`metadataOnly: true`");
|
|
19
|
+
expect(readme).toContain("This package currently exposes MCP tools only");
|
|
20
|
+
expect(readme).toContain("resources, resource templates, and prompts are intentionally unsupported");
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -41,7 +41,7 @@ describe("Artifactly MCP stdio server helpers", () => {
|
|
|
41
41
|
it("registers tool schemas and server info from package metadata", () => {
|
|
42
42
|
const server = createArtifactlyMcpServer() as unknown as {
|
|
43
43
|
server: { _serverInfo: { name: string; version: string } };
|
|
44
|
-
_registeredTools: Record<string, { inputSchema: { def: { shape: Record<string, unknown> }; safeParse(value: unknown): { success: boolean } } }>;
|
|
44
|
+
_registeredTools: Record<string, { inputSchema: { def: { shape: Record<string, unknown> }; safeParse(value: unknown): { success: boolean } }; annotations?: Record<string, unknown>; description: string }>;
|
|
45
45
|
};
|
|
46
46
|
|
|
47
47
|
expect(server.server._serverInfo).toEqual({
|
|
@@ -55,6 +55,54 @@ 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);
|
|
74
|
+
expect(
|
|
75
|
+
server._registeredTools.artifactly_publish_file.inputSchema.safeParse({
|
|
76
|
+
path: "/tmp/chart.png",
|
|
77
|
+
visibility: "gated",
|
|
78
|
+
accessList: ["agent@example.com"],
|
|
79
|
+
}).success,
|
|
80
|
+
).toBe(true);
|
|
81
|
+
expect(
|
|
82
|
+
server._registeredTools.artifactly_update_artifact.inputSchema.safeParse({
|
|
83
|
+
artifactId: "artifact_1",
|
|
84
|
+
filePath: "/tmp/replacement.pdf",
|
|
85
|
+
mimeType: "application/pdf",
|
|
86
|
+
}).success,
|
|
87
|
+
).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("registers MCP tool annotations and accurate content descriptions", () => {
|
|
91
|
+
const server = createArtifactlyMcpServer() as unknown as {
|
|
92
|
+
_registeredTools: Record<string, { annotations?: Record<string, unknown>; description: string }>;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
expect(server._registeredTools.artifactly_list_artifacts.annotations).toMatchObject({
|
|
96
|
+
readOnlyHint: true,
|
|
97
|
+
});
|
|
98
|
+
expect(server._registeredTools.artifactly_get_artifact.annotations).toMatchObject({
|
|
99
|
+
readOnlyHint: true,
|
|
100
|
+
});
|
|
101
|
+
expect(server._registeredTools.artifactly_delete_artifact.annotations).toMatchObject({
|
|
102
|
+
destructiveHint: true,
|
|
103
|
+
});
|
|
104
|
+
expect(server._registeredTools.artifactly_get_artifact.description).toMatch(/full content/i);
|
|
105
|
+
expect(server._registeredTools.artifactly_get_artifact.description).toMatch(/metadataOnly/i);
|
|
58
106
|
});
|
|
59
107
|
|
|
60
108
|
it("maps Artifactly tools to MCP-compatible tool definitions", () => {
|
|
@@ -81,6 +129,20 @@ describe("Artifactly MCP stdio server helpers", () => {
|
|
|
81
129
|
expect(result.content).toHaveLength(1);
|
|
82
130
|
expect(result.content[0]).toMatchObject({ type: "text" });
|
|
83
131
|
expect(result.content[0].text).toContain('"authenticated": false');
|
|
132
|
+
expect(result.structuredContent).toEqual({ authenticated: false, apiBaseUrl: "https://artifactly.io" });
|
|
84
133
|
expect(result.content[0].text).not.toContain("art_");
|
|
85
134
|
});
|
|
135
|
+
|
|
136
|
+
it("returns typed MCP error content", async () => {
|
|
137
|
+
const result = await createArtifactlyMcpCallResult({
|
|
138
|
+
name: "artifactly_list_artifacts",
|
|
139
|
+
credentialPath: "/tmp/does-not-exist-artifactly-credentials.json",
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(result.isError).toBe(true);
|
|
143
|
+
expect(result.structuredContent).toMatchObject({
|
|
144
|
+
error: expect.stringContaining("artifactly_request_login_code"),
|
|
145
|
+
errorType: "Error",
|
|
146
|
+
});
|
|
147
|
+
});
|
|
86
148
|
});
|
|
@@ -27,6 +27,7 @@ type McpTextContent = {
|
|
|
27
27
|
|
|
28
28
|
export type ArtifactlyMcpCallResult = {
|
|
29
29
|
content: McpTextContent[];
|
|
30
|
+
structuredContent?: Record<string, unknown>;
|
|
30
31
|
isError?: boolean;
|
|
31
32
|
};
|
|
32
33
|
|
|
@@ -53,7 +54,12 @@ export function listArtifactlyMcpServerTools() {
|
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
function resultToText(value: unknown): ArtifactlyMcpCallResult {
|
|
57
|
+
const structuredContent =
|
|
58
|
+
value && typeof value === "object" && !Array.isArray(value)
|
|
59
|
+
? (value as Record<string, unknown>)
|
|
60
|
+
: undefined;
|
|
56
61
|
return {
|
|
62
|
+
...(structuredContent ? { structuredContent } : {}),
|
|
57
63
|
content: [
|
|
58
64
|
{
|
|
59
65
|
type: "text",
|
|
@@ -74,6 +80,12 @@ export async function createArtifactlyMcpCallResult(input: {
|
|
|
74
80
|
} catch (error) {
|
|
75
81
|
return {
|
|
76
82
|
isError: true,
|
|
83
|
+
structuredContent: {
|
|
84
|
+
error: error instanceof Error ? error.message : String(error),
|
|
85
|
+
errorType: error instanceof Error ? error.name : "Error",
|
|
86
|
+
...(error instanceof Error && "status" in error ? { status: (error as { status?: unknown }).status } : {}),
|
|
87
|
+
...(error instanceof Error && "details" in error ? { details: (error as { details?: unknown }).details } : {}),
|
|
88
|
+
},
|
|
77
89
|
content: [
|
|
78
90
|
{
|
|
79
91
|
type: "text",
|
|
@@ -100,16 +112,25 @@ const publishMarkdownSchema = z.object({
|
|
|
100
112
|
title: z.string().min(1),
|
|
101
113
|
markdown: z.string().min(1),
|
|
102
114
|
visibility: z.enum(["public", "unlisted", "private", "gated"]).optional(),
|
|
115
|
+
accessList: z.array(z.string().email()).optional(),
|
|
116
|
+
});
|
|
117
|
+
const listArtifactsSchema = z.object({
|
|
118
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
119
|
+
cursor: z.string().optional(),
|
|
120
|
+
visibility: z.enum(["public", "unlisted", "private", "gated"]).optional(),
|
|
121
|
+
kind: z.enum(["markdown", "html", "image", "file", "json", "web_app"]).optional(),
|
|
103
122
|
});
|
|
104
|
-
const listArtifactsSchema = z.object({});
|
|
105
123
|
const getArtifactSchema = z.object({
|
|
106
124
|
artifactId: z.string().min(1),
|
|
125
|
+
metadataOnly: z.boolean().optional(),
|
|
107
126
|
});
|
|
108
127
|
const updateArtifactSchema = z.object({
|
|
109
128
|
artifactId: z.string().min(1),
|
|
110
129
|
title: z.string().min(1).optional(),
|
|
111
130
|
markdown: z.string().min(1).optional(),
|
|
112
131
|
content: z.string().min(1).optional(),
|
|
132
|
+
filePath: z.string().min(1).optional(),
|
|
133
|
+
mimeType: z.string().min(1).optional(),
|
|
113
134
|
description: z.string().max(500).optional(),
|
|
114
135
|
visibility: z.enum(["public", "unlisted", "private", "gated"]).optional(),
|
|
115
136
|
accessList: z.array(z.string().email()).optional(),
|
|
@@ -122,6 +143,7 @@ const publishFileSchema = z.object({
|
|
|
122
143
|
title: z.string().min(1).optional(),
|
|
123
144
|
mimeType: z.string().min(1).optional(),
|
|
124
145
|
visibility: z.enum(["public", "unlisted", "private", "gated"]).optional(),
|
|
146
|
+
accessList: z.array(z.string().email()).optional(),
|
|
125
147
|
});
|
|
126
148
|
|
|
127
149
|
const toolSchemas = {
|
|
@@ -137,6 +159,14 @@ const toolSchemas = {
|
|
|
137
159
|
artifactly_publish_file: publishFileSchema,
|
|
138
160
|
} as const;
|
|
139
161
|
|
|
162
|
+
const toolAnnotations: Record<string, Record<string, boolean>> = {
|
|
163
|
+
artifactly_auth_status: { readOnlyHint: true },
|
|
164
|
+
artifactly_list_artifacts: { readOnlyHint: true },
|
|
165
|
+
artifactly_get_artifact: { readOnlyHint: true },
|
|
166
|
+
artifactly_delete_artifact: { destructiveHint: true },
|
|
167
|
+
artifactly_logout: { destructiveHint: true },
|
|
168
|
+
};
|
|
169
|
+
|
|
140
170
|
export function createArtifactlyMcpServer() {
|
|
141
171
|
const server = new McpServer({
|
|
142
172
|
name: "artifactly",
|
|
@@ -151,6 +181,7 @@ export function createArtifactlyMcpServer() {
|
|
|
151
181
|
title: tool.name,
|
|
152
182
|
description: tool.description,
|
|
153
183
|
inputSchema: schema,
|
|
184
|
+
...(toolAnnotations[tool.name] ? { annotations: toolAnnotations[tool.name] } : {}),
|
|
154
185
|
},
|
|
155
186
|
async (args: Record<string, unknown>) =>
|
|
156
187
|
createArtifactlyMcpCallResult({ name: tool.name, arguments: args }),
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
|
|
@@ -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
|
|
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({
|
|
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
|
-
{
|
|
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,164 @@ 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_test_secret",
|
|
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
|
+
});
|
|
508
|
+
|
|
509
|
+
it("rejects directory paths with a friendly file error", async () => {
|
|
510
|
+
const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
|
|
511
|
+
const credentialPath = join(dir, "credentials.json");
|
|
512
|
+
const directoryPath = join(dir, "folder");
|
|
513
|
+
mkdirSync(directoryPath);
|
|
514
|
+
writeCredentials(
|
|
515
|
+
{
|
|
516
|
+
apiBaseUrl: "https://artifactly.example",
|
|
517
|
+
apiKey: "art_test_secret",
|
|
518
|
+
email: "agent@example.com",
|
|
519
|
+
workspace: "agent",
|
|
520
|
+
},
|
|
521
|
+
credentialPath,
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
await expect(
|
|
525
|
+
callArtifactlyMcpTool({
|
|
526
|
+
name: "artifactly_publish_file",
|
|
527
|
+
credentialPath,
|
|
528
|
+
arguments: { path: directoryPath },
|
|
529
|
+
}),
|
|
530
|
+
).rejects.toThrow(/Path must point to a file/);
|
|
531
|
+
|
|
532
|
+
rmSync(dir, { recursive: true, force: true });
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("publishes gated image files with access lists and image kind", async () => {
|
|
536
|
+
const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
|
|
537
|
+
const credentialPath = join(dir, "credentials.json");
|
|
538
|
+
const pngPath = join(dir, "chart.png");
|
|
539
|
+
let publishedBody: unknown;
|
|
540
|
+
writeFileSync(pngPath, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
|
541
|
+
writeCredentials(
|
|
542
|
+
{
|
|
543
|
+
apiBaseUrl: "https://artifactly.example",
|
|
544
|
+
apiKey: "art_test_secret",
|
|
545
|
+
email: "agent@example.com",
|
|
546
|
+
workspace: "agent",
|
|
547
|
+
},
|
|
548
|
+
credentialPath,
|
|
549
|
+
);
|
|
550
|
+
|
|
551
|
+
await callArtifactlyMcpTool({
|
|
552
|
+
name: "artifactly_publish_file",
|
|
553
|
+
credentialPath,
|
|
554
|
+
arguments: {
|
|
555
|
+
path: pngPath,
|
|
556
|
+
mimeType: "image/png",
|
|
557
|
+
visibility: "gated",
|
|
558
|
+
accessList: ["One@Example.com"],
|
|
559
|
+
},
|
|
560
|
+
postJson: async (_path, body) => {
|
|
561
|
+
publishedBody = body;
|
|
562
|
+
return { id: "artifact_1", accessList: ["one@example.com"] };
|
|
563
|
+
},
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
expect(publishedBody).toMatchObject({
|
|
567
|
+
kind: "image",
|
|
568
|
+
storageUrl: "data:image/png;base64,iVBORw==",
|
|
569
|
+
visibility: "gated",
|
|
570
|
+
accessList: ["One@Example.com"],
|
|
571
|
+
});
|
|
572
|
+
rmSync(dir, { recursive: true, force: true });
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it("preserves update whitespace and rejects whitespace-only updates locally", async () => {
|
|
576
|
+
const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
|
|
577
|
+
const credentialPath = join(dir, "credentials.json");
|
|
578
|
+
const requests: unknown[] = [];
|
|
579
|
+
writeCredentials(
|
|
580
|
+
{
|
|
581
|
+
apiBaseUrl: "https://artifactly.example",
|
|
582
|
+
apiKey: "art_test_secret",
|
|
583
|
+
email: "agent@example.com",
|
|
584
|
+
workspace: "agent",
|
|
585
|
+
},
|
|
586
|
+
credentialPath,
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
await callArtifactlyMcpTool({
|
|
590
|
+
name: "artifactly_update_artifact",
|
|
591
|
+
credentialPath,
|
|
592
|
+
arguments: { artifactId: "artifact_1", markdown: " # Title\n" },
|
|
593
|
+
requestJson: async (_method, _path, body) => {
|
|
594
|
+
requests.push(body);
|
|
595
|
+
return { artifact: { id: "artifact_1" } };
|
|
596
|
+
},
|
|
597
|
+
});
|
|
598
|
+
expect(requests).toEqual([{ markdown: " # Title\n" }]);
|
|
599
|
+
await expect(
|
|
600
|
+
callArtifactlyMcpTool({
|
|
601
|
+
name: "artifactly_update_artifact",
|
|
602
|
+
credentialPath,
|
|
603
|
+
arguments: { artifactId: "artifact_1", content: " " },
|
|
604
|
+
}),
|
|
605
|
+
).rejects.toThrow(/Content cannot be whitespace-only/);
|
|
606
|
+
|
|
607
|
+
rmSync(dir, { recursive: true, force: true });
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it("replaces artifact files explicitly through update", async () => {
|
|
611
|
+
const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
|
|
612
|
+
const credentialPath = join(dir, "credentials.json");
|
|
613
|
+
const pdfPath = join(dir, "replacement.pdf");
|
|
614
|
+
let bodySent: unknown;
|
|
615
|
+
writeFileSync(pdfPath, Buffer.from("%PDF-1.4"));
|
|
616
|
+
writeCredentials(
|
|
617
|
+
{
|
|
618
|
+
apiBaseUrl: "https://artifactly.example",
|
|
619
|
+
apiKey: "art_test_secret",
|
|
620
|
+
email: "agent@example.com",
|
|
621
|
+
workspace: "agent",
|
|
622
|
+
},
|
|
623
|
+
credentialPath,
|
|
624
|
+
);
|
|
625
|
+
|
|
626
|
+
await callArtifactlyMcpTool({
|
|
627
|
+
name: "artifactly_update_artifact",
|
|
628
|
+
credentialPath,
|
|
629
|
+
arguments: { artifactId: "artifact_1", filePath: pdfPath, mimeType: "application/pdf" },
|
|
630
|
+
requestJson: async (_method, _path, body) => {
|
|
631
|
+
bodySent = body;
|
|
632
|
+
return { artifact: { id: "artifact_1", kind: "file" } };
|
|
633
|
+
},
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
expect(bodySent).toMatchObject({
|
|
637
|
+
kind: "file",
|
|
638
|
+
storageUrl: "data:application/pdf;base64,JVBERi0xLjQ=",
|
|
639
|
+
});
|
|
640
|
+
rmSync(dir, { recursive: true, force: true });
|
|
641
|
+
});
|
|
420
642
|
});
|
|
@@ -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,21 @@ 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
|
+
|
|
38
|
+
export class ArtifactlyApiError extends Error {
|
|
39
|
+
status: number;
|
|
40
|
+
details: unknown;
|
|
41
|
+
|
|
42
|
+
constructor(message: string, status: number, details: unknown) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.name = "ArtifactlyApiError";
|
|
45
|
+
this.status = status;
|
|
46
|
+
this.details = details;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
35
50
|
function createDefaultRequestJson(apiBaseUrl: string): RequestJson {
|
|
36
51
|
return async (method, path, body, options) => {
|
|
37
52
|
const response = await fetch(`${apiBaseUrl}${path}`, {
|
|
@@ -41,6 +56,7 @@ function createDefaultRequestJson(apiBaseUrl: string): RequestJson {
|
|
|
41
56
|
...(options?.apiKey ? { authorization: `Bearer ${options.apiKey}` } : {}),
|
|
42
57
|
},
|
|
43
58
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
59
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
44
60
|
});
|
|
45
61
|
const payload = (await response.json().catch(() => ({}))) as unknown;
|
|
46
62
|
if (!response.ok) {
|
|
@@ -51,7 +67,7 @@ function createDefaultRequestJson(apiBaseUrl: string): RequestJson {
|
|
|
51
67
|
typeof payload.error === "string"
|
|
52
68
|
? payload.error
|
|
53
69
|
: `Artifactly API request failed with ${response.status}`;
|
|
54
|
-
throw new
|
|
70
|
+
throw new ArtifactlyApiError(message, response.status, payload);
|
|
55
71
|
}
|
|
56
72
|
return payload;
|
|
57
73
|
};
|
|
@@ -73,6 +89,14 @@ function optionalStringArg(
|
|
|
73
89
|
return typeof value === "string" ? value.trim() : undefined;
|
|
74
90
|
}
|
|
75
91
|
|
|
92
|
+
function optionalRawStringArg(
|
|
93
|
+
args: Record<string, unknown> | undefined,
|
|
94
|
+
key: string,
|
|
95
|
+
): string | undefined {
|
|
96
|
+
const value = args?.[key];
|
|
97
|
+
return typeof value === "string" ? value : undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
76
100
|
function requireStringArg(
|
|
77
101
|
args: Record<string, unknown> | undefined,
|
|
78
102
|
key: string,
|
|
@@ -122,6 +146,15 @@ function booleanArg(args: Record<string, unknown> | undefined, key: string): boo
|
|
|
122
146
|
|
|
123
147
|
function readFileForPublish(path: string): Buffer {
|
|
124
148
|
try {
|
|
149
|
+
const stats = statSync(path);
|
|
150
|
+
if (!stats.isFile()) {
|
|
151
|
+
throw new Error(`Path must point to a file: ${path}`);
|
|
152
|
+
}
|
|
153
|
+
if (stats.size > MAX_UPLOAD_BYTES) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`File is too large: ${path}. Maximum supported upload size is 10 MB.`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
125
158
|
return readFileSync(path);
|
|
126
159
|
} catch (error) {
|
|
127
160
|
const code = typeof error === "object" && error && "code" in error ? String(error.code) : "";
|
|
@@ -147,6 +180,10 @@ function inferMimeType(path: string): string {
|
|
|
147
180
|
if (extension === ".json") return "application/json";
|
|
148
181
|
if (extension === ".md" || extension === ".markdown") return "text/markdown";
|
|
149
182
|
if (extension === ".txt") return "text/plain";
|
|
183
|
+
if (extension === ".png") return "image/png";
|
|
184
|
+
if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg";
|
|
185
|
+
if (extension === ".gif") return "image/gif";
|
|
186
|
+
if (extension === ".webp") return "image/webp";
|
|
150
187
|
return "application/octet-stream";
|
|
151
188
|
}
|
|
152
189
|
|
|
@@ -155,9 +192,29 @@ function kindForMime(_path: string, mimeType: string) {
|
|
|
155
192
|
if (normalized.includes("html")) return "html";
|
|
156
193
|
if (normalized.includes("json")) return "json";
|
|
157
194
|
if (normalized.includes("markdown")) return "markdown";
|
|
195
|
+
if (normalized.startsWith("image/")) return "image";
|
|
158
196
|
return "file";
|
|
159
197
|
}
|
|
160
198
|
|
|
199
|
+
function queryString(params: Record<string, string | number | undefined>): string {
|
|
200
|
+
const search = new URLSearchParams();
|
|
201
|
+
for (const [key, value] of Object.entries(params)) {
|
|
202
|
+
if (value !== undefined) {
|
|
203
|
+
search.set(key, String(value));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const serialized = search.toString();
|
|
207
|
+
return serialized ? `?${serialized}` : "";
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function numberArg(args: Record<string, unknown> | undefined, key: string): number | undefined {
|
|
211
|
+
const value = args?.[key];
|
|
212
|
+
if (typeof value === "number" && Number.isInteger(value)) {
|
|
213
|
+
return value;
|
|
214
|
+
}
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
161
218
|
export function listArtifactlyMcpTools(): ArtifactlyMcpTool[] {
|
|
162
219
|
return [
|
|
163
220
|
{
|
|
@@ -200,21 +257,31 @@ export function listArtifactlyMcpTools(): ArtifactlyMcpTool[] {
|
|
|
200
257
|
title: { type: "string" },
|
|
201
258
|
markdown: { type: "string" },
|
|
202
259
|
visibility: { type: "string", enum: ["public", "unlisted", "private", "gated"] },
|
|
260
|
+
accessList: { type: "array", items: { type: "string" } },
|
|
203
261
|
},
|
|
204
262
|
required: ["title", "markdown"],
|
|
205
263
|
},
|
|
206
264
|
},
|
|
207
265
|
{
|
|
208
266
|
name: "artifactly_list_artifacts",
|
|
209
|
-
description:
|
|
210
|
-
|
|
267
|
+
description:
|
|
268
|
+
"List recent Artifactly artifacts without large content bodies. Supports limit (1-100), cursor, visibility, and kind filters.",
|
|
269
|
+
inputSchema: {
|
|
270
|
+
type: "object",
|
|
271
|
+
properties: {
|
|
272
|
+
limit: { type: "number", minimum: 1, maximum: 100 },
|
|
273
|
+
cursor: { type: "string" },
|
|
274
|
+
visibility: { type: "string", enum: ["public", "unlisted", "private", "gated"] },
|
|
275
|
+
kind: { type: "string", enum: ["markdown", "html", "image", "file", "json", "web_app"] },
|
|
276
|
+
},
|
|
277
|
+
},
|
|
211
278
|
},
|
|
212
279
|
{
|
|
213
280
|
name: "artifactly_get_artifact",
|
|
214
|
-
description: "Get Artifactly artifact
|
|
281
|
+
description: "Get an Artifactly artifact. Returns full content by default, including base64/data URL file payloads; pass metadataOnly: true to omit content and storageUrl.",
|
|
215
282
|
inputSchema: {
|
|
216
283
|
type: "object",
|
|
217
|
-
properties: { artifactId: { type: "string" } },
|
|
284
|
+
properties: { artifactId: { type: "string" }, metadataOnly: { type: "boolean" } },
|
|
218
285
|
required: ["artifactId"],
|
|
219
286
|
},
|
|
220
287
|
},
|
|
@@ -229,6 +296,8 @@ export function listArtifactlyMcpTools(): ArtifactlyMcpTool[] {
|
|
|
229
296
|
title: { type: "string" },
|
|
230
297
|
markdown: { type: "string" },
|
|
231
298
|
content: { type: "string" },
|
|
299
|
+
filePath: { type: "string" },
|
|
300
|
+
mimeType: { type: "string" },
|
|
232
301
|
description: { type: "string" },
|
|
233
302
|
visibility: { type: "string", enum: ["public", "unlisted", "private", "gated"] },
|
|
234
303
|
accessList: { type: "array", items: { type: "string" } },
|
|
@@ -248,7 +317,7 @@ export function listArtifactlyMcpTools(): ArtifactlyMcpTool[] {
|
|
|
248
317
|
{
|
|
249
318
|
name: "artifactly_publish_file",
|
|
250
319
|
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.",
|
|
320
|
+
"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
321
|
inputSchema: {
|
|
253
322
|
type: "object",
|
|
254
323
|
properties: {
|
|
@@ -256,6 +325,7 @@ export function listArtifactlyMcpTools(): ArtifactlyMcpTool[] {
|
|
|
256
325
|
title: { type: "string" },
|
|
257
326
|
mimeType: { type: "string" },
|
|
258
327
|
visibility: { type: "string", enum: ["public", "unlisted", "private", "gated"] },
|
|
328
|
+
accessList: { type: "array", items: { type: "string" } },
|
|
259
329
|
},
|
|
260
330
|
required: ["path"],
|
|
261
331
|
},
|
|
@@ -344,10 +414,17 @@ export async function callArtifactlyMcpTool(input: CallToolInput) {
|
|
|
344
414
|
const title = requireStringArg(input.arguments, "title");
|
|
345
415
|
const content = requireRawStringArg(input.arguments, "markdown");
|
|
346
416
|
const visibility = stringArg(input.arguments, "visibility") ?? "unlisted";
|
|
417
|
+
const accessList = stringArrayArg(input.arguments, "accessList");
|
|
347
418
|
return objectResult(
|
|
348
419
|
await postJson(
|
|
349
420
|
"/api/artifacts",
|
|
350
|
-
{
|
|
421
|
+
{
|
|
422
|
+
title,
|
|
423
|
+
content,
|
|
424
|
+
kind: "markdown",
|
|
425
|
+
visibility,
|
|
426
|
+
...(accessList ? { accessList } : {}),
|
|
427
|
+
},
|
|
351
428
|
{ apiKey: credentials.apiKey },
|
|
352
429
|
),
|
|
353
430
|
);
|
|
@@ -357,7 +434,12 @@ export async function callArtifactlyMcpTool(input: CallToolInput) {
|
|
|
357
434
|
if (!credentials) {
|
|
358
435
|
throw new Error(LOGIN_GUIDANCE);
|
|
359
436
|
}
|
|
360
|
-
|
|
437
|
+
const limit = numberArg(input.arguments, "limit");
|
|
438
|
+
const cursor = stringArg(input.arguments, "cursor");
|
|
439
|
+
const visibility = stringArg(input.arguments, "visibility");
|
|
440
|
+
const kind = stringArg(input.arguments, "kind");
|
|
441
|
+
const query = queryString({ limit, cursor, visibility, kind });
|
|
442
|
+
return objectResult(await requestJson("GET", `/api/artifacts${query}`, undefined, { apiKey: credentials.apiKey }));
|
|
361
443
|
}
|
|
362
444
|
|
|
363
445
|
if (input.name === "artifactly_get_artifact") {
|
|
@@ -365,7 +447,15 @@ export async function callArtifactlyMcpTool(input: CallToolInput) {
|
|
|
365
447
|
throw new Error(LOGIN_GUIDANCE);
|
|
366
448
|
}
|
|
367
449
|
const artifactId = requireStringArg(input.arguments, "artifactId");
|
|
368
|
-
|
|
450
|
+
const metadataOnly = booleanArg(input.arguments, "metadataOnly");
|
|
451
|
+
const result = objectResult(await requestJson("GET", `/api/artifacts/${artifactId}`, undefined, { apiKey: credentials.apiKey }));
|
|
452
|
+
if (metadataOnly && result.artifact && typeof result.artifact === "object" && !Array.isArray(result.artifact)) {
|
|
453
|
+
const { content: _content, storageUrl: _storageUrl, ...metadata } = result.artifact as Record<string, unknown>;
|
|
454
|
+
void _content;
|
|
455
|
+
void _storageUrl;
|
|
456
|
+
return { ...result, artifact: metadata };
|
|
457
|
+
}
|
|
458
|
+
return result;
|
|
369
459
|
}
|
|
370
460
|
|
|
371
461
|
if (input.name === "artifactly_update_artifact") {
|
|
@@ -374,22 +464,41 @@ export async function callArtifactlyMcpTool(input: CallToolInput) {
|
|
|
374
464
|
}
|
|
375
465
|
const artifactId = requireStringArg(input.arguments, "artifactId");
|
|
376
466
|
const title = stringArg(input.arguments, "title");
|
|
377
|
-
const markdown =
|
|
378
|
-
const content =
|
|
467
|
+
const markdown = optionalRawStringArg(input.arguments, "markdown");
|
|
468
|
+
const content = optionalRawStringArg(input.arguments, "content");
|
|
469
|
+
const filePath = stringArg(input.arguments, "filePath");
|
|
379
470
|
if (markdown !== undefined && content !== undefined) {
|
|
380
471
|
throw new Error("Use either markdown or content, not both.");
|
|
381
472
|
}
|
|
473
|
+
if ((markdown !== undefined || content !== undefined) && filePath) {
|
|
474
|
+
throw new Error("Use either filePath or text content, not both.");
|
|
475
|
+
}
|
|
476
|
+
const textContent = markdown ?? content;
|
|
477
|
+
if (textContent !== undefined && !textContent.trim()) {
|
|
478
|
+
throw new Error("Content cannot be whitespace-only.");
|
|
479
|
+
}
|
|
382
480
|
const description = optionalStringArg(input.arguments, "description");
|
|
383
481
|
const visibility = stringArg(input.arguments, "visibility");
|
|
384
482
|
const accessList = stringArrayArg(input.arguments, "accessList");
|
|
483
|
+
let replacement: Record<string, unknown> = {};
|
|
484
|
+
if (filePath) {
|
|
485
|
+
const mimeType = stringArg(input.arguments, "mimeType") ?? inferMimeType(filePath);
|
|
486
|
+
const kind = kindForMime(filePath, mimeType);
|
|
487
|
+
const file = readFileForPublish(filePath);
|
|
488
|
+
replacement = {
|
|
489
|
+
kind,
|
|
490
|
+
storageUrl: `data:${mimeType};base64,${file.toString("base64")}`,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
385
493
|
return objectResult(
|
|
386
494
|
await requestJson(
|
|
387
495
|
"PATCH",
|
|
388
496
|
`/api/artifacts/${artifactId}`,
|
|
389
497
|
{
|
|
390
498
|
...(title ? { title } : {}),
|
|
391
|
-
...(markdown ? { markdown } : {}),
|
|
392
|
-
...(content ? { content } : {}),
|
|
499
|
+
...(markdown !== undefined ? { markdown } : {}),
|
|
500
|
+
...(content !== undefined ? { content } : {}),
|
|
501
|
+
...replacement,
|
|
393
502
|
...(description !== undefined ? { description } : {}),
|
|
394
503
|
...(visibility ? { visibility } : {}),
|
|
395
504
|
...(accessList ? { accessList } : {}),
|
|
@@ -415,21 +524,25 @@ export async function callArtifactlyMcpTool(input: CallToolInput) {
|
|
|
415
524
|
const title = stringArg(input.arguments, "title") ?? titleFromPath(path);
|
|
416
525
|
const mimeType = stringArg(input.arguments, "mimeType") ?? inferMimeType(path);
|
|
417
526
|
const visibility = stringArg(input.arguments, "visibility") ?? "unlisted";
|
|
527
|
+
const accessList = stringArrayArg(input.arguments, "accessList");
|
|
418
528
|
const kind = kindForMime(path, mimeType);
|
|
419
529
|
const file = readFileForPublish(path);
|
|
530
|
+
const fileLike = kind === "file" || kind === "image";
|
|
420
531
|
const payload =
|
|
421
|
-
|
|
532
|
+
fileLike
|
|
422
533
|
? {
|
|
423
534
|
title,
|
|
424
535
|
kind,
|
|
425
536
|
storageUrl: `data:${mimeType};base64,${file.toString("base64")}`,
|
|
426
537
|
visibility,
|
|
538
|
+
...(accessList ? { accessList } : {}),
|
|
427
539
|
}
|
|
428
540
|
: {
|
|
429
541
|
title,
|
|
430
542
|
kind,
|
|
431
543
|
content: file.toString("utf8"),
|
|
432
544
|
visibility,
|
|
545
|
+
...(accessList ? { accessList } : {}),
|
|
433
546
|
};
|
|
434
547
|
return objectResult(
|
|
435
548
|
await postJson("/api/artifacts", payload, { apiKey: credentials.apiKey }),
|