artifact-hub-mcp 1.0.0

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.
Files changed (2) hide show
  1. package/dist/client.js +307 -0
  2. package/package.json +33 -0
package/dist/client.js ADDED
@@ -0,0 +1,307 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ /**
5
+ * Artifact Hub MCP Client — standalone stdio server.
6
+ *
7
+ * Proxies MCP tool calls to the Artifact Hub REST API over HTTP.
8
+ * Reads local files from disk (fileLocalPath) and uploads as multipart — works
9
+ * even when the API server is remote/hosted.
10
+ *
11
+ * Environment variables:
12
+ * ARTIFACT_HUB_URL Base URL of the API (default: http://localhost:3000)
13
+ * ARTIFACT_HUB_API_KEY API key (default: changeme-local-dev)
14
+ * MCP_AUTHOR Default feedback author (default: MCP User)
15
+ *
16
+ * Claude Desktop config (local dev):
17
+ * {
18
+ * "mcpServers": {
19
+ * "artifact-hub": {
20
+ * "command": "/path/to/mcp/node_modules/.bin/ts-node",
21
+ * "args": ["--project", "/path/to/mcp/tsconfig.json", "/path/to/mcp/client.ts"],
22
+ * "env": {
23
+ * "ARTIFACT_HUB_URL": "http://localhost:3000",
24
+ * "ARTIFACT_HUB_API_KEY": "changeme-local-dev",
25
+ * "MCP_AUTHOR": "Your Name"
26
+ * }
27
+ * }
28
+ * }
29
+ * }
30
+ */
31
+ const fs_1 = require("fs");
32
+ const path_1 = require("path");
33
+ const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
34
+ const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
35
+ const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
36
+ // ─── Config ──────────────────────────────────────────────────────────────────
37
+ const BASE_URL = (process.env['ARTIFACT_HUB_URL'] ?? 'http://localhost:3000').replace(/\/$/, '');
38
+ const API_KEY = process.env['ARTIFACT_HUB_API_KEY'] ?? process.env['API_KEY'] ?? 'changeme-local-dev';
39
+ const DEFAULT_AUTHOR = process.env['MCP_AUTHOR'] ?? 'MCP User';
40
+ const MIME_BY_EXT = {
41
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
42
+ '.gif': 'image/gif', '.webp': 'image/webp',
43
+ '.pdf': 'application/pdf',
44
+ '.html': 'text/html', '.htm': 'text/html',
45
+ };
46
+ // ─── HTTP helpers ─────────────────────────────────────────────────────────────
47
+ const authHeader = { Authorization: `Bearer ${API_KEY}` };
48
+ async function apiGet(path) {
49
+ const resp = await fetch(`${BASE_URL}${path}`, { headers: authHeader });
50
+ if (!resp.ok)
51
+ throw new Error(`${resp.status} ${await resp.text()}`);
52
+ return resp.json();
53
+ }
54
+ async function apiPost(path, body) {
55
+ const resp = await fetch(`${BASE_URL}${path}`, {
56
+ method: 'POST',
57
+ headers: { ...authHeader, 'Content-Type': 'application/json' },
58
+ body: JSON.stringify(body),
59
+ });
60
+ if (!resp.ok)
61
+ throw new Error(`${resp.status} ${await resp.text()}`);
62
+ return resp.json();
63
+ }
64
+ async function uploadMultipart(artifactPath, fileBuffer, filename, mimetype, title, description, tags) {
65
+ const form = new FormData();
66
+ form.append('file', new Blob([new Uint8Array(fileBuffer)], { type: mimetype }), filename);
67
+ form.append('title', title);
68
+ if (description)
69
+ form.append('description', description);
70
+ if (tags && tags.length > 0)
71
+ form.append('tags', JSON.stringify(tags));
72
+ const resp = await fetch(`${BASE_URL}/artifacts/${artifactPath}`, {
73
+ method: 'POST',
74
+ headers: authHeader, // no Content-Type — fetch sets it with boundary automatically
75
+ body: form,
76
+ });
77
+ if (!resp.ok)
78
+ throw new Error(`${resp.status} ${await resp.text()}`);
79
+ return resp.json();
80
+ }
81
+ // ─── MCP Server ───────────────────────────────────────────────────────────────
82
+ const server = new index_js_1.Server({ name: 'artifact-hub', version: '1.0.0' }, { capabilities: { tools: {} } });
83
+ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
84
+ tools: [
85
+ {
86
+ name: 'get_catalog',
87
+ description: 'Returns the full artifact catalog as YAML: all folders (with allTags, artifactCount) ' +
88
+ 'and all artifacts (title, description, tags, path). Call this first.',
89
+ inputSchema: { type: 'object', properties: {} },
90
+ },
91
+ {
92
+ name: 'search_artifacts',
93
+ description: 'Search artifacts and folders by text. Matches titles, descriptions, tags, paths.',
94
+ inputSchema: {
95
+ type: 'object',
96
+ properties: {
97
+ query: { type: 'string', description: 'Search text' },
98
+ limit: { type: 'number', description: 'Max results (default 20)' },
99
+ },
100
+ required: ['query'],
101
+ },
102
+ },
103
+ {
104
+ name: 'browse_folder',
105
+ description: 'List subfolders and direct artifacts in a folder. Use path="" for root.',
106
+ inputSchema: {
107
+ type: 'object',
108
+ properties: {
109
+ path: { type: 'string', description: 'Folder path, or "" for root' },
110
+ cursor: { type: 'string', description: 'Pagination cursor from a previous call' },
111
+ },
112
+ required: ['path'],
113
+ },
114
+ },
115
+ {
116
+ name: 'get_artifact',
117
+ description: 'Get metadata, all feedback comments, and a 1-hour presigned view URL.',
118
+ inputSchema: {
119
+ type: 'object',
120
+ properties: {
121
+ path: { type: 'string', description: 'Full artifact path, e.g. "designs/hero-v2.png"' },
122
+ },
123
+ required: ['path'],
124
+ },
125
+ },
126
+ {
127
+ name: 'request_upload_url',
128
+ description: 'Request a presigned upload URL for a file attached to the chat (path is /mnt/user-data/uploads/...). ' +
129
+ 'Use this when the user drags/attaches a file into the chat. ' +
130
+ 'Returns an uploadUrl (valid 30 seconds) and a ready-to-run curl command. ' +
131
+ 'After calling this, immediately run the curl command to upload the file — do NOT read the file or use fileBase64.',
132
+ inputSchema: {
133
+ type: 'object',
134
+ properties: {
135
+ path: { type: 'string', description: 'Target storage path including filename, e.g. "designs/hero.png"' },
136
+ title: { type: 'string', description: 'Human-readable title' },
137
+ description: { type: 'string', description: 'Optional description' },
138
+ tags: { type: 'array', items: { type: 'string' }, description: 'Optional tags' },
139
+ contentType: { type: 'string', description: 'MIME type — auto-detected from extension if omitted' },
140
+ fileLocalPath: { type: 'string', description: 'The /mnt/user-data/... path of the file in the chat sandbox' },
141
+ },
142
+ required: ['path', 'title', 'fileLocalPath'],
143
+ },
144
+ },
145
+ {
146
+ name: 'publish_artifact',
147
+ description: 'Upload a new artifact from an explicit filesystem path (e.g. /Users/me/Downloads/file.pdf). ' +
148
+ 'Use fileLocalPath — pass the path string only, do NOT read bytes. ' +
149
+ 'If the file was dragged/attached into the chat (path is /mnt/user-data/...) use request_upload_url instead. ' +
150
+ 'Read and analyze the file yourself first to generate title, description, tags, and path.',
151
+ inputSchema: {
152
+ type: 'object',
153
+ properties: {
154
+ path: { type: 'string', description: 'Target path including filename, e.g. "designs/sprint12/hero.png"' },
155
+ title: { type: 'string', description: 'Human-readable title' },
156
+ description: { type: 'string', description: 'Optional description' },
157
+ tags: { type: 'array', items: { type: 'string' }, description: 'Optional tags' },
158
+ fileLocalPath: { type: 'string', description: 'Absolute filesystem path — use when user provided an explicit path' },
159
+ fileUrl: { type: 'string', description: 'Public HTTP URL to download from' },
160
+ fileBase64: { type: 'string', description: 'Base64 content — use for small files (≤5 MB) uploaded into the chat' },
161
+ contentType: { type: 'string', description: 'MIME type — auto-detected from extension' },
162
+ },
163
+ required: ['path', 'title'],
164
+ },
165
+ },
166
+ {
167
+ name: 'add_feedback',
168
+ description: `Add a feedback comment. Author defaults to "${DEFAULT_AUTHOR}" (set MCP_AUTHOR env to change).`,
169
+ inputSchema: {
170
+ type: 'object',
171
+ properties: {
172
+ path: { type: 'string', description: 'Artifact path' },
173
+ author: { type: 'string', description: `Commenter name (default: "${DEFAULT_AUTHOR}")` },
174
+ text: { type: 'string', description: 'Comment text' },
175
+ },
176
+ required: ['path', 'text'],
177
+ },
178
+ },
179
+ {
180
+ name: 'share_artifact',
181
+ description: 'Generate a time-limited presigned download URL.',
182
+ inputSchema: {
183
+ type: 'object',
184
+ properties: {
185
+ path: { type: 'string', description: 'Artifact path' },
186
+ ttlHours: { type: 'number', description: 'Validity in hours (default 24, max 168)' },
187
+ },
188
+ required: ['path'],
189
+ },
190
+ },
191
+ ],
192
+ }));
193
+ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (req) => {
194
+ const { name, arguments: args = {} } = req.params;
195
+ try {
196
+ switch (name) {
197
+ case 'get_catalog': {
198
+ const data = await apiGet('/catalog');
199
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
200
+ }
201
+ case 'search_artifacts': {
202
+ const limit = args['limit'] ?? 20;
203
+ const data = await apiGet(`/search?q=${encodeURIComponent(args['query'])}&limit=${limit}`);
204
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
205
+ }
206
+ case 'browse_folder': {
207
+ const path = args['path'];
208
+ const cursor = args['cursor'];
209
+ const url = path ? `/browse/${path}` : '/browse';
210
+ const full = cursor ? `${url}?cursor=${encodeURIComponent(cursor)}` : url;
211
+ const data = await apiGet(full);
212
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
213
+ }
214
+ case 'get_artifact': {
215
+ const path = args['path'];
216
+ const [detail, feedback] = await Promise.all([
217
+ apiGet(`/artifacts/${path}`),
218
+ apiGet(`/artifacts/${path}/feedback`).catch(() => []),
219
+ ]);
220
+ return { content: [{ type: 'text', text: JSON.stringify({ ...detail, feedback }, null, 2) }] };
221
+ }
222
+ case 'request_upload_url': {
223
+ const data = await apiPost('/upload/token', {
224
+ path: args['path'],
225
+ title: args['title'],
226
+ description: args['description'],
227
+ tags: args['tags'],
228
+ contentType: args['contentType'],
229
+ });
230
+ const curlCmd = `curl -s -X POST '${data.uploadUrl}' -F 'file=@${args['fileLocalPath']}'`;
231
+ return {
232
+ content: [{
233
+ type: 'text',
234
+ text: JSON.stringify({
235
+ uploadUrl: data.uploadUrl,
236
+ expiresIn: data.expiresIn,
237
+ curlCommand: curlCmd,
238
+ instruction: 'Run the curlCommand now to upload the file.',
239
+ }, null, 2),
240
+ }],
241
+ };
242
+ }
243
+ case 'publish_artifact': {
244
+ const targetPath = args['path'];
245
+ const filename = targetPath.split('/').pop() ?? 'file';
246
+ const explicit = args['contentType'];
247
+ let buffer;
248
+ let mimetype;
249
+ if (args['fileLocalPath']) {
250
+ const localPath = args['fileLocalPath'];
251
+ buffer = (0, fs_1.readFileSync)(localPath);
252
+ const ext = (0, path_1.extname)(localPath).toLowerCase();
253
+ mimetype = explicit ?? MIME_BY_EXT[ext] ?? 'application/octet-stream';
254
+ }
255
+ else if (args['fileUrl']) {
256
+ const resp = await fetch(args['fileUrl']);
257
+ if (!resp.ok)
258
+ throw new Error(`Fetch failed: ${resp.status}`);
259
+ buffer = Buffer.from(await resp.arrayBuffer());
260
+ const ct = resp.headers.get('content-type');
261
+ const ctPrimary = ct ? (ct.split(';')[0] ?? ct).trim() : undefined;
262
+ mimetype = ctPrimary ?? explicit ?? 'application/octet-stream';
263
+ }
264
+ else if (args['fileBase64']) {
265
+ buffer = Buffer.from(args['fileBase64'], 'base64');
266
+ const ext = (0, path_1.extname)(filename).toLowerCase();
267
+ mimetype = explicit ?? MIME_BY_EXT[ext] ?? 'application/octet-stream';
268
+ }
269
+ else {
270
+ throw new Error('Provide fileLocalPath, fileUrl, or fileBase64');
271
+ }
272
+ const data = await uploadMultipart(targetPath, buffer, filename, mimetype, args['title'], args['description'], args['tags']);
273
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
274
+ }
275
+ case 'add_feedback': {
276
+ const author = args['author'] ?? DEFAULT_AUTHOR;
277
+ const data = await apiPost(`/artifacts/${args['path']}/feedback`, {
278
+ author,
279
+ text: args['text'],
280
+ });
281
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
282
+ }
283
+ case 'share_artifact': {
284
+ const ttlHours = Math.min(args['ttlHours'] ?? 24, 168);
285
+ const data = await apiPost(`/artifacts/${args['path']}/share`, {
286
+ ttlSeconds: ttlHours * 3600,
287
+ });
288
+ return { content: [{ type: 'text', text: JSON.stringify({ ...data, ttlHours }, null, 2) }] };
289
+ }
290
+ default:
291
+ return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
292
+ }
293
+ }
294
+ catch (err) {
295
+ return { content: [{ type: 'text', text: `Error: ${String(err)}` }], isError: true };
296
+ }
297
+ });
298
+ // ─── Bootstrap ────────────────────────────────────────────────────────────────
299
+ async function main() {
300
+ const transport = new stdio_js_1.StdioServerTransport();
301
+ await server.connect(transport);
302
+ transport.onclose = () => process.exit(0);
303
+ }
304
+ main().catch((err) => {
305
+ process.stderr.write(`[artifact-hub MCP] failed to start: ${String(err)}\n`);
306
+ process.exit(1);
307
+ });
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "artifact-hub-mcp",
3
+ "version": "1.0.0",
4
+ "description": "Artifact Hub MCP stdio client — proxies tool calls to the REST API",
5
+ "main": "dist/client.js",
6
+ "bin": {
7
+ "artifact-hub-mcp": "./dist/client.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "start": "ts-node --project tsconfig.json client.ts",
14
+ "build": "tsc --project tsconfig.json",
15
+ "type-check": "tsc --project tsconfig.json --noEmit",
16
+ "prepublishOnly": "npm run build"
17
+ },
18
+ "dependencies": {
19
+ "@modelcontextprotocol/sdk": "^1.29.0"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^20.0.0",
23
+ "ts-node": "^10.9.2",
24
+ "typescript": "^5.0.0"
25
+ },
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "license": "MIT"
33
+ }