@artifactly/mcp 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,20 @@
1
- # Temporary Holding Version
1
+ # Artifactly
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Agent-first artifact sharing for AI-created content.
4
+
5
+ Production domain: artifactly.io
6
+
7
+ ## MVP direction
8
+
9
+ Artifactly is built around one core loop: an authenticated agent publishes an artifact, receives a stable share URL, and a human can view the artifact through a hosted page.
10
+
11
+ See `docs/database.md` for Supabase migration, owner seed, and API key setup.
12
+
13
+ ## Development
14
+
15
+ ```bash
16
+ npm run typecheck
17
+ npm test
18
+ npm run lint
19
+ npm run build
20
+ ```
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
7
+ const tsxCli = fileURLToPath(import.meta.resolve("tsx/cli"));
8
+ const entrypoint = resolve(packageRoot, "src/mcp/run-artifactly-mcp.ts");
9
+ const child = spawn(process.execPath, [tsxCli, entrypoint, ...process.argv.slice(2)], {
10
+ stdio: "inherit",
11
+ });
12
+
13
+ child.on("exit", (code, signal) => {
14
+ if (signal) {
15
+ process.kill(process.pid, signal);
16
+ return;
17
+ }
18
+ process.exitCode = code ?? 1;
19
+ });
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
7
+ const tsxCli = fileURLToPath(import.meta.resolve("tsx/cli"));
8
+ const entrypoint = resolve(packageRoot, "src/cli/run-artifactly.ts");
9
+ const child = spawn(process.execPath, [tsxCli, entrypoint, ...process.argv.slice(2)], {
10
+ stdio: "inherit",
11
+ });
12
+
13
+ child.on("exit", (code, signal) => {
14
+ if (signal) {
15
+ process.kill(process.pid, signal);
16
+ return;
17
+ }
18
+ process.exitCode = code ?? 1;
19
+ });
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "@artifactly/mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "files": [
6
+ "bin",
7
+ "src/cli",
8
+ "src/mcp",
9
+ "README.md"
10
+ ],
11
+ "bin": {
12
+ "artifactly": "bin/artifactly.mjs",
13
+ "artifactly-mcp": "bin/artifactly-mcp.mjs"
14
+ },
15
+ "scripts": {
16
+ "dev": "next dev",
17
+ "build": "next build",
18
+ "start": "next start",
19
+ "lint": "eslint",
20
+ "typecheck": "tsc --noEmit",
21
+ "test": "vitest run",
22
+ "artifactly": "tsx src/cli/run-artifactly.ts",
23
+ "generate-api-key": "node scripts/generate-api-key.mjs",
24
+ "supabase:start": "supabase start",
25
+ "supabase:reset": "supabase db reset",
26
+ "supabase:status": "supabase status"
27
+ },
28
+ "dependencies": {
29
+ "@modelcontextprotocol/sdk": "^1.32.1",
30
+ "@supabase/supabase-js": "^2.117.2",
31
+ "better-sqlite3": "^13.0.3",
32
+ "fflate": "^0.8.3",
33
+ "next": "16.3.8",
34
+ "react": "19.2.8",
35
+ "react-dom": "19.2.8",
36
+ "tsx": "^4.23.15",
37
+ "zod": "^4.6.5"
38
+ },
39
+ "devDependencies": {
40
+ "@tailwindcss/postcss": "^4",
41
+ "@types/better-sqlite3": "^9.6.0",
42
+ "@types/node": "^20",
43
+ "@types/react": "^19",
44
+ "@types/react-dom": "^19",
45
+ "eslint": "^9",
46
+ "eslint-config-next": "16.3.8",
47
+ "supabase": "^2.119.0",
48
+ "tailwindcss": "^4",
49
+ "typescript": "^5",
50
+ "vitest": "^4.1.10"
51
+ }
52
+ }
@@ -0,0 +1,90 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ import {
8
+ createArtifactlyCli,
9
+ getDefaultCredentialPath,
10
+ readCredentials,
11
+ } from "./artifactly-cli";
12
+
13
+ describe("artifactly CLI", () => {
14
+ it("stores verified login credentials without printing the API key", async () => {
15
+ const dir = mkdtempSync(join(tmpdir(), "artifactly-cli-"));
16
+ const credentialPath = join(dir, "credentials.json");
17
+ const requests: Array<{ path: string; body: unknown }> = [];
18
+ const output: string[] = [];
19
+
20
+ const cli = createArtifactlyCli({
21
+ credentialPath,
22
+ apiBaseUrl: "https://artifactly.example",
23
+ stdout: (line) => output.push(line),
24
+ prompt: async () => "123456",
25
+ postJson: async (path, body) => {
26
+ requests.push({ path, body });
27
+ if (path === "/api/auth/request-code") {
28
+ return { ok: true, email: "agent@example.com", expiresAt: "soon" };
29
+ }
30
+ return {
31
+ ok: true,
32
+ account: { email: "agent@example.com", workspace: "agent" },
33
+ apiKey: { secret: "art_test_secret", prefix: "art_test" },
34
+ };
35
+ },
36
+ });
37
+
38
+ await cli.run(["login", "--email", "agent@example.com"]);
39
+
40
+ expect(requests.map((request) => request.path)).toEqual([
41
+ "/api/auth/request-code",
42
+ "/api/auth/verify-code",
43
+ ]);
44
+ expect(readCredentials(credentialPath)).toEqual({
45
+ apiBaseUrl: "https://artifactly.example",
46
+ apiKey: "art_test_secret",
47
+ email: "agent@example.com",
48
+ workspace: "agent",
49
+ });
50
+ expect(output.join("\n")).not.toContain("art_test_secret");
51
+ expect(output.join("\n")).toContain("Logged in as agent@example.com");
52
+
53
+ rmSync(dir, { recursive: true, force: true });
54
+ });
55
+
56
+ it("publishes markdown using stored credentials", async () => {
57
+ const dir = mkdtempSync(join(tmpdir(), "artifactly-cli-"));
58
+ const credentialPath = join(dir, "credentials.json");
59
+ const output: string[] = [];
60
+ const cli = createArtifactlyCli({
61
+ credentialPath,
62
+ apiBaseUrl: "https://artifactly.example",
63
+ stdout: (line) => output.push(line),
64
+ postJson: async () => ({
65
+ id: "artifact_1",
66
+ url: "https://artifactly.example/share/agent/hello",
67
+ }),
68
+ });
69
+
70
+ await cli.run(["login", "--api-key", "art_test_secret", "--email", "agent@example.com"]);
71
+ await cli.run([
72
+ "publish",
73
+ "--title",
74
+ "Hello",
75
+ "--content",
76
+ "# Hello",
77
+ "--visibility",
78
+ "unlisted",
79
+ ]);
80
+
81
+ expect(output.at(-1)).toContain("https://artifactly.example/share/agent/hello");
82
+ rmSync(dir, { recursive: true, force: true });
83
+ });
84
+
85
+ it("reports the default credential path under the user home", () => {
86
+ expect(getDefaultCredentialPath("/Users/alice")).toBe(
87
+ "/Users/alice/.artifactly/credentials.json",
88
+ );
89
+ });
90
+ });
@@ -0,0 +1,221 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { stdin as input, stdout as output } from "node:process";
6
+
7
+ export type ArtifactlyCredentials = {
8
+ apiBaseUrl: string;
9
+ apiKey: string;
10
+ email?: string;
11
+ workspace?: string;
12
+ };
13
+
14
+ export type PostJson = (
15
+ path: string,
16
+ body: unknown,
17
+ options?: { apiKey?: string },
18
+ ) => Promise<unknown>;
19
+
20
+ type ArtifactlyCliOptions = {
21
+ apiBaseUrl?: string;
22
+ credentialPath?: string;
23
+ stdout?: (line: string) => void;
24
+ stderr?: (line: string) => void;
25
+ prompt?: (question: string) => Promise<string>;
26
+ postJson?: PostJson;
27
+ };
28
+
29
+ function parseFlags(argv: string[]): Record<string, string | true> {
30
+ const flags: Record<string, string | true> = {};
31
+ for (let index = 0; index < argv.length; index += 1) {
32
+ const arg = argv[index];
33
+ if (!arg?.startsWith("--")) {
34
+ continue;
35
+ }
36
+ const key = arg.slice(2);
37
+ const next = argv[index + 1];
38
+ if (!next || next.startsWith("--")) {
39
+ flags[key] = true;
40
+ } else {
41
+ flags[key] = next;
42
+ index += 1;
43
+ }
44
+ }
45
+ return flags;
46
+ }
47
+
48
+ function readStringFlag(
49
+ flags: Record<string, string | true>,
50
+ key: string,
51
+ ): string | undefined {
52
+ const value = flags[key];
53
+ return typeof value === "string" ? value : undefined;
54
+ }
55
+
56
+ export function getDefaultCredentialPath(home = homedir()) {
57
+ return join(home, ".artifactly", "credentials.json");
58
+ }
59
+
60
+ export function readCredentials(path = getDefaultCredentialPath()): ArtifactlyCredentials | null {
61
+ try {
62
+ return JSON.parse(readFileSync(path, "utf8")) as ArtifactlyCredentials;
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ export function writeCredentials(
69
+ credentials: ArtifactlyCredentials,
70
+ path = getDefaultCredentialPath(),
71
+ ) {
72
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
73
+ writeFileSync(path, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
74
+ }
75
+
76
+ async function defaultPrompt(question: string) {
77
+ const readline = createInterface({ input, output });
78
+ try {
79
+ return await readline.question(question);
80
+ } finally {
81
+ readline.close();
82
+ }
83
+ }
84
+
85
+ export function createDefaultPostJson(apiBaseUrl: string): PostJson {
86
+ return async (path, body, options) => {
87
+ const response = await fetch(`${apiBaseUrl}${path}`, {
88
+ method: "POST",
89
+ headers: {
90
+ "content-type": "application/json",
91
+ ...(options?.apiKey ? { authorization: `Bearer ${options.apiKey}` } : {}),
92
+ },
93
+ body: JSON.stringify(body),
94
+ });
95
+ const payload = (await response.json().catch(() => ({}))) as unknown;
96
+ if (!response.ok) {
97
+ const message =
98
+ typeof payload === "object" &&
99
+ payload !== null &&
100
+ "error" in payload &&
101
+ typeof payload.error === "string"
102
+ ? payload.error
103
+ : `Artifactly API request failed with ${response.status}`;
104
+ throw new Error(message);
105
+ }
106
+ return payload;
107
+ };
108
+ }
109
+
110
+ function assertObject(value: unknown): Record<string, unknown> {
111
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
112
+ throw new Error("Artifactly API returned an unexpected response.");
113
+ }
114
+ return value as Record<string, unknown>;
115
+ }
116
+
117
+ export function createArtifactlyCli(options: ArtifactlyCliOptions = {}) {
118
+ const apiBaseUrl =
119
+ options.apiBaseUrl ?? process.env.ARTIFACTLY_API_BASE ?? "https://artifactly.io";
120
+ const credentialPath = options.credentialPath ?? getDefaultCredentialPath();
121
+ const stdout = options.stdout ?? ((line: string) => console.log(line));
122
+ const stderr = options.stderr ?? ((line: string) => console.error(line));
123
+ const prompt = options.prompt ?? defaultPrompt;
124
+ const postJson = options.postJson ?? createDefaultPostJson(apiBaseUrl);
125
+
126
+ async function login(argv: string[]) {
127
+ const flags = parseFlags(argv);
128
+ const email = readStringFlag(flags, "email");
129
+ const apiKey = readStringFlag(flags, "api-key");
130
+
131
+ if (apiKey) {
132
+ writeCredentials({ apiBaseUrl, apiKey, email }, credentialPath);
133
+ stdout(`Saved Artifactly credentials${email ? ` for ${email}` : ""}.`);
134
+ return;
135
+ }
136
+
137
+ if (!email) {
138
+ throw new Error("Usage: artifactly login --email you@example.com");
139
+ }
140
+
141
+ await postJson("/api/auth/request-code", { email });
142
+ stdout(`Sent Artifactly login code to ${email}.`);
143
+ const code = (readStringFlag(flags, "code") ?? (await prompt("Code: "))).trim();
144
+ const response = assertObject(
145
+ await postJson("/api/auth/verify-code", { email, code }),
146
+ );
147
+ const apiKeyResponse = assertObject(response.apiKey);
148
+ const account = assertObject(response.account);
149
+ const secret = String(apiKeyResponse.secret ?? "");
150
+ if (!secret) {
151
+ throw new Error("Artifactly API did not return an API key.");
152
+ }
153
+ const workspace =
154
+ typeof account.workspace === "string" ? account.workspace : undefined;
155
+ writeCredentials({ apiBaseUrl, apiKey: secret, email, workspace }, credentialPath);
156
+ stdout(`Logged in as ${email}${workspace ? ` (${workspace})` : ""}.`);
157
+ }
158
+
159
+ function whoami() {
160
+ const credentials = readCredentials(credentialPath);
161
+ if (!credentials) {
162
+ throw new Error("Not logged in. Run artifactly login --email you@example.com.");
163
+ }
164
+ stdout(`API: ${credentials.apiBaseUrl}`);
165
+ stdout(`Email: ${credentials.email ?? "unknown"}`);
166
+ stdout(`Workspace: ${credentials.workspace ?? "unknown"}`);
167
+ }
168
+
169
+ async function publish(argv: string[]) {
170
+ const credentials = readCredentials(credentialPath);
171
+ if (!credentials) {
172
+ throw new Error("Not logged in. Run artifactly login --email you@example.com.");
173
+ }
174
+ const flags = parseFlags(argv);
175
+ const title = readStringFlag(flags, "title");
176
+ const content = readStringFlag(flags, "content");
177
+ const visibility = readStringFlag(flags, "visibility") ?? "unlisted";
178
+ const kind = readStringFlag(flags, "kind") ?? "markdown";
179
+ if (!title || !content) {
180
+ throw new Error(
181
+ "Usage: artifactly publish --title 'Title' --content '# Markdown' [--visibility unlisted]",
182
+ );
183
+ }
184
+ const response = assertObject(
185
+ await postJson(
186
+ "/api/artifacts",
187
+ { title, content, visibility, kind },
188
+ { apiKey: credentials.apiKey },
189
+ ),
190
+ );
191
+ stdout(`Published ${String(response.url ?? response.id ?? "artifact")}`);
192
+ }
193
+
194
+ async function mcp(argv: string[] = []) {
195
+ const { listArtifactlyMcpTools } = await import("../mcp/artifactly-mcp");
196
+ if (argv.includes("--list-tools")) {
197
+ stdout(JSON.stringify({ tools: listArtifactlyMcpTools() }, null, 2));
198
+ return;
199
+ }
200
+ stdout("Artifactly MCP tools are available.");
201
+ stdout("Use: artifactly mcp --list-tools");
202
+ stdout("Configure agents to call the Artifactly MCP entrypoint backed by ~/.artifactly/credentials.json.");
203
+ }
204
+
205
+ async function run(argv: string[]) {
206
+ const [command, ...rest] = argv;
207
+ try {
208
+ if (command === "login") return await login(rest);
209
+ if (command === "whoami") return whoami();
210
+ if (command === "publish") return await publish(rest);
211
+ if (command === "mcp") return await mcp(rest);
212
+ stdout("Usage: artifactly <login|whoami|publish|mcp>");
213
+ } catch (error) {
214
+ const message = error instanceof Error ? error.message : String(error);
215
+ stderr(message);
216
+ throw error;
217
+ }
218
+ }
219
+
220
+ return { run };
221
+ }
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ import { createArtifactlyCli } from "./artifactly-cli";
3
+
4
+ createArtifactlyCli().run(process.argv.slice(2)).catch(() => {
5
+ process.exitCode = 1;
6
+ });
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import packageJson from "../../package.json";
4
+ import {
5
+ createArtifactlyMcpCallResult,
6
+ getArtifactlyMcpInstallConfig,
7
+ listArtifactlyMcpServerTools,
8
+ } from "./artifactly-mcp-server";
9
+
10
+ describe("Artifactly MCP server packaging", () => {
11
+ it("exposes installable CLI bins for humans and MCP clients", () => {
12
+ expect(packageJson.bin).toEqual({
13
+ artifactly: "bin/artifactly.mjs",
14
+ "artifactly-mcp": "bin/artifactly-mcp.mjs",
15
+ });
16
+ expect(packageJson.dependencies).toHaveProperty("@modelcontextprotocol/sdk");
17
+ expect(packageJson.dependencies).toHaveProperty("tsx");
18
+ });
19
+
20
+ it("documents the npm/npx MCP client configuration", () => {
21
+ expect(getArtifactlyMcpInstallConfig()).toEqual({
22
+ packageName: "@artifactly/mcp",
23
+ command: "npx",
24
+ args: ["-y", "--package", "@artifactly/mcp", "artifactly-mcp"],
25
+ credentialPath: "~/.artifactly/credentials.json",
26
+ exampleConfig: {
27
+ mcpServers: {
28
+ artifactly: {
29
+ command: "npx",
30
+ args: ["-y", "--package", "@artifactly/mcp", "artifactly-mcp"],
31
+ },
32
+ },
33
+ },
34
+ });
35
+ });
36
+ });
37
+
38
+ describe("Artifactly MCP stdio server helpers", () => {
39
+ it("maps Artifactly tools to MCP-compatible tool definitions", () => {
40
+ expect(listArtifactlyMcpServerTools().map((tool) => tool.name)).toEqual([
41
+ "artifactly_auth_status",
42
+ "artifactly_request_login_code",
43
+ "artifactly_verify_login_code",
44
+ "artifactly_publish_markdown",
45
+ "artifactly_list_artifacts",
46
+ "artifactly_get_artifact",
47
+ ]);
48
+ });
49
+
50
+ it("returns MCP text content without leaking stored API keys", async () => {
51
+ const result = await createArtifactlyMcpCallResult({
52
+ name: "artifactly_auth_status",
53
+ credentialPath: "/tmp/does-not-exist-artifactly-credentials.json",
54
+ });
55
+
56
+ expect(result.content).toHaveLength(1);
57
+ expect(result.content[0]).toMatchObject({ type: "text" });
58
+ expect(result.content[0].text).toContain('"authenticated": false');
59
+ expect(result.content[0].text).not.toContain("art_");
60
+ });
61
+ });
@@ -0,0 +1,141 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { z } from "zod";
4
+
5
+ import { callArtifactlyMcpTool, listArtifactlyMcpTools } from "./artifactly-mcp.js";
6
+
7
+ export type ArtifactlyMcpInstallConfig = {
8
+ packageName: string;
9
+ command: string;
10
+ args: string[];
11
+ credentialPath: string;
12
+ exampleConfig: {
13
+ mcpServers: {
14
+ artifactly: {
15
+ command: string;
16
+ args: string[];
17
+ };
18
+ };
19
+ };
20
+ };
21
+
22
+ type McpTextContent = {
23
+ type: "text";
24
+ text: string;
25
+ };
26
+
27
+ export type ArtifactlyMcpCallResult = {
28
+ content: McpTextContent[];
29
+ isError?: boolean;
30
+ };
31
+
32
+ export function getArtifactlyMcpInstallConfig(): ArtifactlyMcpInstallConfig {
33
+ const args = ["-y", "--package", "@artifactly/mcp", "artifactly-mcp"];
34
+ return {
35
+ packageName: "@artifactly/mcp",
36
+ command: "npx",
37
+ args,
38
+ credentialPath: "~/.artifactly/credentials.json",
39
+ exampleConfig: {
40
+ mcpServers: {
41
+ artifactly: {
42
+ command: "npx",
43
+ args,
44
+ },
45
+ },
46
+ },
47
+ };
48
+ }
49
+
50
+ export function listArtifactlyMcpServerTools() {
51
+ return listArtifactlyMcpTools();
52
+ }
53
+
54
+ function resultToText(value: unknown): ArtifactlyMcpCallResult {
55
+ return {
56
+ content: [
57
+ {
58
+ type: "text",
59
+ text: JSON.stringify(value, null, 2),
60
+ },
61
+ ],
62
+ };
63
+ }
64
+
65
+ export async function createArtifactlyMcpCallResult(input: {
66
+ name: string;
67
+ arguments?: Record<string, unknown>;
68
+ credentialPath?: string;
69
+ apiBaseUrl?: string;
70
+ }): Promise<ArtifactlyMcpCallResult> {
71
+ try {
72
+ return resultToText(await callArtifactlyMcpTool(input));
73
+ } catch (error) {
74
+ return {
75
+ isError: true,
76
+ content: [
77
+ {
78
+ type: "text",
79
+ text: error instanceof Error ? error.message : String(error),
80
+ },
81
+ ],
82
+ };
83
+ }
84
+ }
85
+
86
+ const authStatusSchema = z.object({});
87
+ const requestLoginCodeSchema = z.object({
88
+ email: z.string().email(),
89
+ name: z.string().optional(),
90
+ });
91
+ const verifyLoginCodeSchema = z.object({
92
+ email: z.string().email(),
93
+ code: z.string().min(4),
94
+ });
95
+ const publishMarkdownSchema = z.object({
96
+ title: z.string().min(1),
97
+ markdown: z.string().min(1),
98
+ visibility: z.enum(["public", "unlisted", "private", "gated"]).optional(),
99
+ });
100
+ const listArtifactsSchema = z.object({});
101
+ const getArtifactSchema = z.object({
102
+ artifactId: z.string().min(1),
103
+ });
104
+
105
+ const toolSchemas = {
106
+ artifactly_auth_status: authStatusSchema,
107
+ artifactly_request_login_code: requestLoginCodeSchema,
108
+ artifactly_verify_login_code: verifyLoginCodeSchema,
109
+ artifactly_publish_markdown: publishMarkdownSchema,
110
+ artifactly_list_artifacts: listArtifactsSchema,
111
+ artifactly_get_artifact: getArtifactSchema,
112
+ } as const;
113
+
114
+ export function createArtifactlyMcpServer() {
115
+ const server = new McpServer({
116
+ name: "artifactly",
117
+ version: "0.1.1",
118
+ });
119
+
120
+ for (const tool of listArtifactlyMcpTools()) {
121
+ const schema = toolSchemas[tool.name as keyof typeof toolSchemas] ?? z.object({});
122
+ server.registerTool(
123
+ tool.name,
124
+ {
125
+ title: tool.name,
126
+ description: tool.description,
127
+ inputSchema: schema,
128
+ },
129
+ async (args: Record<string, unknown>) =>
130
+ createArtifactlyMcpCallResult({ name: tool.name, arguments: args }),
131
+ );
132
+ }
133
+
134
+ return server;
135
+ }
136
+
137
+ export async function runArtifactlyMcpStdioServer() {
138
+ const server = createArtifactlyMcpServer();
139
+ const transport = new StdioServerTransport();
140
+ await server.connect(transport);
141
+ }
@@ -0,0 +1,91 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ import { writeCredentials } from "../cli/artifactly-cli";
8
+ import { callArtifactlyMcpTool, listArtifactlyMcpTools } from "./artifactly-mcp";
9
+
10
+ describe("Artifactly MCP tools", () => {
11
+ it("exposes the initial agent-native tool list", () => {
12
+ expect(listArtifactlyMcpTools().map((tool) => tool.name)).toEqual([
13
+ "artifactly_auth_status",
14
+ "artifactly_request_login_code",
15
+ "artifactly_verify_login_code",
16
+ "artifactly_publish_markdown",
17
+ "artifactly_list_artifacts",
18
+ "artifactly_get_artifact",
19
+ ]);
20
+ });
21
+
22
+ it("reports auth status from the shared credential store", async () => {
23
+ const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
24
+ const credentialPath = join(dir, "credentials.json");
25
+ writeCredentials(
26
+ {
27
+ apiBaseUrl: "https://artifactly.example",
28
+ apiKey: "art_test_secret",
29
+ email: "agent@example.com",
30
+ workspace: "agent",
31
+ },
32
+ credentialPath,
33
+ );
34
+
35
+ await expect(
36
+ callArtifactlyMcpTool({ name: "artifactly_auth_status", credentialPath }),
37
+ ).resolves.toEqual({
38
+ authenticated: true,
39
+ apiBaseUrl: "https://artifactly.example",
40
+ email: "agent@example.com",
41
+ workspace: "agent",
42
+ });
43
+
44
+ rmSync(dir, { recursive: true, force: true });
45
+ });
46
+
47
+ it("publishes markdown through the Artifactly API without returning secrets", async () => {
48
+ const dir = mkdtempSync(join(tmpdir(), "artifactly-mcp-"));
49
+ const credentialPath = join(dir, "credentials.json");
50
+ const requests: Array<{ path: string; body: unknown; apiKey?: string }> = [];
51
+ writeCredentials(
52
+ {
53
+ apiBaseUrl: "https://artifactly.example",
54
+ apiKey: "art_test_secret",
55
+ email: "agent@example.com",
56
+ workspace: "agent",
57
+ },
58
+ credentialPath,
59
+ );
60
+
61
+ const result = await callArtifactlyMcpTool({
62
+ name: "artifactly_publish_markdown",
63
+ credentialPath,
64
+ arguments: { title: "Hello", markdown: "# Hello" },
65
+ postJson: async (path, body, options) => {
66
+ requests.push({ path, body, apiKey: options?.apiKey });
67
+ return { id: "artifact_1", url: "https://artifactly.example/share/agent/hello" };
68
+ },
69
+ });
70
+
71
+ expect(result).toEqual({
72
+ id: "artifact_1",
73
+ url: "https://artifactly.example/share/agent/hello",
74
+ });
75
+ expect(requests).toEqual([
76
+ {
77
+ path: "/api/artifacts",
78
+ apiKey: "art_test_secret",
79
+ body: {
80
+ title: "Hello",
81
+ content: "# Hello",
82
+ kind: "markdown",
83
+ visibility: "unlisted",
84
+ },
85
+ },
86
+ ]);
87
+ expect(JSON.stringify(result)).not.toContain("art_test_secret");
88
+
89
+ rmSync(dir, { recursive: true, force: true });
90
+ });
91
+ });
@@ -0,0 +1,189 @@
1
+ import {
2
+ createDefaultPostJson,
3
+ getDefaultCredentialPath,
4
+ readCredentials,
5
+ writeCredentials,
6
+ type PostJson,
7
+ } from "../cli/artifactly-cli.js";
8
+
9
+ export type ArtifactlyMcpTool = {
10
+ name: string;
11
+ description: string;
12
+ inputSchema: Record<string, unknown>;
13
+ };
14
+
15
+ type CallToolInput = {
16
+ name: string;
17
+ arguments?: Record<string, unknown>;
18
+ credentialPath?: string;
19
+ apiBaseUrl?: string;
20
+ postJson?: PostJson;
21
+ };
22
+
23
+ function stringArg(
24
+ args: Record<string, unknown> | undefined,
25
+ key: string,
26
+ ): string | undefined {
27
+ const value = args?.[key];
28
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
29
+ }
30
+
31
+ function requireStringArg(
32
+ args: Record<string, unknown> | undefined,
33
+ key: string,
34
+ ): string {
35
+ const value = stringArg(args, key);
36
+ if (!value) {
37
+ throw new Error(`Missing required argument: ${key}`);
38
+ }
39
+ return value;
40
+ }
41
+
42
+ function objectResult(value: unknown): Record<string, unknown> {
43
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
44
+ throw new Error("Artifactly API returned an unexpected response.");
45
+ }
46
+ return value as Record<string, unknown>;
47
+ }
48
+
49
+ export function listArtifactlyMcpTools(): ArtifactlyMcpTool[] {
50
+ return [
51
+ {
52
+ name: "artifactly_auth_status",
53
+ description: "Check whether local Artifactly credentials are configured.",
54
+ inputSchema: { type: "object", properties: {} },
55
+ },
56
+ {
57
+ name: "artifactly_request_login_code",
58
+ description: "Request a real Artifactly signup/login email code.",
59
+ inputSchema: {
60
+ type: "object",
61
+ properties: { email: { type: "string" }, name: { type: "string" } },
62
+ required: ["email"],
63
+ },
64
+ },
65
+ {
66
+ name: "artifactly_verify_login_code",
67
+ description:
68
+ "Verify an email code, create or resume the account, and store the returned API key locally.",
69
+ inputSchema: {
70
+ type: "object",
71
+ properties: { email: { type: "string" }, code: { type: "string" } },
72
+ required: ["email", "code"],
73
+ },
74
+ },
75
+ {
76
+ name: "artifactly_publish_markdown",
77
+ description: "Publish markdown through the Artifactly API using stored credentials.",
78
+ inputSchema: {
79
+ type: "object",
80
+ properties: {
81
+ title: { type: "string" },
82
+ markdown: { type: "string" },
83
+ visibility: { type: "string", enum: ["public", "unlisted", "private", "gated"] },
84
+ },
85
+ required: ["title", "markdown"],
86
+ },
87
+ },
88
+ {
89
+ name: "artifactly_list_artifacts",
90
+ description: "List recent Artifactly artifacts when the list API is available.",
91
+ inputSchema: { type: "object", properties: {} },
92
+ },
93
+ {
94
+ name: "artifactly_get_artifact",
95
+ description: "Get Artifactly artifact metadata when the lookup API is available.",
96
+ inputSchema: {
97
+ type: "object",
98
+ properties: { artifactId: { type: "string" } },
99
+ required: ["artifactId"],
100
+ },
101
+ },
102
+ ];
103
+ }
104
+
105
+ export async function callArtifactlyMcpTool(input: CallToolInput) {
106
+ const credentialPath = input.credentialPath ?? getDefaultCredentialPath();
107
+ const credentials = readCredentials(credentialPath);
108
+ const apiBaseUrl = input.apiBaseUrl ?? credentials?.apiBaseUrl ?? "https://artifactly.io";
109
+ const postJson = input.postJson ?? createDefaultPostJson(apiBaseUrl);
110
+
111
+ if (input.name === "artifactly_auth_status") {
112
+ return credentials
113
+ ? {
114
+ authenticated: true,
115
+ apiBaseUrl: credentials.apiBaseUrl,
116
+ email: credentials.email,
117
+ workspace: credentials.workspace,
118
+ }
119
+ : { authenticated: false, apiBaseUrl };
120
+ }
121
+
122
+ if (input.name === "artifactly_request_login_code") {
123
+ const email = requireStringArg(input.arguments, "email");
124
+ const name = stringArg(input.arguments, "name");
125
+ const response = objectResult(
126
+ await postJson("/api/auth/request-code", { email, ...(name ? { name } : {}) }),
127
+ );
128
+ return {
129
+ ok: Boolean(response.ok),
130
+ email: String(response.email ?? email),
131
+ expiresAt: String(response.expiresAt ?? ""),
132
+ };
133
+ }
134
+
135
+ if (input.name === "artifactly_verify_login_code") {
136
+ const email = requireStringArg(input.arguments, "email");
137
+ const code = requireStringArg(input.arguments, "code");
138
+ const response = objectResult(
139
+ await postJson("/api/auth/verify-code", { email, code }),
140
+ );
141
+ const apiKey = objectResult(response.apiKey);
142
+ const account = objectResult(response.account);
143
+ const secret = String(apiKey.secret ?? "");
144
+ if (!secret) {
145
+ throw new Error("Artifactly API did not return an API key.");
146
+ }
147
+ const workspace =
148
+ typeof account.workspace === "string" ? account.workspace : undefined;
149
+ writeCredentials({ apiBaseUrl, apiKey: secret, email, workspace }, credentialPath);
150
+ return {
151
+ authenticated: true,
152
+ email,
153
+ workspace,
154
+ apiKeyPrefix: typeof apiKey.prefix === "string" ? apiKey.prefix : undefined,
155
+ };
156
+ }
157
+
158
+ if (input.name === "artifactly_publish_markdown") {
159
+ if (!credentials) {
160
+ throw new Error("Artifactly credentials are not configured. Run artifactly_request_login_code first.");
161
+ }
162
+ const title = requireStringArg(input.arguments, "title");
163
+ const content = requireStringArg(input.arguments, "markdown");
164
+ const visibility = stringArg(input.arguments, "visibility") ?? "unlisted";
165
+ return objectResult(
166
+ await postJson(
167
+ "/api/artifacts",
168
+ { title, content, kind: "markdown", visibility },
169
+ { apiKey: credentials.apiKey },
170
+ ),
171
+ );
172
+ }
173
+
174
+ if (input.name === "artifactly_list_artifacts") {
175
+ return {
176
+ error:
177
+ "Artifact list API is not available yet. This MCP tool is reserved for the next API expansion.",
178
+ };
179
+ }
180
+
181
+ if (input.name === "artifactly_get_artifact") {
182
+ return {
183
+ error:
184
+ "Artifact lookup API is not available yet. This MCP tool is reserved for the next API expansion.",
185
+ };
186
+ }
187
+
188
+ throw new Error(`Unknown Artifactly MCP tool: ${input.name}`);
189
+ }
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { runArtifactlyMcpStdioServer } from "./artifactly-mcp-server";
3
+
4
+ runArtifactlyMcpStdioServer().catch((error) => {
5
+ console.error(error instanceof Error ? error.message : String(error));
6
+ process.exitCode = 1;
7
+ });