everymuseum-mcp 0.1.0 → 0.1.2
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/dist/api.js +160 -6
- package/dist/board-html.js +46 -0
- package/dist/boards.js +13 -3
- package/dist/images.js +35 -8
- package/dist/index.js +235 -37
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -116,7 +116,11 @@ function asArtwork(value) {
|
|
|
116
116
|
updatedAt: str("updatedAt") ?? undefined,
|
|
117
117
|
};
|
|
118
118
|
}
|
|
119
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* Search the archive. `includeTotal` is off by default: requesting the grand
|
|
121
|
+
* total forces a COUNT over the whole (600k+) filtered archive, which is the
|
|
122
|
+
* slowest part of a search — skip it unless a caller genuinely needs it.
|
|
123
|
+
*/
|
|
120
124
|
export async function searchArtworks(query, options = {}) {
|
|
121
125
|
const params = new URLSearchParams();
|
|
122
126
|
const trimmed = query.trim();
|
|
@@ -125,7 +129,8 @@ export async function searchArtworks(query, options = {}) {
|
|
|
125
129
|
params.set("limit", String(options.limit ?? 12));
|
|
126
130
|
if (options.offset)
|
|
127
131
|
params.set("offset", String(options.offset));
|
|
128
|
-
|
|
132
|
+
if (options.includeTotal)
|
|
133
|
+
params.set("includeTotal", "1");
|
|
129
134
|
const body = await apiFetch(`/api/search?${params.toString()}`);
|
|
130
135
|
// includeTotal=1 returns { items, total, limit, offset, nextCursor }.
|
|
131
136
|
if (body && typeof body === "object" && "items" in body) {
|
|
@@ -140,13 +145,162 @@ export async function searchArtworks(query, options = {}) {
|
|
|
140
145
|
const items = Array.isArray(body) ? body.map(asArtwork) : [];
|
|
141
146
|
return { items, total: null, nextCursor: null };
|
|
142
147
|
}
|
|
148
|
+
// ─── Smart search: multi-word matching + client-side filters ─────────────────
|
|
149
|
+
//
|
|
150
|
+
// The backend `q` is a single substring match, so "hokusai wave" matches no
|
|
151
|
+
// field literally and returns nothing. We tokenize the query, fetch each term
|
|
152
|
+
// (the backend matches each one), then rank the union by how many query terms
|
|
153
|
+
// each work's metadata actually covers. Filters the API doesn't support are
|
|
154
|
+
// applied here over an over-fetched window.
|
|
155
|
+
const STOPWORDS = new Set([
|
|
156
|
+
"the", "a", "an", "of", "and", "or", "in", "on", "at", "to",
|
|
157
|
+
"with", "by", "for", "from", "de", "la", "le",
|
|
158
|
+
]);
|
|
159
|
+
const OVERFETCH = 150;
|
|
160
|
+
function queryTerms(query) {
|
|
161
|
+
const terms = query
|
|
162
|
+
.toLowerCase()
|
|
163
|
+
.split(/\s+/)
|
|
164
|
+
.map((term) => term.replace(/[^\p{L}\p{N}-]/gu, ""))
|
|
165
|
+
.filter((term) => term.length >= 2 && !STOPWORDS.has(term));
|
|
166
|
+
return Array.from(new Set(terms)).slice(0, 5);
|
|
167
|
+
}
|
|
168
|
+
function artworkText(artwork) {
|
|
169
|
+
return [
|
|
170
|
+
artwork.title,
|
|
171
|
+
artwork.artist,
|
|
172
|
+
artwork.medium,
|
|
173
|
+
artwork.culture,
|
|
174
|
+
artwork.department,
|
|
175
|
+
artwork.classification,
|
|
176
|
+
artwork.description,
|
|
177
|
+
artwork.tags.join(" "),
|
|
178
|
+
sourceLabel(artwork.source),
|
|
179
|
+
artwork.source,
|
|
180
|
+
]
|
|
181
|
+
.filter(Boolean)
|
|
182
|
+
.join(" ")
|
|
183
|
+
.toLowerCase();
|
|
184
|
+
}
|
|
185
|
+
function termCoverage(text, terms) {
|
|
186
|
+
let covered = 0;
|
|
187
|
+
for (const term of terms)
|
|
188
|
+
if (text.includes(term))
|
|
189
|
+
covered += 1;
|
|
190
|
+
return covered;
|
|
191
|
+
}
|
|
192
|
+
function hasActiveFilters(filters) {
|
|
193
|
+
return (!!filters.source ||
|
|
194
|
+
!!filters.medium ||
|
|
195
|
+
!!filters.culture ||
|
|
196
|
+
!!filters.license ||
|
|
197
|
+
filters.hasImage === true ||
|
|
198
|
+
filters.yearFrom != null ||
|
|
199
|
+
filters.yearTo != null);
|
|
200
|
+
}
|
|
201
|
+
function matchesFilters(artwork, filters) {
|
|
202
|
+
if (filters.source) {
|
|
203
|
+
const needle = filters.source.toLowerCase();
|
|
204
|
+
const matches = artwork.source.toLowerCase().includes(needle) ||
|
|
205
|
+
sourceLabel(artwork.source).toLowerCase().includes(needle);
|
|
206
|
+
if (!matches)
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
if (filters.medium && !(artwork.medium ?? "").toLowerCase().includes(filters.medium.toLowerCase())) {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
if (filters.culture && !(artwork.culture ?? "").toLowerCase().includes(filters.culture.toLowerCase())) {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
if (filters.license && !(artwork.license ?? "").toLowerCase().includes(filters.license.toLowerCase())) {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
if (filters.hasImage && !(artwork.imageUrl || artwork.thumbnailUrl || artwork.iiifUrl)) {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
if (filters.yearFrom != null || filters.yearTo != null) {
|
|
222
|
+
const lo = filters.yearFrom ?? -Infinity;
|
|
223
|
+
const hi = filters.yearTo ?? Infinity;
|
|
224
|
+
const start = artwork.dateStart ?? artwork.dateEnd;
|
|
225
|
+
const end = artwork.dateEnd ?? artwork.dateStart;
|
|
226
|
+
if (start == null || end == null)
|
|
227
|
+
return false; // unknown date can't satisfy a year filter
|
|
228
|
+
if (end < lo || start > hi)
|
|
229
|
+
return false; // no overlap with [lo, hi]
|
|
230
|
+
}
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Search with multi-word ranking and optional client-side filters. Falls back
|
|
235
|
+
* to the fast single-request path when the query is one term and no filters are
|
|
236
|
+
* set. Filters operate over an over-fetched window, so very rare combinations
|
|
237
|
+
* may miss matches beyond that window.
|
|
238
|
+
*/
|
|
239
|
+
export async function searchArtworksSmart(query, options = {}) {
|
|
240
|
+
const limit = options.limit ?? 12;
|
|
241
|
+
const offset = options.offset ?? 0;
|
|
242
|
+
const filters = options.filters ?? {};
|
|
243
|
+
const filtered = hasActiveFilters(filters);
|
|
244
|
+
const terms = queryTerms(query);
|
|
245
|
+
// Fast path: single meaningful term, no filters → one backend page. Search the
|
|
246
|
+
// extracted term (not the raw query) so "the wave" probes "wave", not the literal
|
|
247
|
+
// phrase the substring backend would never match. Over-fetch one to know hasMore.
|
|
248
|
+
if (terms.length <= 1 && !filtered) {
|
|
249
|
+
const probe = terms[0] ?? query.trim();
|
|
250
|
+
const page = await searchArtworks(probe, { limit: limit + 1, offset });
|
|
251
|
+
const items = page.items.slice(0, limit);
|
|
252
|
+
return { items, terms, filtered: false, hasMore: page.items.length > limit };
|
|
253
|
+
}
|
|
254
|
+
// Gather candidates: one over-fetched page per term (backend matches each),
|
|
255
|
+
// deduped by id. Empty term list (e.g. all stopwords) falls back to the raw query.
|
|
256
|
+
const probes = terms.length > 0 ? terms : [query.trim()].filter(Boolean);
|
|
257
|
+
const pages = await Promise.all(probes.map((probe) => searchArtworks(probe, { limit: OVERFETCH })
|
|
258
|
+
.then((result) => result.items)
|
|
259
|
+
.catch(() => [])));
|
|
260
|
+
const byId = new Map();
|
|
261
|
+
for (const page of pages) {
|
|
262
|
+
for (const artwork of page) {
|
|
263
|
+
if (artwork.id && !byId.has(artwork.id))
|
|
264
|
+
byId.set(artwork.id, artwork);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
let candidates = Array.from(byId.values());
|
|
268
|
+
if (filtered)
|
|
269
|
+
candidates = candidates.filter((artwork) => matchesFilters(artwork, filters));
|
|
270
|
+
// Rank by how many query terms each work's metadata covers (desc). Stable for ties.
|
|
271
|
+
const ranked = candidates
|
|
272
|
+
.map((artwork, index) => ({ artwork, index, score: termCoverage(artworkText(artwork), terms) }))
|
|
273
|
+
.sort((a, b) => b.score - a.score || a.index - b.index)
|
|
274
|
+
.map((entry) => entry.artwork);
|
|
275
|
+
const items = ranked.slice(offset, offset + limit);
|
|
276
|
+
return { items, terms, filtered, hasMore: ranked.length > offset + items.length };
|
|
277
|
+
}
|
|
278
|
+
// Memoize artwork lookups for the life of the process: add_to_board, compare,
|
|
279
|
+
// and board flows often re-request the same ids right after a search. Bounded
|
|
280
|
+
// with FIFO eviction so a long browsing session can't grow the map unboundedly.
|
|
281
|
+
const ARTWORK_CACHE_MAX = 500;
|
|
282
|
+
const artworkCache = new Map();
|
|
143
283
|
/** Fetch full detail for a single artwork. Throws ApiError(404) if missing. */
|
|
144
284
|
export async function getArtwork(id) {
|
|
145
|
-
const
|
|
146
|
-
if (
|
|
147
|
-
|
|
285
|
+
const cached = artworkCache.get(id);
|
|
286
|
+
if (cached)
|
|
287
|
+
return cached;
|
|
288
|
+
const pending = (async () => {
|
|
289
|
+
const body = await apiFetch(`/api/artworks/${encodeURIComponent(id)}`);
|
|
290
|
+
if (!body || typeof body !== "object" || !("id" in body)) {
|
|
291
|
+
throw new ApiError(`No artwork found for id "${id}".`, 404);
|
|
292
|
+
}
|
|
293
|
+
return asArtwork(body);
|
|
294
|
+
})();
|
|
295
|
+
if (artworkCache.size >= ARTWORK_CACHE_MAX) {
|
|
296
|
+
const oldest = artworkCache.keys().next().value;
|
|
297
|
+
if (oldest !== undefined)
|
|
298
|
+
artworkCache.delete(oldest);
|
|
148
299
|
}
|
|
149
|
-
|
|
300
|
+
artworkCache.set(id, pending);
|
|
301
|
+
// Don't cache failures — let the next call retry.
|
|
302
|
+
pending.catch(() => artworkCache.delete(id));
|
|
303
|
+
return pending;
|
|
150
304
|
}
|
|
151
305
|
/** Works the archive considers similar to the given artwork (up to ~12). */
|
|
152
306
|
export async function findSimilar(id) {
|
package/dist/board-html.js
CHANGED
|
@@ -47,6 +47,8 @@ function renderCard(item) {
|
|
|
47
47
|
const attribution = [item.sourceLabel, item.license].filter(Boolean).map((part) => escapeHtml(part));
|
|
48
48
|
if (attribution.length)
|
|
49
49
|
metaLines.push(`<span class="attr">${attribution.join(" · ")}</span>`);
|
|
50
|
+
if (item.note)
|
|
51
|
+
metaLines.push(`<span class="note">${escapeHtml(item.note)}</span>`);
|
|
50
52
|
const caption = `
|
|
51
53
|
<figcaption>
|
|
52
54
|
<span class="title">${title}</span>
|
|
@@ -182,6 +184,14 @@ export function renderBoardHtml(board) {
|
|
|
182
184
|
color: var(--muted);
|
|
183
185
|
margin-top: 4px;
|
|
184
186
|
}
|
|
187
|
+
figcaption .note {
|
|
188
|
+
font-size: 13px;
|
|
189
|
+
font-style: italic;
|
|
190
|
+
color: #3a342d;
|
|
191
|
+
margin-top: 6px;
|
|
192
|
+
padding-left: 10px;
|
|
193
|
+
border-left: 2px solid var(--accent);
|
|
194
|
+
}
|
|
185
195
|
.card a:hover figcaption .title { color: var(--accent); }
|
|
186
196
|
.empty {
|
|
187
197
|
max-width: 1280px;
|
|
@@ -224,3 +234,39 @@ export function renderBoardHtml(board) {
|
|
|
224
234
|
</html>
|
|
225
235
|
`;
|
|
226
236
|
}
|
|
237
|
+
const MARKDOWN_IMAGE_SIZE = 1000;
|
|
238
|
+
/** Strip characters that would break a Markdown image alt / link text. */
|
|
239
|
+
function escapeMd(value) {
|
|
240
|
+
return value.replace(/[\[\]]/g, "").replace(/\s+/g, " ").trim();
|
|
241
|
+
}
|
|
242
|
+
/** Render a board as a Markdown document — paste into docs, Notion, or a README. */
|
|
243
|
+
export function renderBoardMarkdown(board) {
|
|
244
|
+
const lines = [];
|
|
245
|
+
lines.push(`# ${escapeMd(board.name)}`);
|
|
246
|
+
if (board.description)
|
|
247
|
+
lines.push("", escapeMd(board.description));
|
|
248
|
+
const count = board.items.length;
|
|
249
|
+
lines.push("", `_${count} ${count === 1 ? "work" : "works"} · curated via [EveryMuseum](https://everymuseum.org)_`, "");
|
|
250
|
+
board.items.forEach((item, index) => {
|
|
251
|
+
const title = item.title?.trim() || "Untitled";
|
|
252
|
+
const imageUrl = safeUrl(bestDisplayUrl(item, MARKDOWN_IMAGE_SIZE));
|
|
253
|
+
const link = safeUrl(item.sourceUrl) || `https://everymuseum.org/artworks/${item.id}`;
|
|
254
|
+
// Escape every interpolated field (collapses newlines, strips [] link/image
|
|
255
|
+
// syntax) so a note or metadata value can't inject Markdown structure. URLs
|
|
256
|
+
// go in angle brackets so a stray ')' can't truncate the link/image.
|
|
257
|
+
const facts = [item.artist, item.dateDisplay, item.medium, item.sourceLabel, item.license]
|
|
258
|
+
.filter((part) => Boolean(part))
|
|
259
|
+
.map(escapeMd)
|
|
260
|
+
.join(" · ");
|
|
261
|
+
lines.push(`### ${index + 1}. ${escapeMd(title)}`);
|
|
262
|
+
if (imageUrl)
|
|
263
|
+
lines.push("", ``);
|
|
264
|
+
if (facts)
|
|
265
|
+
lines.push("", facts);
|
|
266
|
+
if (item.note)
|
|
267
|
+
lines.push("", `> ${escapeMd(item.note)}`);
|
|
268
|
+
if (link)
|
|
269
|
+
lines.push("", `[View the work →](<${link}>)`, "");
|
|
270
|
+
});
|
|
271
|
+
return lines.join("\n");
|
|
272
|
+
}
|
package/dist/boards.js
CHANGED
|
@@ -83,7 +83,7 @@ export function createBoard(boards, name, description) {
|
|
|
83
83
|
boards.push(board);
|
|
84
84
|
return board;
|
|
85
85
|
}
|
|
86
|
-
export function boardItemFromArtwork(artwork) {
|
|
86
|
+
export function boardItemFromArtwork(artwork, note = null) {
|
|
87
87
|
return {
|
|
88
88
|
id: artwork.id,
|
|
89
89
|
title: artwork.title,
|
|
@@ -98,17 +98,18 @@ export function boardItemFromArtwork(artwork) {
|
|
|
98
98
|
iiifUrl: artwork.iiifUrl,
|
|
99
99
|
sourceUrl: artwork.sourceUrl,
|
|
100
100
|
license: artwork.license,
|
|
101
|
+
note,
|
|
101
102
|
addedAt: new Date().toISOString(),
|
|
102
103
|
};
|
|
103
104
|
}
|
|
104
105
|
/** Add artworks to a board, skipping ids already present. Returns count added. */
|
|
105
|
-
export function addArtworksToBoard(board, artworks) {
|
|
106
|
+
export function addArtworksToBoard(board, artworks, note = null) {
|
|
106
107
|
const present = new Set(board.items.map((item) => item.id));
|
|
107
108
|
let added = 0;
|
|
108
109
|
for (const artwork of artworks) {
|
|
109
110
|
if (present.has(artwork.id))
|
|
110
111
|
continue;
|
|
111
|
-
board.items.push(boardItemFromArtwork(artwork));
|
|
112
|
+
board.items.push(boardItemFromArtwork(artwork, note));
|
|
112
113
|
present.add(artwork.id);
|
|
113
114
|
added += 1;
|
|
114
115
|
}
|
|
@@ -116,6 +117,15 @@ export function addArtworksToBoard(board, artworks) {
|
|
|
116
117
|
board.updatedAt = new Date().toISOString();
|
|
117
118
|
return added;
|
|
118
119
|
}
|
|
120
|
+
/** Set (or clear) the note on a board item by artwork id. Returns true if found. */
|
|
121
|
+
export function annotateBoardItem(board, artworkId, note) {
|
|
122
|
+
const item = board.items.find((entry) => entry.id === artworkId);
|
|
123
|
+
if (!item)
|
|
124
|
+
return false;
|
|
125
|
+
item.note = note;
|
|
126
|
+
board.updatedAt = new Date().toISOString();
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
119
129
|
/** Remove items by artwork id. Returns count removed. */
|
|
120
130
|
export function removeItemsFromBoard(board, ids) {
|
|
121
131
|
const remove = new Set(ids);
|
package/dist/images.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { API_BASE } from "./api.js";
|
|
2
|
-
|
|
2
|
+
// Per-image cap. Images are fetched in parallel, so the tool waits for the
|
|
3
|
+
// slowest one; a tight cap keeps a single stalled museum CDN from holding up
|
|
4
|
+
// the whole result (slow images are simply dropped from the gallery).
|
|
5
|
+
const IMAGE_TIMEOUT_MS = 10_000;
|
|
3
6
|
const MAX_IMAGE_BYTES = 6 * 1024 * 1024; // 6 MB — generous for a sized JPEG.
|
|
4
7
|
const USER_AGENT = "everymuseum-mcp/0.1 (+https://everymuseum.org)";
|
|
5
8
|
// MIME types Claude can render as image content blocks.
|
|
@@ -67,8 +70,10 @@ export function bestDisplayUrl(artwork, size) {
|
|
|
67
70
|
}
|
|
68
71
|
return resolveUrl(artwork.imageUrl ?? artwork.thumbnailUrl);
|
|
69
72
|
}
|
|
70
|
-
|
|
71
|
-
export
|
|
73
|
+
// High-res downloads can be much larger than inline previews.
|
|
74
|
+
export const DOWNLOAD_MAX_BYTES = 40 * 1024 * 1024; // 40 MB
|
|
75
|
+
/** Fetch image bytes with a hard size cap and content-type guard. Null on failure. */
|
|
76
|
+
export async function fetchImageBytes(url, maxBytes = MAX_IMAGE_BYTES) {
|
|
72
77
|
const resolved = resolveUrl(url);
|
|
73
78
|
if (!resolved)
|
|
74
79
|
return null;
|
|
@@ -89,9 +94,8 @@ export async function fetchImageContent(url) {
|
|
|
89
94
|
return null;
|
|
90
95
|
// Reject obviously oversized bodies up front…
|
|
91
96
|
const declaredLength = Number(response.headers.get("content-length"));
|
|
92
|
-
if (Number.isFinite(declaredLength) && declaredLength >
|
|
97
|
+
if (Number.isFinite(declaredLength) && declaredLength > maxBytes)
|
|
93
98
|
return null;
|
|
94
|
-
}
|
|
95
99
|
// …then stream with a hard byte cap so a lying/missing content-length can't
|
|
96
100
|
// make us buffer an unbounded body into memory.
|
|
97
101
|
const reader = response.body?.getReader();
|
|
@@ -106,7 +110,7 @@ export async function fetchImageContent(url) {
|
|
|
106
110
|
if (!value)
|
|
107
111
|
continue;
|
|
108
112
|
total += value.byteLength;
|
|
109
|
-
if (total >
|
|
113
|
+
if (total > maxBytes) {
|
|
110
114
|
controller.abort();
|
|
111
115
|
return null;
|
|
112
116
|
}
|
|
@@ -114,8 +118,7 @@ export async function fetchImageContent(url) {
|
|
|
114
118
|
}
|
|
115
119
|
if (total === 0)
|
|
116
120
|
return null;
|
|
117
|
-
|
|
118
|
-
return { type: "image", data: buffer.toString("base64"), mimeType };
|
|
121
|
+
return { buffer: Buffer.concat(chunks, total), mimeType };
|
|
119
122
|
}
|
|
120
123
|
catch {
|
|
121
124
|
return null;
|
|
@@ -124,7 +127,31 @@ export async function fetchImageContent(url) {
|
|
|
124
127
|
clearTimeout(timeout);
|
|
125
128
|
}
|
|
126
129
|
}
|
|
130
|
+
/** Fetch an image and return it as a base64 MCP image content block, or null. */
|
|
131
|
+
export async function fetchImageContent(url) {
|
|
132
|
+
const bytes = await fetchImageBytes(url, MAX_IMAGE_BYTES);
|
|
133
|
+
if (!bytes)
|
|
134
|
+
return null;
|
|
135
|
+
return { type: "image", data: bytes.buffer.toString("base64"), mimeType: bytes.mimeType };
|
|
136
|
+
}
|
|
127
137
|
/** Convenience: fetch the best display image for an artwork as a content block. */
|
|
128
138
|
export async function artworkImageContent(artwork, size) {
|
|
129
139
|
return fetchImageContent(bestDisplayUrl(artwork, size));
|
|
130
140
|
}
|
|
141
|
+
/** A large, renderable URL suitable for saving a high-resolution copy. */
|
|
142
|
+
export function downloadUrlFor(artwork) {
|
|
143
|
+
return bestDisplayUrl(artwork, 4096);
|
|
144
|
+
}
|
|
145
|
+
/** Map an image MIME type to a file extension. */
|
|
146
|
+
export function extensionForMime(mimeType) {
|
|
147
|
+
switch (mimeType) {
|
|
148
|
+
case "image/png":
|
|
149
|
+
return "png";
|
|
150
|
+
case "image/webp":
|
|
151
|
+
return "webp";
|
|
152
|
+
case "image/gif":
|
|
153
|
+
return "gif";
|
|
154
|
+
default:
|
|
155
|
+
return "jpg";
|
|
156
|
+
}
|
|
157
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
4
|
-
import { join } from "node:path";
|
|
4
|
+
import { basename, join, resolve, sep } from "node:path";
|
|
5
5
|
import { platform } from "node:process";
|
|
6
6
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
7
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
8
|
import { z } from "zod";
|
|
9
|
-
import { ApiError, API_BASE, findSimilar, getArtwork, getSuggestions,
|
|
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.
|
|
9
|
+
import { ApiError, API_BASE, findSimilar, getArtwork, getSuggestions, searchArtworksSmart, sourceLabel, } from "./api.js";
|
|
10
|
+
import { renderBoardHtml, renderBoardMarkdown } from "./board-html.js";
|
|
11
|
+
import { addArtworksToBoard, annotateBoardItem, boardsExportDir, createBoard, dataDir, deleteBoard, findBoard, loadBoards, removeItemsFromBoard, saveBoards, withBoards, } from "./boards.js";
|
|
12
|
+
import { artworkImageContent, downloadUrlFor, extensionForMime, fetchImageBytes, DOWNLOAD_MAX_BYTES, } from "./images.js";
|
|
13
|
+
const VERSION = "0.1.2";
|
|
14
14
|
function text(value) {
|
|
15
15
|
return { type: "text", text: value };
|
|
16
16
|
}
|
|
@@ -39,8 +39,10 @@ function captionLine(art, index) {
|
|
|
39
39
|
const date = art.dateDisplay?.trim() || "n.d.";
|
|
40
40
|
const label = sourceLabel(art.source);
|
|
41
41
|
const license = art.license?.trim();
|
|
42
|
-
const
|
|
43
|
-
|
|
42
|
+
const meta = [label, license].filter((part) => Boolean(part)).join(" · ");
|
|
43
|
+
// Link straight to the work: the source museum's record, or the EveryMuseum page.
|
|
44
|
+
const link = art.sourceUrl?.trim() || `${API_BASE}/artworks/${art.id}`;
|
|
45
|
+
return `${index}. "${title}" — ${artist} (${date}) · ${meta}\n ${link} · id: ${art.id}`;
|
|
44
46
|
}
|
|
45
47
|
/** Build a captioned gallery: one text line per item, each followed by its image. */
|
|
46
48
|
async function galleryContent(items, options) {
|
|
@@ -65,6 +67,7 @@ function boardItemCaption(item, index) {
|
|
|
65
67
|
dateDisplay: item.dateDisplay,
|
|
66
68
|
source: item.source,
|
|
67
69
|
license: item.license,
|
|
70
|
+
sourceUrl: item.sourceUrl,
|
|
68
71
|
}, index);
|
|
69
72
|
}
|
|
70
73
|
function boardSummaryLine(board) {
|
|
@@ -80,13 +83,15 @@ const server = new McpServer({ name: "everymuseum", version: VERSION }, {
|
|
|
80
83
|
"",
|
|
81
84
|
"Use these tools to pull up images conversationally, learn about specific works,",
|
|
82
85
|
"discover related pieces, and curate persistent local 'boards' (mood boards /",
|
|
83
|
-
"reading lists) that can be exported to a shareable HTML gallery.",
|
|
86
|
+
"reading lists) that can be exported to a shareable HTML or Markdown gallery.",
|
|
84
87
|
"",
|
|
85
|
-
"Workflow tips: search_artworks
|
|
86
|
-
"
|
|
87
|
-
"
|
|
88
|
-
"
|
|
89
|
-
"
|
|
88
|
+
"Workflow tips: search_artworks (supports multi-word queries + filters by museum,",
|
|
89
|
+
"year range, medium, culture, license) → get_artwork (deep detail) → find_similar",
|
|
90
|
+
"or compare_artworks (explore) → create_board / add_to_board (collect, with notes)",
|
|
91
|
+
"→ download_artworks (save hi-res files) or export_board (share as HTML/Markdown).",
|
|
92
|
+
"Every artwork has a stable `id`; pass it to get_artwork, find_similar, compare,",
|
|
93
|
+
"add_to_board, and download_artworks. Images are real artwork reproductions;",
|
|
94
|
+
"always attribute the source institution and respect the stated license.",
|
|
90
95
|
].join("\n"),
|
|
91
96
|
});
|
|
92
97
|
// ─── Discovery & images ──────────────────────────────────────────────────────
|
|
@@ -94,9 +99,11 @@ server.registerTool("search_artworks", {
|
|
|
94
99
|
title: "Search the EveryMuseum archive",
|
|
95
100
|
description: "Search open-access museum artworks by free text (artist, title, culture, " +
|
|
96
101
|
"medium, period, museum name, or theme — e.g. 'hokusai wave', 'art nouveau " +
|
|
97
|
-
"poster', 'egyptian bronze').
|
|
98
|
-
"
|
|
99
|
-
"
|
|
102
|
+
"poster', 'egyptian bronze'). Multi-word queries are matched intelligently. " +
|
|
103
|
+
"Optionally filter by museum, year range, medium, culture, or license. " +
|
|
104
|
+
"Returns captioned results with images so they can be shown directly in the " +
|
|
105
|
+
"conversation; each includes a link to the work and a stable id for use with " +
|
|
106
|
+
"get_artwork, find_similar, and add_to_board.",
|
|
100
107
|
inputSchema: {
|
|
101
108
|
query: z
|
|
102
109
|
.string()
|
|
@@ -117,6 +124,23 @@ server.registerTool("search_artworks", {
|
|
|
117
124
|
.max(10000)
|
|
118
125
|
.optional()
|
|
119
126
|
.describe("Skip this many results for paging through more (default 0)."),
|
|
127
|
+
source: z
|
|
128
|
+
.string()
|
|
129
|
+
.optional()
|
|
130
|
+
.describe("Filter to a museum, by key or name (e.g. 'met', 'rijksmuseum', 'getty')."),
|
|
131
|
+
yearFrom: z
|
|
132
|
+
.number()
|
|
133
|
+
.int()
|
|
134
|
+
.optional()
|
|
135
|
+
.describe("Only works whose date overlaps on/after this year (e.g. 1800)."),
|
|
136
|
+
yearTo: z
|
|
137
|
+
.number()
|
|
138
|
+
.int()
|
|
139
|
+
.optional()
|
|
140
|
+
.describe("Only works whose date overlaps on/before this year (e.g. 1900)."),
|
|
141
|
+
medium: z.string().optional().describe("Filter by medium substring (e.g. 'woodblock', 'bronze')."),
|
|
142
|
+
culture: z.string().optional().describe("Filter by culture/origin substring (e.g. 'japan', 'france')."),
|
|
143
|
+
license: z.string().optional().describe("Filter by license substring (e.g. 'public domain', 'cc0')."),
|
|
120
144
|
includeImages: z
|
|
121
145
|
.boolean()
|
|
122
146
|
.optional()
|
|
@@ -134,25 +158,30 @@ server.registerTool("search_artworks", {
|
|
|
134
158
|
const limit = args.limit ?? 6;
|
|
135
159
|
const includeImages = args.includeImages ?? true;
|
|
136
160
|
const imageSize = args.imageSize ?? 512;
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
161
|
+
const offset = args.offset ?? 0;
|
|
162
|
+
const filters = {
|
|
163
|
+
source: args.source,
|
|
164
|
+
yearFrom: args.yearFrom,
|
|
165
|
+
yearTo: args.yearTo,
|
|
166
|
+
medium: args.medium,
|
|
167
|
+
culture: args.culture,
|
|
168
|
+
license: args.license,
|
|
169
|
+
};
|
|
170
|
+
const result = await searchArtworksSmart(args.query, { limit, offset, filters });
|
|
141
171
|
if (result.items.length === 0) {
|
|
172
|
+
const filterNote = result.filtered ? " (with your filters applied)" : "";
|
|
142
173
|
return ok([
|
|
143
|
-
text(`No artworks matched "${args.query}". Try a broader term,
|
|
144
|
-
`
|
|
174
|
+
text(`No artworks matched "${args.query}"${filterNote}. Try a broader term, ` +
|
|
175
|
+
`fewer filters, or call suggest_searches for ideas.`),
|
|
145
176
|
]);
|
|
146
177
|
}
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const moreNote = result.total != null && offset + result.items.length < result.total
|
|
152
|
-
? ` Call again with offset: ${offset + result.items.length} for more.`
|
|
178
|
+
const termNote = result.terms.length > 1 ? ` matched on: ${result.terms.join(", ")}` : "";
|
|
179
|
+
const filterNote = result.filtered ? " · filtered" : "";
|
|
180
|
+
const moreNote = result.hasMore
|
|
181
|
+
? ` More available — call again with offset: ${offset + result.items.length}.`
|
|
153
182
|
: "";
|
|
154
183
|
const header = text(`Showing ${result.items.length} result${result.items.length === 1 ? "" : "s"}` +
|
|
155
|
-
|
|
184
|
+
` for "${args.query}"${termNote}${filterNote}.${moreNote}`);
|
|
156
185
|
const gallery = await galleryContent(result.items, {
|
|
157
186
|
includeImages,
|
|
158
187
|
imageSize,
|
|
@@ -267,6 +296,126 @@ server.registerTool("find_similar", {
|
|
|
267
296
|
...gallery,
|
|
268
297
|
]);
|
|
269
298
|
}));
|
|
299
|
+
server.registerTool("compare_artworks", {
|
|
300
|
+
title: "Compare several artworks side by side",
|
|
301
|
+
description: "Fetch multiple works by id and present them together with images and key " +
|
|
302
|
+
"facts — useful for contrasting style, period, medium, or treatment across pieces.",
|
|
303
|
+
inputSchema: {
|
|
304
|
+
ids: z
|
|
305
|
+
.array(z.string().min(1))
|
|
306
|
+
.min(2)
|
|
307
|
+
.max(8)
|
|
308
|
+
.describe("2–8 artwork ids to compare."),
|
|
309
|
+
imageSize: z
|
|
310
|
+
.number()
|
|
311
|
+
.int()
|
|
312
|
+
.min(200)
|
|
313
|
+
.max(2000)
|
|
314
|
+
.optional()
|
|
315
|
+
.describe("Long-edge pixel size for images (default 640)."),
|
|
316
|
+
},
|
|
317
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
318
|
+
}, async (args) => guard(async () => {
|
|
319
|
+
const unique = Array.from(new Set(args.ids));
|
|
320
|
+
const fetched = await Promise.all(unique.map((id) => getArtwork(id).catch(() => null)));
|
|
321
|
+
const items = fetched.filter((art) => art !== null);
|
|
322
|
+
const missing = unique.filter((id) => !items.some((art) => art.id === id));
|
|
323
|
+
if (items.length === 0) {
|
|
324
|
+
return fail(`None of those ids resolved: ${unique.join(", ")}.`);
|
|
325
|
+
}
|
|
326
|
+
const gallery = await galleryContent(items, {
|
|
327
|
+
includeImages: true,
|
|
328
|
+
imageSize: args.imageSize ?? 640,
|
|
329
|
+
});
|
|
330
|
+
const missingNote = missing.length ? ` (couldn't find: ${missing.join(", ")})` : "";
|
|
331
|
+
return ok([text(`Comparing ${items.length} works${missingNote}:`), ...gallery]);
|
|
332
|
+
}));
|
|
333
|
+
function slugForFile(value) {
|
|
334
|
+
return (value ?? "")
|
|
335
|
+
.normalize("NFKD")
|
|
336
|
+
.replace(/[^\w\s-]/g, "")
|
|
337
|
+
.trim()
|
|
338
|
+
.replace(/\s+/g, "-")
|
|
339
|
+
.toLowerCase()
|
|
340
|
+
.slice(0, 60);
|
|
341
|
+
}
|
|
342
|
+
function downloadFilename(art, mimeType) {
|
|
343
|
+
// art.id comes straight from the API JSON and is NOT trusted to be a clean
|
|
344
|
+
// cuid — strip it to a safe segment so no path separators / '..' can reach
|
|
345
|
+
// the filename. (The caller also basenames + asserts containment.)
|
|
346
|
+
const safeId = art.id.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 40) || "id";
|
|
347
|
+
const base = [slugForFile(art.title) || "artwork", slugForFile(sourceLabel(art.source)), safeId]
|
|
348
|
+
.filter(Boolean)
|
|
349
|
+
.join("-")
|
|
350
|
+
.slice(0, 120);
|
|
351
|
+
return `${base}.${extensionForMime(mimeType)}`;
|
|
352
|
+
}
|
|
353
|
+
function resolveDownloadDir(folder) {
|
|
354
|
+
const trimmed = folder?.trim();
|
|
355
|
+
return trimmed ? resolve(trimmed) : join(dataDir(), "downloads");
|
|
356
|
+
}
|
|
357
|
+
server.registerTool("download_artworks", {
|
|
358
|
+
title: "Download high-resolution images to disk",
|
|
359
|
+
description: "Save high-resolution image files for one or more works (by id) to a folder " +
|
|
360
|
+
"on this machine, returning the saved file paths. Use to collect the actual " +
|
|
361
|
+
"image files rather than inline previews.",
|
|
362
|
+
inputSchema: {
|
|
363
|
+
ids: z
|
|
364
|
+
.array(z.string().min(1))
|
|
365
|
+
.min(1)
|
|
366
|
+
.max(50)
|
|
367
|
+
.describe("Artwork ids to download."),
|
|
368
|
+
folder: z
|
|
369
|
+
.string()
|
|
370
|
+
.optional()
|
|
371
|
+
.describe("Destination folder (default ~/.everymuseum/downloads). Created if missing."),
|
|
372
|
+
},
|
|
373
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
374
|
+
}, async (args) => guard(async () => {
|
|
375
|
+
const dir = resolveDownloadDir(args.folder);
|
|
376
|
+
await mkdir(dir, { recursive: true });
|
|
377
|
+
const root = resolve(dir);
|
|
378
|
+
const unique = Array.from(new Set(args.ids));
|
|
379
|
+
const downloadOne = async (id) => {
|
|
380
|
+
try {
|
|
381
|
+
const art = await getArtwork(id);
|
|
382
|
+
const bytes = await fetchImageBytes(downloadUrlFor(art), DOWNLOAD_MAX_BYTES);
|
|
383
|
+
if (!bytes)
|
|
384
|
+
return { id, ok: false, reason: "no downloadable image" };
|
|
385
|
+
// basename + containment assertion: the write can never escape `dir`,
|
|
386
|
+
// even if a field slipped a separator past slugging.
|
|
387
|
+
const filePath = join(dir, basename(downloadFilename(art, bytes.mimeType)));
|
|
388
|
+
if (resolve(filePath) !== root && !resolve(filePath).startsWith(root + sep)) {
|
|
389
|
+
return { id, ok: false, reason: "unsafe path" };
|
|
390
|
+
}
|
|
391
|
+
await writeFile(filePath, bytes.buffer);
|
|
392
|
+
return { id, ok: true, path: filePath, kb: Math.round(bytes.buffer.byteLength / 1024) };
|
|
393
|
+
}
|
|
394
|
+
catch (error) {
|
|
395
|
+
return { id, ok: false, reason: error instanceof Error ? error.message : "failed" };
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
// Bounded concurrency so peak memory is pool×cap, not 50×40 MB.
|
|
399
|
+
const CONCURRENCY = 4;
|
|
400
|
+
const results = [];
|
|
401
|
+
for (let i = 0; i < unique.length; i += CONCURRENCY) {
|
|
402
|
+
const batch = unique.slice(i, i + CONCURRENCY);
|
|
403
|
+
results.push(...(await Promise.all(batch.map(downloadOne))));
|
|
404
|
+
}
|
|
405
|
+
const saved = results.filter((r) => r.ok);
|
|
406
|
+
const failed = results.filter((r) => !r.ok);
|
|
407
|
+
const lines = [
|
|
408
|
+
`Saved ${saved.length} of ${unique.length} image${unique.length === 1 ? "" : "s"} to ${dir}:`,
|
|
409
|
+
];
|
|
410
|
+
for (const entry of saved)
|
|
411
|
+
lines.push(`• ${entry.path} (${entry.kb} KB)`);
|
|
412
|
+
if (failed.length) {
|
|
413
|
+
lines.push(`Could not download ${failed.length}:`);
|
|
414
|
+
for (const entry of failed)
|
|
415
|
+
lines.push(`• ${entry.id} — ${entry.reason}`);
|
|
416
|
+
}
|
|
417
|
+
return ok([text(lines.join("\n"))]);
|
|
418
|
+
}));
|
|
270
419
|
server.registerTool("suggest_searches", {
|
|
271
420
|
title: "Suggest things to explore",
|
|
272
421
|
description: "Return a curated list of popular search terms drawn from the archive's top " +
|
|
@@ -348,6 +497,11 @@ server.registerTool("add_to_board", {
|
|
|
348
497
|
.min(1)
|
|
349
498
|
.max(100)
|
|
350
499
|
.describe("Artwork ids to add (from search/get/similar results)."),
|
|
500
|
+
note: z
|
|
501
|
+
.string()
|
|
502
|
+
.max(500)
|
|
503
|
+
.optional()
|
|
504
|
+
.describe("Optional note applied to each work added in this call (why it's here)."),
|
|
351
505
|
},
|
|
352
506
|
annotations: { readOnlyHint: false },
|
|
353
507
|
}, async (args) => guard(async () => {
|
|
@@ -370,7 +524,7 @@ server.registerTool("add_to_board", {
|
|
|
370
524
|
return fail(`No board matches "${args.board}". Use list_boards to see boards, or ` +
|
|
371
525
|
`create_board to make one.`);
|
|
372
526
|
}
|
|
373
|
-
const added = addArtworksToBoard(board, found);
|
|
527
|
+
const added = addArtworksToBoard(board, found, args.note?.trim() || null);
|
|
374
528
|
const skipped = found.length - added;
|
|
375
529
|
await saveBoards(boards);
|
|
376
530
|
const parts = [
|
|
@@ -386,6 +540,35 @@ server.registerTool("add_to_board", {
|
|
|
386
540
|
return ok([text(parts.join(" "))]);
|
|
387
541
|
});
|
|
388
542
|
}));
|
|
543
|
+
server.registerTool("annotate_board_item", {
|
|
544
|
+
title: "Add or edit a note on a board item",
|
|
545
|
+
description: "Set (or clear) the curator note on one work already on a board — e.g. why " +
|
|
546
|
+
"it was saved, or what to notice. Notes show in view_board and exports.",
|
|
547
|
+
inputSchema: {
|
|
548
|
+
board: z.string().min(1).describe("Board slug, name, or id."),
|
|
549
|
+
artworkId: z.string().min(1).describe("The artwork id on the board to annotate."),
|
|
550
|
+
note: z
|
|
551
|
+
.string()
|
|
552
|
+
.max(500)
|
|
553
|
+
.describe("The note text. Pass an empty string to clear the note."),
|
|
554
|
+
},
|
|
555
|
+
annotations: { readOnlyHint: false },
|
|
556
|
+
}, async (args) => guard(() => withBoards(async (boards) => {
|
|
557
|
+
const board = findBoard(boards, args.board);
|
|
558
|
+
if (!board)
|
|
559
|
+
return fail(`No board matches "${args.board}".`);
|
|
560
|
+
const note = args.note.trim() || null;
|
|
561
|
+
const found = annotateBoardItem(board, args.artworkId, note);
|
|
562
|
+
if (!found) {
|
|
563
|
+
return fail(`Artwork ${args.artworkId} is not on "${board.name}".`);
|
|
564
|
+
}
|
|
565
|
+
await saveBoards(boards);
|
|
566
|
+
return ok([
|
|
567
|
+
text(note
|
|
568
|
+
? `Noted on "${board.name}": ${args.artworkId} — "${note}".`
|
|
569
|
+
: `Cleared the note on ${args.artworkId} in "${board.name}".`),
|
|
570
|
+
]);
|
|
571
|
+
})));
|
|
389
572
|
server.registerTool("view_board", {
|
|
390
573
|
title: "View a board's contents",
|
|
391
574
|
description: "Show the works on a board with captioned images, identified by slug, name, " +
|
|
@@ -468,16 +651,21 @@ server.registerTool("remove_from_board", {
|
|
|
468
651
|
]);
|
|
469
652
|
})));
|
|
470
653
|
server.registerTool("export_board", {
|
|
471
|
-
title: "Export a board to
|
|
472
|
-
description: "Render a board
|
|
473
|
-
"
|
|
474
|
-
"
|
|
654
|
+
title: "Export a board to a shareable gallery",
|
|
655
|
+
description: "Render a board and write it to disk. format 'html' (default) produces a " +
|
|
656
|
+
"self-contained editorial gallery page (captions, attribution, notes, source " +
|
|
657
|
+
"links) that can optionally open in the browser. format 'markdown' returns " +
|
|
658
|
+
"copy-pasteable Markdown (for docs/Notion/READMEs) and also writes a .md file.",
|
|
475
659
|
inputSchema: {
|
|
476
660
|
board: z.string().min(1).describe("Board slug, name, or id to export."),
|
|
661
|
+
format: z
|
|
662
|
+
.enum(["html", "markdown"])
|
|
663
|
+
.optional()
|
|
664
|
+
.describe("Output format (default 'html')."),
|
|
477
665
|
open: z
|
|
478
666
|
.boolean()
|
|
479
667
|
.optional()
|
|
480
|
-
.describe("
|
|
668
|
+
.describe("For HTML, open the page in the default browser (default false)."),
|
|
481
669
|
},
|
|
482
670
|
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
483
671
|
}, async (args) => guard(async () => {
|
|
@@ -487,6 +675,16 @@ server.registerTool("export_board", {
|
|
|
487
675
|
return fail(`No board matches "${args.board}".`);
|
|
488
676
|
const dir = boardsExportDir();
|
|
489
677
|
await mkdir(dir, { recursive: true });
|
|
678
|
+
const countLabel = `${board.items.length} work${board.items.length === 1 ? "" : "s"}`;
|
|
679
|
+
if ((args.format ?? "html") === "markdown") {
|
|
680
|
+
const markdown = renderBoardMarkdown(board);
|
|
681
|
+
const filePath = join(dir, `${board.slug}-${board.id}.md`);
|
|
682
|
+
await writeFile(filePath, markdown, "utf8");
|
|
683
|
+
return ok([
|
|
684
|
+
text(`Exported "${board.name}" (${countLabel}) as Markdown to:\n${filePath}\n\n` +
|
|
685
|
+
`--- copy below ---\n\n${markdown}`),
|
|
686
|
+
]);
|
|
687
|
+
}
|
|
490
688
|
const filePath = join(dir, `${board.slug}-${board.id}.html`);
|
|
491
689
|
await writeFile(filePath, renderBoardHtml(board), "utf8");
|
|
492
690
|
let openNote = "";
|
|
@@ -510,7 +708,7 @@ server.registerTool("export_board", {
|
|
|
510
708
|
}
|
|
511
709
|
}
|
|
512
710
|
return ok([
|
|
513
|
-
text(`Exported "${board.name}" (${
|
|
711
|
+
text(`Exported "${board.name}" (${countLabel}) to:\n${filePath}${openNote}`),
|
|
514
712
|
]);
|
|
515
713
|
}));
|
|
516
714
|
server.registerTool("delete_board", {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "everymuseum-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "MCP server for the EveryMuseum archive — search open-access museum artworks, pull up images, find similar works, and curate boards, all from a conversation with Claude.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|