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 CHANGED
@@ -116,7 +116,11 @@ function asArtwork(value) {
116
116
  updatedAt: str("updatedAt") ?? undefined,
117
117
  };
118
118
  }
119
- /** Search the archive. Always requests totals so callers get paging metadata. */
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
- params.set("includeTotal", "1");
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 body = await apiFetch(`/api/artworks/${encodeURIComponent(id)}`);
146
- if (!body || typeof body !== "object" || !("id" in body)) {
147
- throw new ApiError(`No artwork found for id "${id}".`, 404);
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
- return asArtwork(body);
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) {
@@ -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("", `![${escapeMd(title)}](<${imageUrl}>)`);
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
- const IMAGE_TIMEOUT_MS = 20_000;
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
- /** Fetch an image and return it as a base64 MCP image content block, or null. */
71
- export async function fetchImageContent(url) {
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 > MAX_IMAGE_BYTES) {
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 > MAX_IMAGE_BYTES) {
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
- const buffer = Buffer.concat(chunks, total);
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, 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";
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 tail = license ? ` · ${license}` : "";
43
- return `${index}. "${title}" ${artist} (${date}) · ${label}${tail}\n id: ${art.id}`;
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 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.",
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'). 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.",
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 result = await searchArtworks(args.query, {
138
- limit,
139
- offset: args.offset ?? 0,
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, an artist, ` +
144
- `a culture, or call suggest_searches for ideas.`),
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 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.`
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
- `${totalNote} for "${args.query}".${moreNote}`);
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 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.",
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("Open the exported page in the default browser (default false)."),
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}" (${board.items.length} works) to:\n${filePath}${openNote}`),
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.0",
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",