@artifactly/mcp 0.1.5 → 0.1.7
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 +1 -1
- package/src/cli/artifactly-cli.test.ts +21 -1
- package/src/cli/artifactly-cli.ts +12 -2
- package/src/mcp/artifactly-mcp-server.test.ts +4 -0
- package/src/mcp/artifactly-mcp-server.ts +23 -0
- package/src/mcp/artifactly-mcp.test.ts +231 -1
- package/src/mcp/artifactly-mcp.ts +252 -18
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
|
|
@@ -82,6 +82,26 @@ describe("artifactly CLI", () => {
|
|
|
82
82
|
rmSync(dir, { recursive: true, force: true });
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
+
it("logs out by removing stored credentials", async () => {
|
|
86
|
+
const dir = mkdtempSync(join(tmpdir(), "artifactly-cli-"));
|
|
87
|
+
const credentialPath = join(dir, "credentials.json");
|
|
88
|
+
const output: string[] = [];
|
|
89
|
+
const cli = createArtifactlyCli({
|
|
90
|
+
credentialPath,
|
|
91
|
+
stdout: (line) => output.push(line),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await cli.run(["login", "--api-key", "art_test_secret", "--email", "agent@example.com"]);
|
|
95
|
+
expect(existsSync(credentialPath)).toBe(true);
|
|
96
|
+
|
|
97
|
+
await cli.run(["logout"]);
|
|
98
|
+
|
|
99
|
+
expect(existsSync(credentialPath)).toBe(false);
|
|
100
|
+
expect(readCredentials(credentialPath)).toBeNull();
|
|
101
|
+
expect(output.at(-1)).toBe("Logged out of Artifactly.");
|
|
102
|
+
rmSync(dir, { recursive: true, force: true });
|
|
103
|
+
});
|
|
104
|
+
|
|
85
105
|
it("reports the default credential path under the user home", () => {
|
|
86
106
|
expect(getDefaultCredentialPath("/Users/alice")).toBe(
|
|
87
107
|
"/Users/alice/.artifactly/credentials.json",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { createInterface } from "node:readline/promises";
|
|
@@ -73,6 +73,10 @@ export function writeCredentials(
|
|
|
73
73
|
writeFileSync(path, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
export function clearCredentials(path = getDefaultCredentialPath()) {
|
|
77
|
+
rmSync(path, { force: true });
|
|
78
|
+
}
|
|
79
|
+
|
|
76
80
|
async function defaultPrompt(question: string) {
|
|
77
81
|
const readline = createInterface({ input, output });
|
|
78
82
|
try {
|
|
@@ -166,6 +170,11 @@ export function createArtifactlyCli(options: ArtifactlyCliOptions = {}) {
|
|
|
166
170
|
stdout(`Workspace: ${credentials.workspace ?? "unknown"}`);
|
|
167
171
|
}
|
|
168
172
|
|
|
173
|
+
function logout() {
|
|
174
|
+
clearCredentials(credentialPath);
|
|
175
|
+
stdout("Logged out of Artifactly.");
|
|
176
|
+
}
|
|
177
|
+
|
|
169
178
|
async function publish(argv: string[]) {
|
|
170
179
|
const credentials = readCredentials(credentialPath);
|
|
171
180
|
if (!credentials) {
|
|
@@ -206,10 +215,11 @@ export function createArtifactlyCli(options: ArtifactlyCliOptions = {}) {
|
|
|
206
215
|
const [command, ...rest] = argv;
|
|
207
216
|
try {
|
|
208
217
|
if (command === "login") return await login(rest);
|
|
218
|
+
if (command === "logout") return logout();
|
|
209
219
|
if (command === "whoami") return whoami();
|
|
210
220
|
if (command === "publish") return await publish(rest);
|
|
211
221
|
if (command === "mcp") return await mcp(rest);
|
|
212
|
-
stdout("Usage: artifactly <login|whoami|publish|mcp>");
|
|
222
|
+
stdout("Usage: artifactly <login|logout|whoami|publish|mcp>");
|
|
213
223
|
} catch (error) {
|
|
214
224
|
const message = error instanceof Error ? error.message : String(error);
|
|
215
225
|
stderr(message);
|
|
@@ -42,9 +42,13 @@ describe("Artifactly MCP stdio server helpers", () => {
|
|
|
42
42
|
"artifactly_auth_status",
|
|
43
43
|
"artifactly_request_login_code",
|
|
44
44
|
"artifactly_verify_login_code",
|
|
45
|
+
"artifactly_logout",
|
|
45
46
|
"artifactly_publish_markdown",
|
|
46
47
|
"artifactly_list_artifacts",
|
|
47
48
|
"artifactly_get_artifact",
|
|
49
|
+
"artifactly_update_artifact",
|
|
50
|
+
"artifactly_delete_artifact",
|
|
51
|
+
"artifactly_publish_file",
|
|
48
52
|
]);
|
|
49
53
|
});
|
|
50
54
|
|
|
@@ -92,6 +92,7 @@ const verifyLoginCodeSchema = z.object({
|
|
|
92
92
|
email: z.string().email(),
|
|
93
93
|
code: z.string().min(4),
|
|
94
94
|
});
|
|
95
|
+
const logoutSchema = z.object({});
|
|
95
96
|
const publishMarkdownSchema = z.object({
|
|
96
97
|
title: z.string().min(1),
|
|
97
98
|
markdown: z.string().min(1),
|
|
@@ -101,14 +102,36 @@ const listArtifactsSchema = z.object({});
|
|
|
101
102
|
const getArtifactSchema = z.object({
|
|
102
103
|
artifactId: z.string().min(1),
|
|
103
104
|
});
|
|
105
|
+
const updateArtifactSchema = z.object({
|
|
106
|
+
artifactId: z.string().min(1),
|
|
107
|
+
title: z.string().min(1).optional(),
|
|
108
|
+
markdown: z.string().min(1).optional(),
|
|
109
|
+
content: z.string().min(1).optional(),
|
|
110
|
+
description: z.string().min(1).optional(),
|
|
111
|
+
visibility: z.enum(["public", "unlisted", "private", "gated"]).optional(),
|
|
112
|
+
accessList: z.array(z.string().email()).optional(),
|
|
113
|
+
});
|
|
114
|
+
const deleteArtifactSchema = z.object({
|
|
115
|
+
artifactId: z.string().min(1),
|
|
116
|
+
});
|
|
117
|
+
const publishFileSchema = z.object({
|
|
118
|
+
path: z.string().min(1),
|
|
119
|
+
title: z.string().min(1).optional(),
|
|
120
|
+
mimeType: z.string().min(1).optional(),
|
|
121
|
+
visibility: z.enum(["public", "unlisted", "private", "gated"]).optional(),
|
|
122
|
+
});
|
|
104
123
|
|
|
105
124
|
const toolSchemas = {
|
|
106
125
|
artifactly_auth_status: authStatusSchema,
|
|
107
126
|
artifactly_request_login_code: requestLoginCodeSchema,
|
|
108
127
|
artifactly_verify_login_code: verifyLoginCodeSchema,
|
|
128
|
+
artifactly_logout: logoutSchema,
|
|
109
129
|
artifactly_publish_markdown: publishMarkdownSchema,
|
|
110
130
|
artifactly_list_artifacts: listArtifactsSchema,
|
|
111
131
|
artifactly_get_artifact: getArtifactSchema,
|
|
132
|
+
artifactly_update_artifact: updateArtifactSchema,
|
|
133
|
+
artifactly_delete_artifact: deleteArtifactSchema,
|
|
134
|
+
artifactly_publish_file: publishFileSchema,
|
|
112
135
|
} as const;
|
|
113
136
|
|
|
114
137
|
export function createArtifactlyMcpServer() {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
|
|
@@ -13,9 +13,13 @@ describe("Artifactly MCP tools", () => {
|
|
|
13
13
|
"artifactly_auth_status",
|
|
14
14
|
"artifactly_request_login_code",
|
|
15
15
|
"artifactly_verify_login_code",
|
|
16
|
+
"artifactly_logout",
|
|
16
17
|
"artifactly_publish_markdown",
|
|
17
18
|
"artifactly_list_artifacts",
|
|
18
19
|
"artifactly_get_artifact",
|
|
20
|
+
"artifactly_update_artifact",
|
|
21
|
+
"artifactly_delete_artifact",
|
|
22
|
+
"artifactly_publish_file",
|
|
19
23
|
]);
|
|
20
24
|
});
|
|
21
25
|
|
|
@@ -44,6 +48,72 @@ describe("Artifactly MCP tools", () => {
|
|
|
44
48
|
rmSync(dir, { recursive: true, force: true });
|
|
45
49
|
});
|
|
46
50
|
|
|
51
|
+
it("can validate auth status against the Artifactly API", async () => {
|
|
52
|
+
const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
|
|
53
|
+
const credentialPath = join(dir, "credentials.json");
|
|
54
|
+
const requests: Array<{ method: string; path: string; apiKey?: string }> = [];
|
|
55
|
+
writeCredentials(
|
|
56
|
+
{
|
|
57
|
+
apiBaseUrl: "https://artifactly.example",
|
|
58
|
+
apiKey: "art_test_secret",
|
|
59
|
+
email: "agent@example.com",
|
|
60
|
+
workspace: "agent",
|
|
61
|
+
},
|
|
62
|
+
credentialPath,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
await expect(
|
|
66
|
+
callArtifactlyMcpTool({
|
|
67
|
+
name: "artifactly_auth_status",
|
|
68
|
+
credentialPath,
|
|
69
|
+
arguments: { validate: true },
|
|
70
|
+
requestJson: async (method, path, _body, options) => {
|
|
71
|
+
requests.push({ method, path, apiKey: options?.apiKey });
|
|
72
|
+
return { artifacts: [] };
|
|
73
|
+
},
|
|
74
|
+
}),
|
|
75
|
+
).resolves.toMatchObject({
|
|
76
|
+
authenticated: true,
|
|
77
|
+
serverAuthenticated: true,
|
|
78
|
+
validationError: null,
|
|
79
|
+
});
|
|
80
|
+
expect(requests).toEqual([
|
|
81
|
+
{ method: "GET", path: "/api/artifacts", apiKey: "art_test_secret" },
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
rmSync(dir, { recursive: true, force: true });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("gives complete login guidance when protected tools lack credentials", async () => {
|
|
88
|
+
await expect(
|
|
89
|
+
callArtifactlyMcpTool({ name: "artifactly_list_artifacts", credentialPath: "/tmp/nope.json" }),
|
|
90
|
+
).rejects.toThrow(/artifactly_request_login_code[\s\S]*artifactly_verify_login_code/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("logs out by clearing the shared credential store", async () => {
|
|
94
|
+
const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
|
|
95
|
+
const credentialPath = join(dir, "credentials.json");
|
|
96
|
+
writeCredentials(
|
|
97
|
+
{
|
|
98
|
+
apiBaseUrl: "https://artifactly.example",
|
|
99
|
+
apiKey: "art_t...et",
|
|
100
|
+
email: "agent@example.com",
|
|
101
|
+
workspace: "agent",
|
|
102
|
+
},
|
|
103
|
+
credentialPath,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
await expect(
|
|
107
|
+
callArtifactlyMcpTool({ name: "artifactly_logout", credentialPath }),
|
|
108
|
+
).resolves.toEqual({ ok: true, authenticated: false });
|
|
109
|
+
expect(existsSync(credentialPath)).toBe(false);
|
|
110
|
+
await expect(
|
|
111
|
+
callArtifactlyMcpTool({ name: "artifactly_auth_status", credentialPath }),
|
|
112
|
+
).resolves.toEqual({ authenticated: false, apiBaseUrl: "https://artifactly.io" });
|
|
113
|
+
|
|
114
|
+
rmSync(dir, { recursive: true, force: true });
|
|
115
|
+
});
|
|
116
|
+
|
|
47
117
|
it("publishes markdown through the Artifactly API without returning secrets", async () => {
|
|
48
118
|
const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
|
|
49
119
|
const credentialPath = join(dir, "credentials.json");
|
|
@@ -88,4 +158,164 @@ describe("Artifactly MCP tools", () => {
|
|
|
88
158
|
|
|
89
159
|
rmSync(dir, { recursive: true, force: true });
|
|
90
160
|
});
|
|
161
|
+
|
|
162
|
+
it("lists, gets, updates, and deletes artifacts through authenticated API requests", async () => {
|
|
163
|
+
const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
|
|
164
|
+
const credentialPath = join(dir, "credentials.json");
|
|
165
|
+
const requests: Array<{ method: string; path: string; body?: unknown; apiKey?: string }> = [];
|
|
166
|
+
writeCredentials(
|
|
167
|
+
{
|
|
168
|
+
apiBaseUrl: "https://artifactly.example",
|
|
169
|
+
apiKey: "art_t...et",
|
|
170
|
+
email: "agent@example.com",
|
|
171
|
+
workspace: "agent",
|
|
172
|
+
},
|
|
173
|
+
credentialPath,
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const requestJson = async (method: string, path: string, body?: unknown, options?: { apiKey?: string }) => {
|
|
177
|
+
requests.push({ method, path, body, apiKey: options?.apiKey });
|
|
178
|
+
if (method === "GET" && path === "/api/artifacts") {
|
|
179
|
+
return { artifacts: [{ id: "artifact_1", title: "Hello" }] };
|
|
180
|
+
}
|
|
181
|
+
if (method === "GET" && path === "/api/artifacts/artifact_1") {
|
|
182
|
+
return { artifact: { id: "artifact_1", title: "Hello" } };
|
|
183
|
+
}
|
|
184
|
+
if (method === "PATCH" && path === "/api/artifacts/artifact_1") {
|
|
185
|
+
return { artifact: { id: "artifact_1", title: "Updated", visibility: "private" } };
|
|
186
|
+
}
|
|
187
|
+
return { ok: true, artifactId: "artifact_1" };
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
await expect(
|
|
191
|
+
callArtifactlyMcpTool({ name: "artifactly_list_artifacts", credentialPath, requestJson }),
|
|
192
|
+
).resolves.toEqual({ artifacts: [{ id: "artifact_1", title: "Hello" }] });
|
|
193
|
+
await expect(
|
|
194
|
+
callArtifactlyMcpTool({
|
|
195
|
+
name: "artifactly_get_artifact",
|
|
196
|
+
credentialPath,
|
|
197
|
+
arguments: { artifactId: "artifact_1" },
|
|
198
|
+
requestJson,
|
|
199
|
+
}),
|
|
200
|
+
).resolves.toEqual({ artifact: { id: "artifact_1", title: "Hello" } });
|
|
201
|
+
await expect(
|
|
202
|
+
callArtifactlyMcpTool({
|
|
203
|
+
name: "artifactly_update_artifact",
|
|
204
|
+
credentialPath,
|
|
205
|
+
arguments: {
|
|
206
|
+
artifactId: "artifact_1",
|
|
207
|
+
title: "Updated",
|
|
208
|
+
visibility: "private",
|
|
209
|
+
accessList: ["agent@example.com"],
|
|
210
|
+
},
|
|
211
|
+
requestJson,
|
|
212
|
+
}),
|
|
213
|
+
).resolves.toEqual({ artifact: { id: "artifact_1", title: "Updated", visibility: "private" } });
|
|
214
|
+
await expect(
|
|
215
|
+
callArtifactlyMcpTool({
|
|
216
|
+
name: "artifactly_delete_artifact",
|
|
217
|
+
credentialPath,
|
|
218
|
+
arguments: { artifactId: "artifact_1" },
|
|
219
|
+
requestJson,
|
|
220
|
+
}),
|
|
221
|
+
).resolves.toEqual({ ok: true, artifactId: "artifact_1" });
|
|
222
|
+
|
|
223
|
+
expect(requests).toEqual([
|
|
224
|
+
{ method: "GET", path: "/api/artifacts", apiKey: "art_t...et", body: undefined },
|
|
225
|
+
{ method: "GET", path: "/api/artifacts/artifact_1", apiKey: "art_t...et", body: undefined },
|
|
226
|
+
{
|
|
227
|
+
method: "PATCH",
|
|
228
|
+
path: "/api/artifacts/artifact_1",
|
|
229
|
+
apiKey: "art_t...et",
|
|
230
|
+
body: { title: "Updated", visibility: "private", accessList: ["agent@example.com"] },
|
|
231
|
+
},
|
|
232
|
+
{ method: "DELETE", path: "/api/artifacts/artifact_1", apiKey: "art_t...et", body: undefined },
|
|
233
|
+
]);
|
|
234
|
+
rmSync(dir, { recursive: true, force: true });
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("publishes local HTML and PDF files through the artifact API", async () => {
|
|
238
|
+
const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
|
|
239
|
+
const credentialPath = join(dir, "credentials.json");
|
|
240
|
+
const htmlPath = join(dir, "hello.html");
|
|
241
|
+
const pdfPath = join(dir, "hello.pdf");
|
|
242
|
+
const requests: Array<{ path: string; body: unknown; apiKey?: string }> = [];
|
|
243
|
+
writeFileSync(htmlPath, "<h1>Hello</h1>");
|
|
244
|
+
writeFileSync(pdfPath, Buffer.from("%PDF-1.4\nhello"));
|
|
245
|
+
writeCredentials(
|
|
246
|
+
{
|
|
247
|
+
apiBaseUrl: "https://artifactly.example",
|
|
248
|
+
apiKey: "art_t...et",
|
|
249
|
+
email: "agent@example.com",
|
|
250
|
+
workspace: "agent",
|
|
251
|
+
},
|
|
252
|
+
credentialPath,
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
const postJson = async (path: string, body: unknown, options?: { apiKey?: string }) => {
|
|
256
|
+
requests.push({ path, body, apiKey: options?.apiKey });
|
|
257
|
+
return { id: "artifact_1", url: "https://artifactly.example/share/agent/hello" };
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
await callArtifactlyMcpTool({
|
|
261
|
+
name: "artifactly_publish_file",
|
|
262
|
+
credentialPath,
|
|
263
|
+
arguments: { path: htmlPath, visibility: "public" },
|
|
264
|
+
postJson,
|
|
265
|
+
});
|
|
266
|
+
await callArtifactlyMcpTool({
|
|
267
|
+
name: "artifactly_publish_file",
|
|
268
|
+
credentialPath,
|
|
269
|
+
arguments: { path: pdfPath, title: "PDF Hello", mimeType: "application/pdf" },
|
|
270
|
+
postJson,
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
expect(requests).toEqual([
|
|
274
|
+
{
|
|
275
|
+
path: "/api/artifacts",
|
|
276
|
+
apiKey: "art_t...et",
|
|
277
|
+
body: {
|
|
278
|
+
title: "hello",
|
|
279
|
+
kind: "html",
|
|
280
|
+
content: "<h1>Hello</h1>",
|
|
281
|
+
visibility: "public",
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
path: "/api/artifacts",
|
|
286
|
+
apiKey: "art_t...et",
|
|
287
|
+
body: {
|
|
288
|
+
title: "PDF Hello",
|
|
289
|
+
kind: "file",
|
|
290
|
+
storageUrl: "data:application/pdf;base64,JVBERi0xLjQKaGVsbG8=",
|
|
291
|
+
visibility: "unlisted",
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
]);
|
|
295
|
+
rmSync(dir, { recursive: true, force: true });
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("returns a friendly error when publishing a missing file", async () => {
|
|
299
|
+
const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
|
|
300
|
+
const credentialPath = join(dir, "credentials.json");
|
|
301
|
+
writeCredentials(
|
|
302
|
+
{
|
|
303
|
+
apiBaseUrl: "https://artifactly.example",
|
|
304
|
+
apiKey: "art_test_secret",
|
|
305
|
+
email: "agent@example.com",
|
|
306
|
+
workspace: "agent",
|
|
307
|
+
},
|
|
308
|
+
credentialPath,
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
await expect(
|
|
312
|
+
callArtifactlyMcpTool({
|
|
313
|
+
name: "artifactly_publish_file",
|
|
314
|
+
credentialPath,
|
|
315
|
+
arguments: { path: join(dir, "missing.pdf") },
|
|
316
|
+
}),
|
|
317
|
+
).rejects.toThrow(/File not found.*missing\.pdf/);
|
|
318
|
+
|
|
319
|
+
rmSync(dir, { recursive: true, force: true });
|
|
320
|
+
});
|
|
91
321
|
});
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { basename, extname } from "node:path";
|
|
3
|
+
|
|
1
4
|
import {
|
|
5
|
+
clearCredentials,
|
|
2
6
|
createDefaultPostJson,
|
|
3
7
|
getDefaultCredentialPath,
|
|
4
8
|
readCredentials,
|
|
@@ -18,8 +22,41 @@ type CallToolInput = {
|
|
|
18
22
|
credentialPath?: string;
|
|
19
23
|
apiBaseUrl?: string;
|
|
20
24
|
postJson?: PostJson;
|
|
25
|
+
requestJson?: RequestJson;
|
|
21
26
|
};
|
|
22
27
|
|
|
28
|
+
type RequestJson = (
|
|
29
|
+
method: string,
|
|
30
|
+
path: string,
|
|
31
|
+
body?: unknown,
|
|
32
|
+
options?: { apiKey?: string },
|
|
33
|
+
) => Promise<unknown>;
|
|
34
|
+
|
|
35
|
+
function createDefaultRequestJson(apiBaseUrl: string): RequestJson {
|
|
36
|
+
return async (method, path, body, options) => {
|
|
37
|
+
const response = await fetch(`${apiBaseUrl}${path}`, {
|
|
38
|
+
method,
|
|
39
|
+
headers: {
|
|
40
|
+
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
41
|
+
...(options?.apiKey ? { authorization: `Bearer ${options.apiKey}` } : {}),
|
|
42
|
+
},
|
|
43
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
44
|
+
});
|
|
45
|
+
const payload = (await response.json().catch(() => ({}))) as unknown;
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
const message =
|
|
48
|
+
typeof payload === "object" &&
|
|
49
|
+
payload !== null &&
|
|
50
|
+
"error" in payload &&
|
|
51
|
+
typeof payload.error === "string"
|
|
52
|
+
? payload.error
|
|
53
|
+
: `Artifactly API request failed with ${response.status}`;
|
|
54
|
+
throw new Error(message);
|
|
55
|
+
}
|
|
56
|
+
return payload;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
23
60
|
function stringArg(
|
|
24
61
|
args: Record<string, unknown> | undefined,
|
|
25
62
|
key: string,
|
|
@@ -28,6 +65,14 @@ function stringArg(
|
|
|
28
65
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
29
66
|
}
|
|
30
67
|
|
|
68
|
+
function optionalStringArg(
|
|
69
|
+
args: Record<string, unknown> | undefined,
|
|
70
|
+
key: string,
|
|
71
|
+
): string | undefined {
|
|
72
|
+
const value = args?.[key];
|
|
73
|
+
return typeof value === "string" ? value.trim() : undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
31
76
|
function requireStringArg(
|
|
32
77
|
args: Record<string, unknown> | undefined,
|
|
33
78
|
key: string,
|
|
@@ -39,6 +84,17 @@ function requireStringArg(
|
|
|
39
84
|
return value;
|
|
40
85
|
}
|
|
41
86
|
|
|
87
|
+
function stringArrayArg(
|
|
88
|
+
args: Record<string, unknown> | undefined,
|
|
89
|
+
key: string,
|
|
90
|
+
): string[] | undefined {
|
|
91
|
+
const value = args?.[key];
|
|
92
|
+
if (!Array.isArray(value)) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
return value.filter((item): item is string => typeof item === "string");
|
|
96
|
+
}
|
|
97
|
+
|
|
42
98
|
function objectResult(value: unknown): Record<string, unknown> {
|
|
43
99
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
44
100
|
throw new Error("Artifactly API returned an unexpected response.");
|
|
@@ -46,12 +102,58 @@ function objectResult(value: unknown): Record<string, unknown> {
|
|
|
46
102
|
return value as Record<string, unknown>;
|
|
47
103
|
}
|
|
48
104
|
|
|
105
|
+
const LOGIN_GUIDANCE =
|
|
106
|
+
"Artifactly credentials are not configured. Run artifactly_request_login_code first, then run artifactly_verify_login_code with the emailed code.";
|
|
107
|
+
|
|
108
|
+
function booleanArg(args: Record<string, unknown> | undefined, key: string): boolean {
|
|
109
|
+
return args?.[key] === true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function readFileForPublish(path: string): Buffer {
|
|
113
|
+
try {
|
|
114
|
+
return readFileSync(path);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
const code = typeof error === "object" && error && "code" in error ? String(error.code) : "";
|
|
117
|
+
if (code === "ENOENT") {
|
|
118
|
+
throw new Error(`File not found: ${path}`);
|
|
119
|
+
}
|
|
120
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
121
|
+
throw new Error(`File is not readable: ${path}`);
|
|
122
|
+
}
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function titleFromPath(path: string): string {
|
|
128
|
+
const name = basename(path, extname(path));
|
|
129
|
+
return name || "artifact";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function inferMimeType(path: string): string {
|
|
133
|
+
const extension = extname(path).toLowerCase();
|
|
134
|
+
if (extension === ".pdf") return "application/pdf";
|
|
135
|
+
if (extension === ".html" || extension === ".htm") return "text/html";
|
|
136
|
+
if (extension === ".json") return "application/json";
|
|
137
|
+
if (extension === ".md" || extension === ".markdown") return "text/markdown";
|
|
138
|
+
if (extension === ".txt") return "text/plain";
|
|
139
|
+
return "application/octet-stream";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function kindForMime(path: string, mimeType: string) {
|
|
143
|
+
const extension = extname(path).toLowerCase();
|
|
144
|
+
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";
|
|
148
|
+
return "file";
|
|
149
|
+
}
|
|
150
|
+
|
|
49
151
|
export function listArtifactlyMcpTools(): ArtifactlyMcpTool[] {
|
|
50
152
|
return [
|
|
51
153
|
{
|
|
52
154
|
name: "artifactly_auth_status",
|
|
53
155
|
description: "Check whether local Artifactly credentials are configured.",
|
|
54
|
-
inputSchema: { type: "object", properties: {} },
|
|
156
|
+
inputSchema: { type: "object", properties: { validate: { type: "boolean" } } },
|
|
55
157
|
},
|
|
56
158
|
{
|
|
57
159
|
name: "artifactly_request_login_code",
|
|
@@ -72,6 +174,11 @@ export function listArtifactlyMcpTools(): ArtifactlyMcpTool[] {
|
|
|
72
174
|
required: ["email", "code"],
|
|
73
175
|
},
|
|
74
176
|
},
|
|
177
|
+
{
|
|
178
|
+
name: "artifactly_logout",
|
|
179
|
+
description: "Log out by clearing the local Artifactly credential store.",
|
|
180
|
+
inputSchema: { type: "object", properties: {} },
|
|
181
|
+
},
|
|
75
182
|
{
|
|
76
183
|
name: "artifactly_publish_markdown",
|
|
77
184
|
description: "Publish markdown through the Artifactly API using stored credentials.",
|
|
@@ -99,6 +206,46 @@ export function listArtifactlyMcpTools(): ArtifactlyMcpTool[] {
|
|
|
99
206
|
required: ["artifactId"],
|
|
100
207
|
},
|
|
101
208
|
},
|
|
209
|
+
{
|
|
210
|
+
name: "artifactly_update_artifact",
|
|
211
|
+
description: "Update an existing Artifactly artifact, including visibility and gated access emails.",
|
|
212
|
+
inputSchema: {
|
|
213
|
+
type: "object",
|
|
214
|
+
properties: {
|
|
215
|
+
artifactId: { type: "string" },
|
|
216
|
+
title: { type: "string" },
|
|
217
|
+
markdown: { type: "string" },
|
|
218
|
+
content: { type: "string" },
|
|
219
|
+
description: { type: "string" },
|
|
220
|
+
visibility: { type: "string", enum: ["public", "unlisted", "private", "gated"] },
|
|
221
|
+
accessList: { type: "array", items: { type: "string" } },
|
|
222
|
+
},
|
|
223
|
+
required: ["artifactId"],
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
name: "artifactly_delete_artifact",
|
|
228
|
+
description: "Delete an existing Artifactly artifact owned by the authenticated account.",
|
|
229
|
+
inputSchema: {
|
|
230
|
+
type: "object",
|
|
231
|
+
properties: { artifactId: { type: "string" } },
|
|
232
|
+
required: ["artifactId"],
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
name: "artifactly_publish_file",
|
|
237
|
+
description: "Publish a local PDF, HTML, JSON, Markdown, or other file through the Artifactly API.",
|
|
238
|
+
inputSchema: {
|
|
239
|
+
type: "object",
|
|
240
|
+
properties: {
|
|
241
|
+
path: { type: "string" },
|
|
242
|
+
title: { type: "string" },
|
|
243
|
+
mimeType: { type: "string" },
|
|
244
|
+
visibility: { type: "string", enum: ["public", "unlisted", "private", "gated"] },
|
|
245
|
+
},
|
|
246
|
+
required: ["path"],
|
|
247
|
+
},
|
|
248
|
+
},
|
|
102
249
|
];
|
|
103
250
|
}
|
|
104
251
|
|
|
@@ -107,16 +254,32 @@ export async function callArtifactlyMcpTool(input: CallToolInput) {
|
|
|
107
254
|
const credentials = readCredentials(credentialPath);
|
|
108
255
|
const apiBaseUrl = input.apiBaseUrl ?? credentials?.apiBaseUrl ?? "https://artifactly.io";
|
|
109
256
|
const postJson = input.postJson ?? createDefaultPostJson(apiBaseUrl);
|
|
257
|
+
const requestJson = input.requestJson ?? createDefaultRequestJson(apiBaseUrl);
|
|
110
258
|
|
|
111
259
|
if (input.name === "artifactly_auth_status") {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
:
|
|
260
|
+
if (!credentials) {
|
|
261
|
+
return { authenticated: false, apiBaseUrl };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const result: Record<string, unknown> = {
|
|
265
|
+
authenticated: true,
|
|
266
|
+
apiBaseUrl: credentials.apiBaseUrl,
|
|
267
|
+
email: credentials.email,
|
|
268
|
+
workspace: credentials.workspace,
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
if (booleanArg(input.arguments, "validate")) {
|
|
272
|
+
try {
|
|
273
|
+
await requestJson("GET", "/api/artifacts", undefined, { apiKey: credentials.apiKey });
|
|
274
|
+
result.serverAuthenticated = true;
|
|
275
|
+
result.validationError = null;
|
|
276
|
+
} catch (error) {
|
|
277
|
+
result.serverAuthenticated = false;
|
|
278
|
+
result.validationError = error instanceof Error ? error.message : "Validation failed";
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return result;
|
|
120
283
|
}
|
|
121
284
|
|
|
122
285
|
if (input.name === "artifactly_request_login_code") {
|
|
@@ -155,9 +318,14 @@ export async function callArtifactlyMcpTool(input: CallToolInput) {
|
|
|
155
318
|
};
|
|
156
319
|
}
|
|
157
320
|
|
|
321
|
+
if (input.name === "artifactly_logout") {
|
|
322
|
+
clearCredentials(credentialPath);
|
|
323
|
+
return { ok: true, authenticated: false };
|
|
324
|
+
}
|
|
325
|
+
|
|
158
326
|
if (input.name === "artifactly_publish_markdown") {
|
|
159
327
|
if (!credentials) {
|
|
160
|
-
throw new Error(
|
|
328
|
+
throw new Error(LOGIN_GUIDANCE);
|
|
161
329
|
}
|
|
162
330
|
const title = requireStringArg(input.arguments, "title");
|
|
163
331
|
const content = requireStringArg(input.arguments, "markdown");
|
|
@@ -172,17 +340,83 @@ export async function callArtifactlyMcpTool(input: CallToolInput) {
|
|
|
172
340
|
}
|
|
173
341
|
|
|
174
342
|
if (input.name === "artifactly_list_artifacts") {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
};
|
|
343
|
+
if (!credentials) {
|
|
344
|
+
throw new Error(LOGIN_GUIDANCE);
|
|
345
|
+
}
|
|
346
|
+
return objectResult(await requestJson("GET", "/api/artifacts", undefined, { apiKey: credentials.apiKey }));
|
|
179
347
|
}
|
|
180
348
|
|
|
181
349
|
if (input.name === "artifactly_get_artifact") {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
350
|
+
if (!credentials) {
|
|
351
|
+
throw new Error(LOGIN_GUIDANCE);
|
|
352
|
+
}
|
|
353
|
+
const artifactId = requireStringArg(input.arguments, "artifactId");
|
|
354
|
+
return objectResult(await requestJson("GET", `/api/artifacts/${artifactId}`, undefined, { apiKey: credentials.apiKey }));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (input.name === "artifactly_update_artifact") {
|
|
358
|
+
if (!credentials) {
|
|
359
|
+
throw new Error(LOGIN_GUIDANCE);
|
|
360
|
+
}
|
|
361
|
+
const artifactId = requireStringArg(input.arguments, "artifactId");
|
|
362
|
+
const title = stringArg(input.arguments, "title");
|
|
363
|
+
const markdown = stringArg(input.arguments, "markdown");
|
|
364
|
+
const content = stringArg(input.arguments, "content");
|
|
365
|
+
const description = optionalStringArg(input.arguments, "description");
|
|
366
|
+
const visibility = stringArg(input.arguments, "visibility");
|
|
367
|
+
const accessList = stringArrayArg(input.arguments, "accessList");
|
|
368
|
+
return objectResult(
|
|
369
|
+
await requestJson(
|
|
370
|
+
"PATCH",
|
|
371
|
+
`/api/artifacts/${artifactId}`,
|
|
372
|
+
{
|
|
373
|
+
...(title ? { title } : {}),
|
|
374
|
+
...(markdown ? { markdown } : {}),
|
|
375
|
+
...(content ? { content } : {}),
|
|
376
|
+
...(description !== undefined ? { description } : {}),
|
|
377
|
+
...(visibility ? { visibility } : {}),
|
|
378
|
+
...(accessList ? { accessList } : {}),
|
|
379
|
+
},
|
|
380
|
+
{ apiKey: credentials.apiKey },
|
|
381
|
+
),
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (input.name === "artifactly_delete_artifact") {
|
|
386
|
+
if (!credentials) {
|
|
387
|
+
throw new Error(LOGIN_GUIDANCE);
|
|
388
|
+
}
|
|
389
|
+
const artifactId = requireStringArg(input.arguments, "artifactId");
|
|
390
|
+
return objectResult(await requestJson("DELETE", `/api/artifacts/${artifactId}`, undefined, { apiKey: credentials.apiKey }));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (input.name === "artifactly_publish_file") {
|
|
394
|
+
if (!credentials) {
|
|
395
|
+
throw new Error(LOGIN_GUIDANCE);
|
|
396
|
+
}
|
|
397
|
+
const path = requireStringArg(input.arguments, "path");
|
|
398
|
+
const title = stringArg(input.arguments, "title") ?? titleFromPath(path);
|
|
399
|
+
const mimeType = stringArg(input.arguments, "mimeType") ?? inferMimeType(path);
|
|
400
|
+
const visibility = stringArg(input.arguments, "visibility") ?? "unlisted";
|
|
401
|
+
const kind = kindForMime(path, mimeType);
|
|
402
|
+
const file = readFileForPublish(path);
|
|
403
|
+
const payload =
|
|
404
|
+
kind === "file"
|
|
405
|
+
? {
|
|
406
|
+
title,
|
|
407
|
+
kind,
|
|
408
|
+
storageUrl: `data:${mimeType};base64,${file.toString("base64")}`,
|
|
409
|
+
visibility,
|
|
410
|
+
}
|
|
411
|
+
: {
|
|
412
|
+
title,
|
|
413
|
+
kind,
|
|
414
|
+
content: file.toString("utf8"),
|
|
415
|
+
visibility,
|
|
416
|
+
};
|
|
417
|
+
return objectResult(
|
|
418
|
+
await postJson("/api/artifacts", payload, { apiKey: credentials.apiKey }),
|
|
419
|
+
);
|
|
186
420
|
}
|
|
187
421
|
|
|
188
422
|
throw new Error(`Unknown Artifactly MCP tool: ${input.name}`);
|