everymuseum-mcp 0.1.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.
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/dist/api.js +175 -0
- package/dist/board-html.js +226 -0
- package/dist/boards.js +131 -0
- package/dist/images.js +130 -0
- package/dist/index.js +551 -0
- package/dist/types.js +1 -0
- package/package.json +50 -0
package/dist/images.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { API_BASE } from "./api.js";
|
|
2
|
+
const IMAGE_TIMEOUT_MS = 20_000;
|
|
3
|
+
const MAX_IMAGE_BYTES = 6 * 1024 * 1024; // 6 MB — generous for a sized JPEG.
|
|
4
|
+
const USER_AGENT = "everymuseum-mcp/0.1 (+https://everymuseum.org)";
|
|
5
|
+
// MIME types Claude can render as image content blocks.
|
|
6
|
+
const SUPPORTED_IMAGE_TYPES = new Set([
|
|
7
|
+
"image/jpeg",
|
|
8
|
+
"image/png",
|
|
9
|
+
"image/gif",
|
|
10
|
+
"image/webp",
|
|
11
|
+
]);
|
|
12
|
+
/**
|
|
13
|
+
* The app rewrites some image URLs to relative proxy paths (e.g.
|
|
14
|
+
* `/api/media/te-papa/...`). Resolve those against the API base so they fetch.
|
|
15
|
+
*/
|
|
16
|
+
export function resolveUrl(url) {
|
|
17
|
+
if (!url)
|
|
18
|
+
return null;
|
|
19
|
+
const trimmed = url.trim();
|
|
20
|
+
if (!trimmed)
|
|
21
|
+
return null;
|
|
22
|
+
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
|
|
23
|
+
return trimmed;
|
|
24
|
+
}
|
|
25
|
+
if (trimmed.startsWith("/")) {
|
|
26
|
+
return `${API_BASE}${trimmed}`;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
/** Rewrite a IIIF "full" image URL to a bounded `!w,h` derivative. */
|
|
31
|
+
function resizeIiifFullUrl(url, size) {
|
|
32
|
+
const resized = url.replace(/\/full\/[^/]+\/0\/default\.(jpe?g|png|webp)(\?.*)?$/i, `/full/!${size},${size}/0/default.$1$2`);
|
|
33
|
+
return resized === url ? null : resized;
|
|
34
|
+
}
|
|
35
|
+
/** Turn a IIIF base/info/full URL into a sized JPEG derivative. */
|
|
36
|
+
function iiifImageUrl(value, size) {
|
|
37
|
+
const url = value?.trim();
|
|
38
|
+
if (!url)
|
|
39
|
+
return null;
|
|
40
|
+
if (url.includes("/presentation/"))
|
|
41
|
+
return null;
|
|
42
|
+
if (url.endsWith("/info.json")) {
|
|
43
|
+
return url.replace(/\/info\.json$/, `/full/!${size},${size}/0/default.jpg`);
|
|
44
|
+
}
|
|
45
|
+
const resized = resizeIiifFullUrl(url, size);
|
|
46
|
+
if (resized)
|
|
47
|
+
return resized;
|
|
48
|
+
if (url.includes("/full/"))
|
|
49
|
+
return url; // already a full-spec URL
|
|
50
|
+
// Treat as a IIIF image service base.
|
|
51
|
+
return `${url.replace(/\/$/, "")}/full/!${size},${size}/0/default.jpg`;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Pick the best URL to display an artwork at roughly `size` px on its long edge.
|
|
55
|
+
* IIIF sources are sized exactly on demand; others fall back to the stored
|
|
56
|
+
* derivative (small thumbnail for grids, full image for detail views).
|
|
57
|
+
*/
|
|
58
|
+
export function bestDisplayUrl(artwork, size) {
|
|
59
|
+
const fromIiif = iiifImageUrl(artwork.iiifUrl, size);
|
|
60
|
+
if (fromIiif)
|
|
61
|
+
return resolveUrl(fromIiif);
|
|
62
|
+
const resizedFull = artwork.imageUrl ? resizeIiifFullUrl(artwork.imageUrl, size) : null;
|
|
63
|
+
if (resizedFull)
|
|
64
|
+
return resolveUrl(resizedFull);
|
|
65
|
+
if (size <= 600) {
|
|
66
|
+
return resolveUrl(artwork.thumbnailUrl ?? artwork.imageUrl);
|
|
67
|
+
}
|
|
68
|
+
return resolveUrl(artwork.imageUrl ?? artwork.thumbnailUrl);
|
|
69
|
+
}
|
|
70
|
+
/** Fetch an image and return it as a base64 MCP image content block, or null. */
|
|
71
|
+
export async function fetchImageContent(url) {
|
|
72
|
+
const resolved = resolveUrl(url);
|
|
73
|
+
if (!resolved)
|
|
74
|
+
return null;
|
|
75
|
+
const controller = new AbortController();
|
|
76
|
+
const timeout = setTimeout(() => controller.abort(), IMAGE_TIMEOUT_MS);
|
|
77
|
+
try {
|
|
78
|
+
const response = await fetch(resolved, {
|
|
79
|
+
headers: { "User-Agent": USER_AGENT },
|
|
80
|
+
signal: controller.signal,
|
|
81
|
+
});
|
|
82
|
+
if (!response.ok)
|
|
83
|
+
return null;
|
|
84
|
+
const mimeType = (response.headers.get("content-type") ?? "")
|
|
85
|
+
.split(";")[0]
|
|
86
|
+
?.trim()
|
|
87
|
+
.toLowerCase();
|
|
88
|
+
if (!mimeType || !SUPPORTED_IMAGE_TYPES.has(mimeType))
|
|
89
|
+
return null;
|
|
90
|
+
// Reject obviously oversized bodies up front…
|
|
91
|
+
const declaredLength = Number(response.headers.get("content-length"));
|
|
92
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_IMAGE_BYTES) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
// …then stream with a hard byte cap so a lying/missing content-length can't
|
|
96
|
+
// make us buffer an unbounded body into memory.
|
|
97
|
+
const reader = response.body?.getReader();
|
|
98
|
+
if (!reader)
|
|
99
|
+
return null;
|
|
100
|
+
const chunks = [];
|
|
101
|
+
let total = 0;
|
|
102
|
+
while (true) {
|
|
103
|
+
const { done, value } = await reader.read();
|
|
104
|
+
if (done)
|
|
105
|
+
break;
|
|
106
|
+
if (!value)
|
|
107
|
+
continue;
|
|
108
|
+
total += value.byteLength;
|
|
109
|
+
if (total > MAX_IMAGE_BYTES) {
|
|
110
|
+
controller.abort();
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
chunks.push(value);
|
|
114
|
+
}
|
|
115
|
+
if (total === 0)
|
|
116
|
+
return null;
|
|
117
|
+
const buffer = Buffer.concat(chunks, total);
|
|
118
|
+
return { type: "image", data: buffer.toString("base64"), mimeType };
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
clearTimeout(timeout);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** Convenience: fetch the best display image for an artwork as a content block. */
|
|
128
|
+
export async function artworkImageContent(artwork, size) {
|
|
129
|
+
return fetchImageContent(bestDisplayUrl(artwork, size));
|
|
130
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { platform } from "node:process";
|
|
6
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { ApiError, API_BASE, findSimilar, getArtwork, getSuggestions, searchArtworks, sourceLabel, } from "./api.js";
|
|
10
|
+
import { renderBoardHtml } from "./board-html.js";
|
|
11
|
+
import { addArtworksToBoard, boardsExportDir, createBoard, deleteBoard, findBoard, loadBoards, removeItemsFromBoard, saveBoards, withBoards, } from "./boards.js";
|
|
12
|
+
import { artworkImageContent } from "./images.js";
|
|
13
|
+
const VERSION = "0.1.0";
|
|
14
|
+
function text(value) {
|
|
15
|
+
return { type: "text", text: value };
|
|
16
|
+
}
|
|
17
|
+
function ok(content) {
|
|
18
|
+
return { content };
|
|
19
|
+
}
|
|
20
|
+
function fail(message) {
|
|
21
|
+
return { content: [text(message)], isError: true };
|
|
22
|
+
}
|
|
23
|
+
/** Wrap a handler so API/network errors become friendly tool errors, not crashes. */
|
|
24
|
+
async function guard(run) {
|
|
25
|
+
try {
|
|
26
|
+
return await run();
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error instanceof ApiError) {
|
|
30
|
+
return fail(`EveryMuseum API error: ${error.message}`);
|
|
31
|
+
}
|
|
32
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
33
|
+
return fail(`Something went wrong: ${message}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function captionLine(art, index) {
|
|
37
|
+
const title = art.title?.trim() || "Untitled";
|
|
38
|
+
const artist = art.artist?.trim() || "Unknown artist";
|
|
39
|
+
const date = art.dateDisplay?.trim() || "n.d.";
|
|
40
|
+
const label = sourceLabel(art.source);
|
|
41
|
+
const license = art.license?.trim();
|
|
42
|
+
const tail = license ? ` · ${license}` : "";
|
|
43
|
+
return `${index}. "${title}" — ${artist} (${date}) · ${label}${tail}\n id: ${art.id}`;
|
|
44
|
+
}
|
|
45
|
+
/** Build a captioned gallery: one text line per item, each followed by its image. */
|
|
46
|
+
async function galleryContent(items, options) {
|
|
47
|
+
const start = options.startIndex ?? 1;
|
|
48
|
+
const images = options.includeImages
|
|
49
|
+
? await Promise.all(items.map((art) => artworkImageContent(art, options.imageSize)))
|
|
50
|
+
: items.map(() => null);
|
|
51
|
+
const content = [];
|
|
52
|
+
items.forEach((art, i) => {
|
|
53
|
+
content.push(text(captionLine(art, start + i)));
|
|
54
|
+
const image = images[i];
|
|
55
|
+
if (image)
|
|
56
|
+
content.push(image);
|
|
57
|
+
});
|
|
58
|
+
return content;
|
|
59
|
+
}
|
|
60
|
+
function boardItemCaption(item, index) {
|
|
61
|
+
return captionLine({
|
|
62
|
+
id: item.id,
|
|
63
|
+
title: item.title,
|
|
64
|
+
artist: item.artist,
|
|
65
|
+
dateDisplay: item.dateDisplay,
|
|
66
|
+
source: item.source,
|
|
67
|
+
license: item.license,
|
|
68
|
+
}, index);
|
|
69
|
+
}
|
|
70
|
+
function boardSummaryLine(board) {
|
|
71
|
+
const count = board.items.length;
|
|
72
|
+
const desc = board.description ? ` — ${board.description}` : "";
|
|
73
|
+
return `• ${board.name} (slug: ${board.slug}, ${count} ${count === 1 ? "work" : "works"})${desc}`;
|
|
74
|
+
}
|
|
75
|
+
const server = new McpServer({ name: "everymuseum", version: VERSION }, {
|
|
76
|
+
instructions: [
|
|
77
|
+
"EveryMuseum is a searchable archive of open-access artworks aggregated from",
|
|
78
|
+
"museums worldwide (The Met, Rijksmuseum, Art Institute of Chicago, V&A, Getty,",
|
|
79
|
+
"Smithsonian, Harvard, Cleveland, and more).",
|
|
80
|
+
"",
|
|
81
|
+
"Use these tools to pull up images conversationally, learn about specific works,",
|
|
82
|
+
"discover related pieces, and curate persistent local 'boards' (mood boards /",
|
|
83
|
+
"reading lists) that can be exported to a shareable HTML gallery.",
|
|
84
|
+
"",
|
|
85
|
+
"Workflow tips: search_artworks → get_artwork (deep detail) → find_similar",
|
|
86
|
+
"(go deeper) → create_board / add_to_board (collect) → export_board (share).",
|
|
87
|
+
"Every artwork has a stable `id`; pass it to get_artwork, find_similar, and",
|
|
88
|
+
"add_to_board. Images are real artwork reproductions; always attribute the",
|
|
89
|
+
"source institution and respect the stated license.",
|
|
90
|
+
].join("\n"),
|
|
91
|
+
});
|
|
92
|
+
// ─── Discovery & images ──────────────────────────────────────────────────────
|
|
93
|
+
server.registerTool("search_artworks", {
|
|
94
|
+
title: "Search the EveryMuseum archive",
|
|
95
|
+
description: "Search open-access museum artworks by free text (artist, title, culture, " +
|
|
96
|
+
"medium, period, museum name, or theme — e.g. 'hokusai wave', 'art nouveau " +
|
|
97
|
+
"poster', 'egyptian bronze'). Returns captioned results with images so they " +
|
|
98
|
+
"can be shown directly in the conversation. Each result includes a stable id " +
|
|
99
|
+
"for use with get_artwork, find_similar, and add_to_board.",
|
|
100
|
+
inputSchema: {
|
|
101
|
+
query: z
|
|
102
|
+
.string()
|
|
103
|
+
.min(2)
|
|
104
|
+
.max(120)
|
|
105
|
+
.describe("Free-text search query (2–120 characters)."),
|
|
106
|
+
limit: z
|
|
107
|
+
.number()
|
|
108
|
+
.int()
|
|
109
|
+
.min(1)
|
|
110
|
+
.max(24)
|
|
111
|
+
.optional()
|
|
112
|
+
.describe("How many works to return (default 6, max 24)."),
|
|
113
|
+
offset: z
|
|
114
|
+
.number()
|
|
115
|
+
.int()
|
|
116
|
+
.min(0)
|
|
117
|
+
.max(10000)
|
|
118
|
+
.optional()
|
|
119
|
+
.describe("Skip this many results for paging through more (default 0)."),
|
|
120
|
+
includeImages: z
|
|
121
|
+
.boolean()
|
|
122
|
+
.optional()
|
|
123
|
+
.describe("Return artwork images, not just text (default true)."),
|
|
124
|
+
imageSize: z
|
|
125
|
+
.number()
|
|
126
|
+
.int()
|
|
127
|
+
.min(200)
|
|
128
|
+
.max(2000)
|
|
129
|
+
.optional()
|
|
130
|
+
.describe("Long-edge pixel size for result images (default 512)."),
|
|
131
|
+
},
|
|
132
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
133
|
+
}, async (args) => guard(async () => {
|
|
134
|
+
const limit = args.limit ?? 6;
|
|
135
|
+
const includeImages = args.includeImages ?? true;
|
|
136
|
+
const imageSize = args.imageSize ?? 512;
|
|
137
|
+
const result = await searchArtworks(args.query, {
|
|
138
|
+
limit,
|
|
139
|
+
offset: args.offset ?? 0,
|
|
140
|
+
});
|
|
141
|
+
if (result.items.length === 0) {
|
|
142
|
+
return ok([
|
|
143
|
+
text(`No artworks matched "${args.query}". Try a broader term, an artist, ` +
|
|
144
|
+
`a culture, or call suggest_searches for ideas.`),
|
|
145
|
+
]);
|
|
146
|
+
}
|
|
147
|
+
const totalNote = result.total != null
|
|
148
|
+
? ` of ~${result.total.toLocaleString()} matching works`
|
|
149
|
+
: "";
|
|
150
|
+
const offset = args.offset ?? 0;
|
|
151
|
+
const moreNote = result.total != null && offset + result.items.length < result.total
|
|
152
|
+
? ` Call again with offset: ${offset + result.items.length} for more.`
|
|
153
|
+
: "";
|
|
154
|
+
const header = text(`Showing ${result.items.length} result${result.items.length === 1 ? "" : "s"}` +
|
|
155
|
+
`${totalNote} for "${args.query}".${moreNote}`);
|
|
156
|
+
const gallery = await galleryContent(result.items, {
|
|
157
|
+
includeImages,
|
|
158
|
+
imageSize,
|
|
159
|
+
startIndex: offset + 1,
|
|
160
|
+
});
|
|
161
|
+
return ok([header, ...gallery]);
|
|
162
|
+
}));
|
|
163
|
+
server.registerTool("get_artwork", {
|
|
164
|
+
title: "Get full detail for one artwork",
|
|
165
|
+
description: "Fetch complete metadata for a single artwork by id — description, medium, " +
|
|
166
|
+
"dimensions, culture, department, classification, tags, license, and a link " +
|
|
167
|
+
"to the source record — plus a high-resolution image. Use this to learn " +
|
|
168
|
+
"about or discuss a specific work in depth.",
|
|
169
|
+
inputSchema: {
|
|
170
|
+
id: z.string().min(1).describe("The artwork id (from a search result)."),
|
|
171
|
+
includeImage: z
|
|
172
|
+
.boolean()
|
|
173
|
+
.optional()
|
|
174
|
+
.describe("Include a high-resolution image (default true)."),
|
|
175
|
+
imageSize: z
|
|
176
|
+
.number()
|
|
177
|
+
.int()
|
|
178
|
+
.min(400)
|
|
179
|
+
.max(3000)
|
|
180
|
+
.optional()
|
|
181
|
+
.describe("Long-edge pixel size for the image (default 1200)."),
|
|
182
|
+
},
|
|
183
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
184
|
+
}, async (args) => guard(async () => {
|
|
185
|
+
const art = await getArtwork(args.id);
|
|
186
|
+
const includeImage = args.includeImage ?? true;
|
|
187
|
+
const imageSize = args.imageSize ?? 1200;
|
|
188
|
+
const lines = [];
|
|
189
|
+
lines.push(`# ${art.title?.trim() || "Untitled"}`);
|
|
190
|
+
const facts = [
|
|
191
|
+
["Artist", art.artist],
|
|
192
|
+
["Date", art.dateDisplay],
|
|
193
|
+
["Medium", art.medium],
|
|
194
|
+
["Dimensions", art.dimensions],
|
|
195
|
+
["Culture", art.culture],
|
|
196
|
+
["Department", art.department],
|
|
197
|
+
["Classification", art.classification],
|
|
198
|
+
["Collection", sourceLabel(art.source)],
|
|
199
|
+
["License", art.license],
|
|
200
|
+
];
|
|
201
|
+
for (const [key, value] of facts) {
|
|
202
|
+
if (value && value.trim())
|
|
203
|
+
lines.push(`**${key}:** ${value.trim()}`);
|
|
204
|
+
}
|
|
205
|
+
if (art.description?.trim()) {
|
|
206
|
+
lines.push("", art.description.trim());
|
|
207
|
+
}
|
|
208
|
+
if (art.tags.length) {
|
|
209
|
+
lines.push("", `**Tags:** ${art.tags.slice(0, 20).join(", ")}`);
|
|
210
|
+
}
|
|
211
|
+
if (art.sourceUrl)
|
|
212
|
+
lines.push("", `Source record: ${art.sourceUrl}`);
|
|
213
|
+
lines.push("", `id: ${art.id}`);
|
|
214
|
+
lines.push("Next: find_similar to explore related works, or add_to_board to save it.");
|
|
215
|
+
const content = [text(lines.join("\n"))];
|
|
216
|
+
if (includeImage) {
|
|
217
|
+
const image = await artworkImageContent(art, imageSize);
|
|
218
|
+
if (image)
|
|
219
|
+
content.push(image);
|
|
220
|
+
else
|
|
221
|
+
content.push(text("(No displayable image is available for this work.)"));
|
|
222
|
+
}
|
|
223
|
+
return ok(content);
|
|
224
|
+
}));
|
|
225
|
+
server.registerTool("find_similar", {
|
|
226
|
+
title: "Find similar artworks",
|
|
227
|
+
description: "Given an artwork id, return works the archive considers related by shared " +
|
|
228
|
+
"tags, artist, culture, medium, department, or classification. Great for " +
|
|
229
|
+
"going deeper down a visual or thematic thread. Returns captioned images.",
|
|
230
|
+
inputSchema: {
|
|
231
|
+
id: z.string().min(1).describe("The artwork id to find neighbors for."),
|
|
232
|
+
limit: z
|
|
233
|
+
.number()
|
|
234
|
+
.int()
|
|
235
|
+
.min(1)
|
|
236
|
+
.max(24)
|
|
237
|
+
.optional()
|
|
238
|
+
.describe("How many similar works to return (default 8, max 24)."),
|
|
239
|
+
includeImages: z
|
|
240
|
+
.boolean()
|
|
241
|
+
.optional()
|
|
242
|
+
.describe("Return images, not just text (default true)."),
|
|
243
|
+
imageSize: z
|
|
244
|
+
.number()
|
|
245
|
+
.int()
|
|
246
|
+
.min(200)
|
|
247
|
+
.max(2000)
|
|
248
|
+
.optional()
|
|
249
|
+
.describe("Long-edge pixel size for images (default 512)."),
|
|
250
|
+
},
|
|
251
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
252
|
+
}, async (args) => guard(async () => {
|
|
253
|
+
const limit = args.limit ?? 8;
|
|
254
|
+
const items = (await findSimilar(args.id)).slice(0, limit);
|
|
255
|
+
if (items.length === 0) {
|
|
256
|
+
return ok([
|
|
257
|
+
text(`No similar works found for id "${args.id}". The id may be invalid, ` +
|
|
258
|
+
`or the work has little metadata to match on.`),
|
|
259
|
+
]);
|
|
260
|
+
}
|
|
261
|
+
const gallery = await galleryContent(items, {
|
|
262
|
+
includeImages: args.includeImages ?? true,
|
|
263
|
+
imageSize: args.imageSize ?? 512,
|
|
264
|
+
});
|
|
265
|
+
return ok([
|
|
266
|
+
text(`${items.length} works related to id ${args.id}:`),
|
|
267
|
+
...gallery,
|
|
268
|
+
]);
|
|
269
|
+
}));
|
|
270
|
+
server.registerTool("suggest_searches", {
|
|
271
|
+
title: "Suggest things to explore",
|
|
272
|
+
description: "Return a curated list of popular search terms drawn from the archive's top " +
|
|
273
|
+
"artists, cultures, collections, media, and source museums. Use when the " +
|
|
274
|
+
"user is unsure what to look for, then feed a term to search_artworks.",
|
|
275
|
+
inputSchema: {
|
|
276
|
+
limit: z
|
|
277
|
+
.number()
|
|
278
|
+
.int()
|
|
279
|
+
.min(5)
|
|
280
|
+
.max(160)
|
|
281
|
+
.optional()
|
|
282
|
+
.describe("How many suggestions to return (default 40)."),
|
|
283
|
+
},
|
|
284
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
285
|
+
}, async (args) => guard(async () => {
|
|
286
|
+
const limit = args.limit ?? 40;
|
|
287
|
+
const suggestions = (await getSuggestions()).slice(0, limit);
|
|
288
|
+
if (suggestions.length === 0) {
|
|
289
|
+
return ok([text("No suggestions are available right now.")]);
|
|
290
|
+
}
|
|
291
|
+
return ok([
|
|
292
|
+
text(`Try searching for any of these (pass one to search_artworks):\n\n` +
|
|
293
|
+
suggestions.map((value) => `• ${value}`).join("\n")),
|
|
294
|
+
]);
|
|
295
|
+
}));
|
|
296
|
+
// ─── Boards ──────────────────────────────────────────────────────────────────
|
|
297
|
+
server.registerTool("list_boards", {
|
|
298
|
+
title: "List saved boards",
|
|
299
|
+
description: "List every saved board with its name, slug, and number of works. Boards are " +
|
|
300
|
+
"stored locally on this machine and persist across conversations.",
|
|
301
|
+
inputSchema: {},
|
|
302
|
+
annotations: { readOnlyHint: true },
|
|
303
|
+
}, async () => guard(async () => {
|
|
304
|
+
const boards = await loadBoards();
|
|
305
|
+
if (boards.length === 0) {
|
|
306
|
+
return ok([
|
|
307
|
+
text("You have no boards yet. Create one with create_board."),
|
|
308
|
+
]);
|
|
309
|
+
}
|
|
310
|
+
return ok([
|
|
311
|
+
text(`You have ${boards.length} board${boards.length === 1 ? "" : "s"}:\n\n` +
|
|
312
|
+
boards.map(boardSummaryLine).join("\n")),
|
|
313
|
+
]);
|
|
314
|
+
}));
|
|
315
|
+
server.registerTool("create_board", {
|
|
316
|
+
title: "Create a board",
|
|
317
|
+
description: "Create a new board (a saved collection / mood board) to group artworks. " +
|
|
318
|
+
"Returns the board's slug; use it (or the name) with add_to_board.",
|
|
319
|
+
inputSchema: {
|
|
320
|
+
name: z.string().min(1).max(120).describe("A name for the board."),
|
|
321
|
+
description: z
|
|
322
|
+
.string()
|
|
323
|
+
.max(500)
|
|
324
|
+
.optional()
|
|
325
|
+
.describe("Optional one-line description of the board's theme."),
|
|
326
|
+
},
|
|
327
|
+
annotations: { readOnlyHint: false },
|
|
328
|
+
}, async (args) => guard(() => withBoards(async (boards) => {
|
|
329
|
+
const board = createBoard(boards, args.name, args.description ?? null);
|
|
330
|
+
await saveBoards(boards);
|
|
331
|
+
return ok([
|
|
332
|
+
text(`Created board "${board.name}" (slug: ${board.slug}).\n` +
|
|
333
|
+
`Add works with add_to_board using board "${board.slug}" and artwork ids.`),
|
|
334
|
+
]);
|
|
335
|
+
})));
|
|
336
|
+
server.registerTool("add_to_board", {
|
|
337
|
+
title: "Add artworks to a board",
|
|
338
|
+
description: "Add one or more artworks (by id) to a board, identified by its slug, name, " +
|
|
339
|
+
"or id. Stores a snapshot of each work so the board renders even if the " +
|
|
340
|
+
"archive changes. Skips ids already on the board.",
|
|
341
|
+
inputSchema: {
|
|
342
|
+
board: z
|
|
343
|
+
.string()
|
|
344
|
+
.min(1)
|
|
345
|
+
.describe("Board slug, name, or id to add to."),
|
|
346
|
+
artworkIds: z
|
|
347
|
+
.array(z.string().min(1))
|
|
348
|
+
.min(1)
|
|
349
|
+
.max(100)
|
|
350
|
+
.describe("Artwork ids to add (from search/get/similar results)."),
|
|
351
|
+
},
|
|
352
|
+
annotations: { readOnlyHint: false },
|
|
353
|
+
}, async (args) => guard(async () => {
|
|
354
|
+
// Fetch artwork snapshots outside the board lock (network I/O), then take
|
|
355
|
+
// the lock only for the quick read-modify-write.
|
|
356
|
+
const unique = Array.from(new Set(args.artworkIds));
|
|
357
|
+
const fetched = await Promise.all(unique.map(async (id) => {
|
|
358
|
+
try {
|
|
359
|
+
return await getArtwork(id);
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
}));
|
|
365
|
+
const found = fetched.filter((art) => art !== null);
|
|
366
|
+
const notFound = unique.filter((id) => !found.some((art) => art.id === id));
|
|
367
|
+
return withBoards(async (boards) => {
|
|
368
|
+
const board = findBoard(boards, args.board);
|
|
369
|
+
if (!board) {
|
|
370
|
+
return fail(`No board matches "${args.board}". Use list_boards to see boards, or ` +
|
|
371
|
+
`create_board to make one.`);
|
|
372
|
+
}
|
|
373
|
+
const added = addArtworksToBoard(board, found);
|
|
374
|
+
const skipped = found.length - added;
|
|
375
|
+
await saveBoards(boards);
|
|
376
|
+
const parts = [
|
|
377
|
+
`Added ${added} work${added === 1 ? "" : "s"} to "${board.name}" ` +
|
|
378
|
+
`(now ${board.items.length} total).`,
|
|
379
|
+
];
|
|
380
|
+
if (skipped > 0)
|
|
381
|
+
parts.push(`${skipped} already on the board.`);
|
|
382
|
+
if (notFound.length > 0) {
|
|
383
|
+
parts.push(`Could not find ${notFound.length}: ${notFound.join(", ")}.`);
|
|
384
|
+
}
|
|
385
|
+
parts.push(`Use export_board "${board.slug}" to render it.`);
|
|
386
|
+
return ok([text(parts.join(" "))]);
|
|
387
|
+
});
|
|
388
|
+
}));
|
|
389
|
+
server.registerTool("view_board", {
|
|
390
|
+
title: "View a board's contents",
|
|
391
|
+
description: "Show the works on a board with captioned images, identified by slug, name, " +
|
|
392
|
+
"or id. Persisted snapshot images are used, so this works without re-querying.",
|
|
393
|
+
inputSchema: {
|
|
394
|
+
board: z.string().min(1).describe("Board slug, name, or id to view."),
|
|
395
|
+
includeImages: z
|
|
396
|
+
.boolean()
|
|
397
|
+
.optional()
|
|
398
|
+
.describe("Show images, not just text (default true)."),
|
|
399
|
+
imageSize: z
|
|
400
|
+
.number()
|
|
401
|
+
.int()
|
|
402
|
+
.min(200)
|
|
403
|
+
.max(2000)
|
|
404
|
+
.optional()
|
|
405
|
+
.describe("Long-edge pixel size for images (default 512)."),
|
|
406
|
+
limit: z
|
|
407
|
+
.number()
|
|
408
|
+
.int()
|
|
409
|
+
.min(1)
|
|
410
|
+
.max(40)
|
|
411
|
+
.optional()
|
|
412
|
+
.describe("Max works to show images for (default 24)."),
|
|
413
|
+
},
|
|
414
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
415
|
+
}, async (args) => guard(async () => {
|
|
416
|
+
const boards = await loadBoards();
|
|
417
|
+
const board = findBoard(boards, args.board);
|
|
418
|
+
if (!board)
|
|
419
|
+
return fail(`No board matches "${args.board}".`);
|
|
420
|
+
if (board.items.length === 0) {
|
|
421
|
+
return ok([
|
|
422
|
+
text(`"${board.name}" is empty. Add works with add_to_board.`),
|
|
423
|
+
]);
|
|
424
|
+
}
|
|
425
|
+
const includeImages = args.includeImages ?? true;
|
|
426
|
+
const imageSize = args.imageSize ?? 512;
|
|
427
|
+
const limit = args.limit ?? 24;
|
|
428
|
+
const shown = board.items.slice(0, limit);
|
|
429
|
+
// BoardItem carries iiifUrl/imageUrl/thumbnailUrl, satisfying ImageSource.
|
|
430
|
+
const images = includeImages
|
|
431
|
+
? await Promise.all(shown.map((item) => artworkImageContent(item, imageSize)))
|
|
432
|
+
: shown.map(() => null);
|
|
433
|
+
const header = `"${board.name}" — ${board.items.length} work${board.items.length === 1 ? "" : "s"}` +
|
|
434
|
+
(board.description ? `\n${board.description}` : "") +
|
|
435
|
+
(board.items.length > shown.length
|
|
436
|
+
? `\n(Showing the first ${shown.length}.)`
|
|
437
|
+
: "");
|
|
438
|
+
const content = [text(header)];
|
|
439
|
+
shown.forEach((item, i) => {
|
|
440
|
+
content.push(text(boardItemCaption(item, i + 1)));
|
|
441
|
+
const image = images[i];
|
|
442
|
+
if (image)
|
|
443
|
+
content.push(image);
|
|
444
|
+
});
|
|
445
|
+
return ok(content);
|
|
446
|
+
}));
|
|
447
|
+
server.registerTool("remove_from_board", {
|
|
448
|
+
title: "Remove artworks from a board",
|
|
449
|
+
description: "Remove one or more artworks (by id) from a board.",
|
|
450
|
+
inputSchema: {
|
|
451
|
+
board: z.string().min(1).describe("Board slug, name, or id."),
|
|
452
|
+
artworkIds: z
|
|
453
|
+
.array(z.string().min(1))
|
|
454
|
+
.min(1)
|
|
455
|
+
.max(100)
|
|
456
|
+
.describe("Artwork ids to remove from the board."),
|
|
457
|
+
},
|
|
458
|
+
annotations: { readOnlyHint: false },
|
|
459
|
+
}, async (args) => guard(() => withBoards(async (boards) => {
|
|
460
|
+
const board = findBoard(boards, args.board);
|
|
461
|
+
if (!board)
|
|
462
|
+
return fail(`No board matches "${args.board}".`);
|
|
463
|
+
const removed = removeItemsFromBoard(board, args.artworkIds);
|
|
464
|
+
await saveBoards(boards);
|
|
465
|
+
return ok([
|
|
466
|
+
text(`Removed ${removed} work${removed === 1 ? "" : "s"} from "${board.name}" ` +
|
|
467
|
+
`(now ${board.items.length} total).`),
|
|
468
|
+
]);
|
|
469
|
+
})));
|
|
470
|
+
server.registerTool("export_board", {
|
|
471
|
+
title: "Export a board to an HTML gallery",
|
|
472
|
+
description: "Render a board as a self-contained, shareable HTML gallery page (an editorial " +
|
|
473
|
+
"contact sheet with captions, attribution, and source links) and write it to " +
|
|
474
|
+
"disk. Optionally open it in the default browser. Returns the file path.",
|
|
475
|
+
inputSchema: {
|
|
476
|
+
board: z.string().min(1).describe("Board slug, name, or id to export."),
|
|
477
|
+
open: z
|
|
478
|
+
.boolean()
|
|
479
|
+
.optional()
|
|
480
|
+
.describe("Open the exported page in the default browser (default false)."),
|
|
481
|
+
},
|
|
482
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
483
|
+
}, async (args) => guard(async () => {
|
|
484
|
+
const boards = await loadBoards();
|
|
485
|
+
const board = findBoard(boards, args.board);
|
|
486
|
+
if (!board)
|
|
487
|
+
return fail(`No board matches "${args.board}".`);
|
|
488
|
+
const dir = boardsExportDir();
|
|
489
|
+
await mkdir(dir, { recursive: true });
|
|
490
|
+
const filePath = join(dir, `${board.slug}-${board.id}.html`);
|
|
491
|
+
await writeFile(filePath, renderBoardHtml(board), "utf8");
|
|
492
|
+
let openNote = "";
|
|
493
|
+
if (args.open) {
|
|
494
|
+
const opener = platform === "darwin"
|
|
495
|
+
? "open"
|
|
496
|
+
: platform === "win32"
|
|
497
|
+
? "cmd"
|
|
498
|
+
: "xdg-open";
|
|
499
|
+
const openerArgs = platform === "win32" ? ["/c", "start", "", filePath] : [filePath];
|
|
500
|
+
try {
|
|
501
|
+
const child = spawn(opener, openerArgs, {
|
|
502
|
+
detached: true,
|
|
503
|
+
stdio: "ignore",
|
|
504
|
+
});
|
|
505
|
+
child.unref();
|
|
506
|
+
openNote = " Opening it in your browser now.";
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
openNote = " (Could not auto-open it; open the file manually.)";
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return ok([
|
|
513
|
+
text(`Exported "${board.name}" (${board.items.length} works) to:\n${filePath}${openNote}`),
|
|
514
|
+
]);
|
|
515
|
+
}));
|
|
516
|
+
server.registerTool("delete_board", {
|
|
517
|
+
title: "Delete a board",
|
|
518
|
+
description: "Permanently delete a board and its saved items. Requires confirm: true. " +
|
|
519
|
+
"This does not delete any exported HTML files.",
|
|
520
|
+
inputSchema: {
|
|
521
|
+
board: z.string().min(1).describe("Board slug, name, or id to delete."),
|
|
522
|
+
confirm: z
|
|
523
|
+
.boolean()
|
|
524
|
+
.describe("Must be true to actually delete the board."),
|
|
525
|
+
},
|
|
526
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
|
527
|
+
}, async (args) => guard(() => withBoards(async (boards) => {
|
|
528
|
+
const board = findBoard(boards, args.board);
|
|
529
|
+
if (!board)
|
|
530
|
+
return fail(`No board matches "${args.board}".`);
|
|
531
|
+
if (!args.confirm) {
|
|
532
|
+
return ok([
|
|
533
|
+
text(`This will permanently delete "${board.name}" (slug: ${board.slug}, ` +
|
|
534
|
+
`${board.items.length} works). Call again with confirm: true to proceed.`),
|
|
535
|
+
]);
|
|
536
|
+
}
|
|
537
|
+
const remaining = deleteBoard(boards, board);
|
|
538
|
+
await saveBoards(remaining);
|
|
539
|
+
return ok([text(`Deleted board "${board.name}".`)]);
|
|
540
|
+
})));
|
|
541
|
+
// ─── Boot ──────────────────────────────────────────────────────────────────
|
|
542
|
+
async function main() {
|
|
543
|
+
const transport = new StdioServerTransport();
|
|
544
|
+
await server.connect(transport);
|
|
545
|
+
// All logging must go to stderr; stdout is reserved for the MCP protocol.
|
|
546
|
+
console.error(`everymuseum-mcp ${VERSION} ready — archive: ${API_BASE}`);
|
|
547
|
+
}
|
|
548
|
+
main().catch((error) => {
|
|
549
|
+
console.error("everymuseum-mcp failed to start:", error);
|
|
550
|
+
process.exit(1);
|
|
551
|
+
});
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|