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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zak Krevitt
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# EveryMuseum MCP server
|
|
2
|
+
|
|
3
|
+
An [MCP](https://modelcontextprotocol.io) server that puts the **EveryMuseum**
|
|
4
|
+
open-access art archive inside any MCP-capable client (Claude Desktop, Claude
|
|
5
|
+
Code, Cursor, …). Once added, you can *converse* with millions of public-domain
|
|
6
|
+
and openly-licensed artworks from The Met, Rijksmuseum, the Art Institute of
|
|
7
|
+
Chicago, the V&A, Getty, Smithsonian, Harvard, Cleveland, and more:
|
|
8
|
+
|
|
9
|
+
- **Pull up images** — search by artist, culture, period, medium, or theme and
|
|
10
|
+
see real artwork images inline in the conversation.
|
|
11
|
+
- **Learn** — get full curatorial detail (description, medium, dimensions,
|
|
12
|
+
culture, license, source link) and a high-resolution image for any work.
|
|
13
|
+
- **Go deeper** — find visually and thematically similar works.
|
|
14
|
+
- **Build boards** — collect works into persistent local boards (mood boards /
|
|
15
|
+
reading lists) and export them to a shareable, editorial HTML gallery.
|
|
16
|
+
|
|
17
|
+
It talks to the live public API at `https://everymuseum.org` — no database,
|
|
18
|
+
API key, or local copy of the app required.
|
|
19
|
+
|
|
20
|
+
## Add it to Claude
|
|
21
|
+
|
|
22
|
+
### Claude Desktop
|
|
23
|
+
|
|
24
|
+
Edit your `claude_desktop_config.json`
|
|
25
|
+
(macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`):
|
|
26
|
+
|
|
27
|
+
```jsonc
|
|
28
|
+
{
|
|
29
|
+
"mcpServers": {
|
|
30
|
+
"everymuseum": {
|
|
31
|
+
"command": "npx",
|
|
32
|
+
"args": ["-y", "everymuseum-mcp"]
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Restart Claude Desktop. You'll see the EveryMuseum tools available.
|
|
39
|
+
|
|
40
|
+
### Claude Code
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
claude mcp add everymuseum -- npx -y everymuseum-mcp
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Running from a local checkout (before npm publish)
|
|
47
|
+
|
|
48
|
+
Build once, then point your client at the compiled entrypoint:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
cd mcp
|
|
52
|
+
npm install
|
|
53
|
+
npm run build
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```jsonc
|
|
57
|
+
{
|
|
58
|
+
"mcpServers": {
|
|
59
|
+
"everymuseum": {
|
|
60
|
+
"command": "node",
|
|
61
|
+
"args": ["/absolute/path/to/CulturalAtlas/mcp/dist/index.js"]
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Claude Code equivalent:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
claude mcp add everymuseum -- node /absolute/path/to/CulturalAtlas/mcp/dist/index.js
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Try it
|
|
74
|
+
|
|
75
|
+
> "Show me some Hokusai prints."
|
|
76
|
+
> "Tell me more about the third one."
|
|
77
|
+
> "Find works similar to it."
|
|
78
|
+
> "Start a board called *Edo seascapes* and add those two."
|
|
79
|
+
> "Export the board and open it."
|
|
80
|
+
|
|
81
|
+
## Tools
|
|
82
|
+
|
|
83
|
+
| Tool | What it does |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| `search_artworks` | Free-text search; returns captioned results with images. |
|
|
86
|
+
| `get_artwork` | Full metadata + high-res image for one work (by id). |
|
|
87
|
+
| `find_similar` | Related works for a given artwork id. |
|
|
88
|
+
| `suggest_searches` | Curated ideas drawn from the archive's top facets. |
|
|
89
|
+
| `create_board` | Create a saved board. |
|
|
90
|
+
| `add_to_board` | Add works (by id) to a board. |
|
|
91
|
+
| `view_board` | Show a board's works with images. |
|
|
92
|
+
| `list_boards` | List all saved boards. |
|
|
93
|
+
| `remove_from_board` | Remove works from a board. |
|
|
94
|
+
| `export_board` | Render a board to a shareable HTML gallery (optionally open it). |
|
|
95
|
+
| `delete_board` | Delete a board (requires `confirm: true`). |
|
|
96
|
+
|
|
97
|
+
## Boards
|
|
98
|
+
|
|
99
|
+
Boards are stored locally as JSON at `~/.everymuseum/boards.json` and persist
|
|
100
|
+
across conversations. Each added work is snapshotted, so a board still renders
|
|
101
|
+
even if the archive changes. Exported galleries are written to
|
|
102
|
+
`~/.everymuseum/boards/<slug>-<id>.html` — self-contained, offline-friendly, and
|
|
103
|
+
safe to share.
|
|
104
|
+
|
|
105
|
+
## Configuration
|
|
106
|
+
|
|
107
|
+
| Env var | Default | Purpose |
|
|
108
|
+
| --- | --- | --- |
|
|
109
|
+
| `EVERYMUSEUM_API_BASE` | `https://everymuseum.org` | Archive to query (set to `http://localhost:3000` for local dev). |
|
|
110
|
+
| `EVERYMUSEUM_DATA_DIR` | `~/.everymuseum` | Where boards and exports are stored. |
|
|
111
|
+
|
|
112
|
+
## Develop
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
npm install
|
|
116
|
+
npm run build # tsc → dist/
|
|
117
|
+
npm test # build + end-to-end stdio smoke test against the live API
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The smoke test (`test/smoke.mjs`) spawns the server, speaks JSON-RPC over stdio,
|
|
121
|
+
and exercises every tool against the live archive using a throwaway data dir.
|
|
122
|
+
|
|
123
|
+
## Attribution & licensing
|
|
124
|
+
|
|
125
|
+
Images and rights belong to the source institutions. The archive only surfaces
|
|
126
|
+
openly-licensed / public-domain records, but always check the per-work `license`
|
|
127
|
+
field before reuse. This server's own code is MIT-licensed.
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base URL of the EveryMuseum deployment to query. Defaults to production.
|
|
3
|
+
* Override with EVERYMUSEUM_API_BASE (e.g. http://localhost:3000) for local dev.
|
|
4
|
+
*/
|
|
5
|
+
export const API_BASE = (process.env.EVERYMUSEUM_API_BASE || "https://everymuseum.org").replace(/\/+$/, "");
|
|
6
|
+
const REQUEST_TIMEOUT_MS = 20_000;
|
|
7
|
+
const USER_AGENT = "everymuseum-mcp/0.1 (+https://everymuseum.org)";
|
|
8
|
+
// Mirrors SOURCE_LABELS in the app's lib/normalize.ts. Used for human-readable
|
|
9
|
+
// attribution in tool output and board exports.
|
|
10
|
+
const SOURCE_LABELS = {
|
|
11
|
+
met: "The Met",
|
|
12
|
+
cleveland: "Cleveland Museum of Art",
|
|
13
|
+
artic: "Art Institute of Chicago",
|
|
14
|
+
vam: "Victoria and Albert Museum",
|
|
15
|
+
wellcome: "Wellcome Collection",
|
|
16
|
+
walters: "The Walters Art Museum",
|
|
17
|
+
smithsonian: "Smithsonian Open Access",
|
|
18
|
+
rijksmuseum: "Rijksmuseum",
|
|
19
|
+
getty: "Getty",
|
|
20
|
+
loc: "Library of Congress",
|
|
21
|
+
harvard: "Harvard Art Museums",
|
|
22
|
+
"te-papa": "Te Papa Tongarewa",
|
|
23
|
+
tepapa: "Te Papa Tongarewa",
|
|
24
|
+
auckland: "Auckland Museum",
|
|
25
|
+
"cooper-hewitt": "Cooper Hewitt",
|
|
26
|
+
cooperhewitt: "Cooper Hewitt",
|
|
27
|
+
mia: "Minneapolis Institute of Art",
|
|
28
|
+
nga: "National Gallery of Art",
|
|
29
|
+
smk: "National Gallery of Denmark",
|
|
30
|
+
ycba: "Yale Center for British Art",
|
|
31
|
+
};
|
|
32
|
+
export function sourceLabel(source) {
|
|
33
|
+
return SOURCE_LABELS[source] ?? source;
|
|
34
|
+
}
|
|
35
|
+
/** Error carrying an HTTP status so callers can craft friendly messages. */
|
|
36
|
+
export class ApiError extends Error {
|
|
37
|
+
status;
|
|
38
|
+
constructor(message, status) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.status = status;
|
|
41
|
+
this.name = "ApiError";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function apiFetch(path) {
|
|
45
|
+
const url = path.startsWith("http") ? path : `${API_BASE}${path}`;
|
|
46
|
+
const controller = new AbortController();
|
|
47
|
+
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
48
|
+
try {
|
|
49
|
+
// The abort signal stays armed across the body read too, so a stalled
|
|
50
|
+
// response body is bounded by the timeout, not just the header phase.
|
|
51
|
+
const response = await fetch(url, {
|
|
52
|
+
headers: { Accept: "application/json", "User-Agent": USER_AGENT },
|
|
53
|
+
signal: controller.signal,
|
|
54
|
+
});
|
|
55
|
+
const text = await response.text();
|
|
56
|
+
let body = null;
|
|
57
|
+
if (text) {
|
|
58
|
+
try {
|
|
59
|
+
body = JSON.parse(text);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
body = null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
const apiMessage = body && typeof body === "object" && "error" in body
|
|
67
|
+
? String(body.error)
|
|
68
|
+
: `Request failed with status ${response.status}.`;
|
|
69
|
+
throw new ApiError(apiMessage, response.status);
|
|
70
|
+
}
|
|
71
|
+
return body;
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (error instanceof ApiError)
|
|
75
|
+
throw error;
|
|
76
|
+
if (controller.signal.aborted) {
|
|
77
|
+
throw new ApiError(`EveryMuseum request timed out after ${REQUEST_TIMEOUT_MS / 1000}s.`);
|
|
78
|
+
}
|
|
79
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
80
|
+
throw new ApiError(`Could not reach EveryMuseum at ${API_BASE} (${reason}).`);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
clearTimeout(timeout);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function asArtwork(value) {
|
|
87
|
+
// The API already returns a normalized shape; we trust it but coerce the
|
|
88
|
+
// fields we depend on so downstream code never sees `undefined`.
|
|
89
|
+
const record = (value ?? {});
|
|
90
|
+
const str = (key) => typeof record[key] === "string" ? record[key] : null;
|
|
91
|
+
const num = (key) => typeof record[key] === "number" ? record[key] : null;
|
|
92
|
+
return {
|
|
93
|
+
id: String(record.id ?? ""),
|
|
94
|
+
source: String(record.source ?? ""),
|
|
95
|
+
sourceObjectId: String(record.sourceObjectId ?? ""),
|
|
96
|
+
title: str("title"),
|
|
97
|
+
artist: str("artist"),
|
|
98
|
+
dateDisplay: str("dateDisplay"),
|
|
99
|
+
dateStart: num("dateStart"),
|
|
100
|
+
dateEnd: num("dateEnd"),
|
|
101
|
+
medium: str("medium"),
|
|
102
|
+
dimensions: str("dimensions"),
|
|
103
|
+
culture: str("culture"),
|
|
104
|
+
department: str("department"),
|
|
105
|
+
classification: str("classification"),
|
|
106
|
+
description: str("description"),
|
|
107
|
+
tags: Array.isArray(record.tags)
|
|
108
|
+
? record.tags.filter((tag) => typeof tag === "string")
|
|
109
|
+
: [],
|
|
110
|
+
imageUrl: str("imageUrl"),
|
|
111
|
+
thumbnailUrl: str("thumbnailUrl"),
|
|
112
|
+
iiifUrl: str("iiifUrl"),
|
|
113
|
+
license: str("license"),
|
|
114
|
+
sourceUrl: str("sourceUrl"),
|
|
115
|
+
createdAt: str("createdAt") ?? undefined,
|
|
116
|
+
updatedAt: str("updatedAt") ?? undefined,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** Search the archive. Always requests totals so callers get paging metadata. */
|
|
120
|
+
export async function searchArtworks(query, options = {}) {
|
|
121
|
+
const params = new URLSearchParams();
|
|
122
|
+
const trimmed = query.trim();
|
|
123
|
+
if (trimmed)
|
|
124
|
+
params.set("q", trimmed);
|
|
125
|
+
params.set("limit", String(options.limit ?? 12));
|
|
126
|
+
if (options.offset)
|
|
127
|
+
params.set("offset", String(options.offset));
|
|
128
|
+
params.set("includeTotal", "1");
|
|
129
|
+
const body = await apiFetch(`/api/search?${params.toString()}`);
|
|
130
|
+
// includeTotal=1 returns { items, total, limit, offset, nextCursor }.
|
|
131
|
+
if (body && typeof body === "object" && "items" in body) {
|
|
132
|
+
const envelope = body;
|
|
133
|
+
return {
|
|
134
|
+
items: Array.isArray(envelope.items) ? envelope.items.map(asArtwork) : [],
|
|
135
|
+
total: typeof envelope.total === "number" ? envelope.total : null,
|
|
136
|
+
nextCursor: typeof envelope.nextCursor === "string" ? envelope.nextCursor : null,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
// Defensive fallback: a bare array (when includeTotal is ignored).
|
|
140
|
+
const items = Array.isArray(body) ? body.map(asArtwork) : [];
|
|
141
|
+
return { items, total: null, nextCursor: null };
|
|
142
|
+
}
|
|
143
|
+
/** Fetch full detail for a single artwork. Throws ApiError(404) if missing. */
|
|
144
|
+
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);
|
|
148
|
+
}
|
|
149
|
+
return asArtwork(body);
|
|
150
|
+
}
|
|
151
|
+
/** Works the archive considers similar to the given artwork (up to ~12). */
|
|
152
|
+
export async function findSimilar(id) {
|
|
153
|
+
try {
|
|
154
|
+
const body = await apiFetch(`/api/similar/${encodeURIComponent(id)}`);
|
|
155
|
+
return Array.isArray(body) ? body.map(asArtwork) : [];
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
// Treat an unknown id (404) as "no neighbors" so the caller can show its
|
|
159
|
+
// tailored empty-state guidance instead of a generic API error.
|
|
160
|
+
if (error instanceof ApiError && error.status === 404)
|
|
161
|
+
return [];
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** Curated search suggestions (artists, cultures, collections, media, sources). */
|
|
166
|
+
export async function getSuggestions() {
|
|
167
|
+
const body = await apiFetch("/api/search/suggestions");
|
|
168
|
+
if (body && typeof body === "object" && "suggestions" in body) {
|
|
169
|
+
const list = body.suggestions;
|
|
170
|
+
if (Array.isArray(list)) {
|
|
171
|
+
return list.filter((item) => typeof item === "string");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { bestDisplayUrl } from "./images.js";
|
|
2
|
+
const HTML_IMAGE_SIZE = 1100; // sharp on retina without bloating the page
|
|
3
|
+
function escapeHtml(value) {
|
|
4
|
+
return value
|
|
5
|
+
.replace(/&/g, "&")
|
|
6
|
+
.replace(/</g, "<")
|
|
7
|
+
.replace(/>/g, ">")
|
|
8
|
+
.replace(/"/g, """)
|
|
9
|
+
.replace(/'/g, "'");
|
|
10
|
+
}
|
|
11
|
+
/** Only allow http(s) URLs into href/src to keep the export safe to open. */
|
|
12
|
+
function safeUrl(value) {
|
|
13
|
+
if (!value)
|
|
14
|
+
return null;
|
|
15
|
+
try {
|
|
16
|
+
const url = new URL(value);
|
|
17
|
+
return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : null;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function formatGenerated(iso) {
|
|
24
|
+
const date = new Date(iso);
|
|
25
|
+
if (Number.isNaN(date.getTime()))
|
|
26
|
+
return "";
|
|
27
|
+
return date.toLocaleDateString("en-US", {
|
|
28
|
+
year: "numeric",
|
|
29
|
+
month: "long",
|
|
30
|
+
day: "numeric",
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function renderCard(item) {
|
|
34
|
+
const imageUrl = safeUrl(bestDisplayUrl(item, HTML_IMAGE_SIZE));
|
|
35
|
+
const sourceUrl = safeUrl(item.sourceUrl);
|
|
36
|
+
const title = escapeHtml(item.title ?? "Untitled");
|
|
37
|
+
const alt = escapeHtml([item.title, item.artist].filter(Boolean).join(" — ") || "Artwork");
|
|
38
|
+
const figure = imageUrl
|
|
39
|
+
? `<img src="${escapeHtml(imageUrl)}" alt="${alt}" loading="lazy" />`
|
|
40
|
+
: `<div class="missing">image unavailable</div>`;
|
|
41
|
+
const metaLines = [];
|
|
42
|
+
if (item.artist)
|
|
43
|
+
metaLines.push(`<span class="artist">${escapeHtml(item.artist)}</span>`);
|
|
44
|
+
const factParts = [item.dateDisplay, item.medium].filter(Boolean).map((part) => escapeHtml(part));
|
|
45
|
+
if (factParts.length)
|
|
46
|
+
metaLines.push(`<span class="facts">${factParts.join(" · ")}</span>`);
|
|
47
|
+
const attribution = [item.sourceLabel, item.license].filter(Boolean).map((part) => escapeHtml(part));
|
|
48
|
+
if (attribution.length)
|
|
49
|
+
metaLines.push(`<span class="attr">${attribution.join(" · ")}</span>`);
|
|
50
|
+
const caption = `
|
|
51
|
+
<figcaption>
|
|
52
|
+
<span class="title">${title}</span>
|
|
53
|
+
${metaLines.join("\n ")}
|
|
54
|
+
</figcaption>`;
|
|
55
|
+
const inner = `<figure>${figure}${caption}</figure>`;
|
|
56
|
+
return sourceUrl
|
|
57
|
+
? `<article class="card"><a href="${escapeHtml(sourceUrl)}" target="_blank" rel="noreferrer noopener">${inner}</a></article>`
|
|
58
|
+
: `<article class="card">${inner}</article>`;
|
|
59
|
+
}
|
|
60
|
+
/** Render a board as a self-contained, offline-friendly gallery page. */
|
|
61
|
+
export function renderBoardHtml(board) {
|
|
62
|
+
const generated = formatGenerated(new Date().toISOString());
|
|
63
|
+
const count = board.items.length;
|
|
64
|
+
const countLabel = `${count} ${count === 1 ? "work" : "works"}`;
|
|
65
|
+
const description = board.description
|
|
66
|
+
? `<p class="board-desc">${escapeHtml(board.description)}</p>`
|
|
67
|
+
: "";
|
|
68
|
+
const cards = board.items.map(renderCard).join("\n");
|
|
69
|
+
const body = count
|
|
70
|
+
? `<main class="grid">\n${cards}\n</main>`
|
|
71
|
+
: `<main class="empty"><p>This board is empty. Add works to it with the <code>add_to_board</code> tool.</p></main>`;
|
|
72
|
+
return `<!DOCTYPE html>
|
|
73
|
+
<html lang="en">
|
|
74
|
+
<head>
|
|
75
|
+
<meta charset="utf-8" />
|
|
76
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
77
|
+
<title>${escapeHtml(board.name)} · EveryMuseum board</title>
|
|
78
|
+
<style>
|
|
79
|
+
:root {
|
|
80
|
+
--paper: #f7f4ee;
|
|
81
|
+
--ink: #1a1714;
|
|
82
|
+
--muted: #6b6358;
|
|
83
|
+
--hair: #e4ddd0;
|
|
84
|
+
--accent: #7a2e1d;
|
|
85
|
+
}
|
|
86
|
+
* { box-sizing: border-box; }
|
|
87
|
+
html { -webkit-text-size-adjust: 100%; }
|
|
88
|
+
body {
|
|
89
|
+
margin: 0;
|
|
90
|
+
background: var(--paper);
|
|
91
|
+
color: var(--ink);
|
|
92
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
93
|
+
line-height: 1.5;
|
|
94
|
+
}
|
|
95
|
+
header {
|
|
96
|
+
max-width: 1280px;
|
|
97
|
+
margin: 0 auto;
|
|
98
|
+
padding: 72px 32px 40px;
|
|
99
|
+
border-bottom: 1px solid var(--hair);
|
|
100
|
+
}
|
|
101
|
+
.eyebrow {
|
|
102
|
+
font-size: 12px;
|
|
103
|
+
letter-spacing: 0.18em;
|
|
104
|
+
text-transform: uppercase;
|
|
105
|
+
color: var(--muted);
|
|
106
|
+
margin: 0 0 18px;
|
|
107
|
+
}
|
|
108
|
+
h1 {
|
|
109
|
+
font-family: Georgia, "Iowan Old Style", "Palatino Linotype", "Times New Roman", serif;
|
|
110
|
+
font-weight: 600;
|
|
111
|
+
font-size: clamp(32px, 5vw, 56px);
|
|
112
|
+
line-height: 1.05;
|
|
113
|
+
letter-spacing: -0.01em;
|
|
114
|
+
margin: 0;
|
|
115
|
+
}
|
|
116
|
+
.board-desc {
|
|
117
|
+
max-width: 60ch;
|
|
118
|
+
font-size: 18px;
|
|
119
|
+
color: #3a342d;
|
|
120
|
+
margin: 18px 0 0;
|
|
121
|
+
}
|
|
122
|
+
.meta {
|
|
123
|
+
margin: 22px 0 0;
|
|
124
|
+
font-size: 13px;
|
|
125
|
+
letter-spacing: 0.04em;
|
|
126
|
+
color: var(--muted);
|
|
127
|
+
}
|
|
128
|
+
.grid {
|
|
129
|
+
max-width: 1280px;
|
|
130
|
+
margin: 0 auto;
|
|
131
|
+
padding: 40px 32px 96px;
|
|
132
|
+
column-gap: 28px;
|
|
133
|
+
column-count: 1;
|
|
134
|
+
}
|
|
135
|
+
@media (min-width: 600px) { .grid { column-count: 2; } }
|
|
136
|
+
@media (min-width: 960px) { .grid { column-count: 3; } }
|
|
137
|
+
@media (min-width: 1280px) { .grid { column-count: 4; } }
|
|
138
|
+
.card {
|
|
139
|
+
break-inside: avoid;
|
|
140
|
+
margin: 0 0 36px;
|
|
141
|
+
}
|
|
142
|
+
.card a { text-decoration: none; color: inherit; display: block; }
|
|
143
|
+
figure { margin: 0; }
|
|
144
|
+
.card img {
|
|
145
|
+
display: block;
|
|
146
|
+
width: 100%;
|
|
147
|
+
height: auto;
|
|
148
|
+
background: #ece6da;
|
|
149
|
+
border: 1px solid var(--hair);
|
|
150
|
+
transition: filter 0.2s ease;
|
|
151
|
+
}
|
|
152
|
+
.card a:hover img { filter: brightness(1.04); }
|
|
153
|
+
.missing {
|
|
154
|
+
aspect-ratio: 4 / 3;
|
|
155
|
+
display: flex;
|
|
156
|
+
align-items: center;
|
|
157
|
+
justify-content: center;
|
|
158
|
+
background: #ece6da;
|
|
159
|
+
color: var(--muted);
|
|
160
|
+
font-size: 13px;
|
|
161
|
+
border: 1px solid var(--hair);
|
|
162
|
+
}
|
|
163
|
+
figcaption {
|
|
164
|
+
padding: 12px 2px 0;
|
|
165
|
+
display: flex;
|
|
166
|
+
flex-direction: column;
|
|
167
|
+
gap: 2px;
|
|
168
|
+
}
|
|
169
|
+
figcaption .title {
|
|
170
|
+
font-family: Georgia, "Iowan Old Style", "Palatino Linotype", "Times New Roman", serif;
|
|
171
|
+
font-style: italic;
|
|
172
|
+
font-size: 16px;
|
|
173
|
+
line-height: 1.25;
|
|
174
|
+
color: var(--ink);
|
|
175
|
+
}
|
|
176
|
+
figcaption .artist { font-size: 14px; color: #3a342d; }
|
|
177
|
+
figcaption .facts { font-size: 13px; color: var(--muted); }
|
|
178
|
+
figcaption .attr {
|
|
179
|
+
font-size: 11px;
|
|
180
|
+
letter-spacing: 0.05em;
|
|
181
|
+
text-transform: uppercase;
|
|
182
|
+
color: var(--muted);
|
|
183
|
+
margin-top: 4px;
|
|
184
|
+
}
|
|
185
|
+
.card a:hover figcaption .title { color: var(--accent); }
|
|
186
|
+
.empty {
|
|
187
|
+
max-width: 1280px;
|
|
188
|
+
margin: 0 auto;
|
|
189
|
+
padding: 80px 32px;
|
|
190
|
+
color: var(--muted);
|
|
191
|
+
}
|
|
192
|
+
.empty code {
|
|
193
|
+
background: #ece6da;
|
|
194
|
+
padding: 2px 6px;
|
|
195
|
+
border-radius: 4px;
|
|
196
|
+
font-size: 0.9em;
|
|
197
|
+
}
|
|
198
|
+
footer {
|
|
199
|
+
max-width: 1280px;
|
|
200
|
+
margin: 0 auto;
|
|
201
|
+
padding: 28px 32px 64px;
|
|
202
|
+
border-top: 1px solid var(--hair);
|
|
203
|
+
font-size: 12px;
|
|
204
|
+
letter-spacing: 0.04em;
|
|
205
|
+
color: var(--muted);
|
|
206
|
+
}
|
|
207
|
+
footer a { color: var(--accent); text-decoration: none; }
|
|
208
|
+
</style>
|
|
209
|
+
</head>
|
|
210
|
+
<body>
|
|
211
|
+
<header>
|
|
212
|
+
<p class="eyebrow">EveryMuseum · Board</p>
|
|
213
|
+
<h1>${escapeHtml(board.name)}</h1>
|
|
214
|
+
${description}
|
|
215
|
+
<p class="meta">${countLabel}${generated ? ` · assembled ${escapeHtml(generated)}` : ""}</p>
|
|
216
|
+
</header>
|
|
217
|
+
${body}
|
|
218
|
+
<footer>
|
|
219
|
+
Open-access works from museum collections worldwide, curated via
|
|
220
|
+
<a href="https://everymuseum.org" target="_blank" rel="noreferrer noopener">everymuseum.org</a>.
|
|
221
|
+
Images and rights belong to their source institutions.
|
|
222
|
+
</footer>
|
|
223
|
+
</body>
|
|
224
|
+
</html>
|
|
225
|
+
`;
|
|
226
|
+
}
|
package/dist/boards.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { sourceLabel } from "./api.js";
|
|
6
|
+
/** Root directory for board storage. Override with EVERYMUSEUM_DATA_DIR. */
|
|
7
|
+
export function dataDir() {
|
|
8
|
+
return process.env.EVERYMUSEUM_DATA_DIR || join(homedir(), ".everymuseum");
|
|
9
|
+
}
|
|
10
|
+
function boardsFile() {
|
|
11
|
+
return join(dataDir(), "boards.json");
|
|
12
|
+
}
|
|
13
|
+
export function boardsExportDir() {
|
|
14
|
+
return join(dataDir(), "boards");
|
|
15
|
+
}
|
|
16
|
+
export async function loadBoards() {
|
|
17
|
+
try {
|
|
18
|
+
const raw = await readFile(boardsFile(), "utf8");
|
|
19
|
+
const parsed = JSON.parse(raw);
|
|
20
|
+
if (Array.isArray(parsed))
|
|
21
|
+
return parsed;
|
|
22
|
+
if (parsed && typeof parsed === "object" && Array.isArray(parsed.boards)) {
|
|
23
|
+
return parsed.boards;
|
|
24
|
+
}
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Missing or unreadable file → start fresh.
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export async function saveBoards(boards) {
|
|
33
|
+
const file = boardsFile();
|
|
34
|
+
await mkdir(dirname(file), { recursive: true });
|
|
35
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
36
|
+
await writeFile(tmp, JSON.stringify(boards, null, 2), "utf8");
|
|
37
|
+
await rename(tmp, file); // atomic replace
|
|
38
|
+
}
|
|
39
|
+
// An MCP client can dispatch multiple tool calls concurrently, so a naive
|
|
40
|
+
// load → mutate → save sequence can lose updates (the last save wins). Funnel
|
|
41
|
+
// every read-modify-write through this in-process queue so they run serially.
|
|
42
|
+
let boardsLock = Promise.resolve();
|
|
43
|
+
export function withBoards(fn) {
|
|
44
|
+
const run = boardsLock.then(() => loadBoards().then(fn));
|
|
45
|
+
// Keep the chain alive whether the operation resolved or threw.
|
|
46
|
+
boardsLock = run.then(() => undefined, () => undefined);
|
|
47
|
+
return run;
|
|
48
|
+
}
|
|
49
|
+
export function slugify(value) {
|
|
50
|
+
return (value
|
|
51
|
+
.normalize("NFKD")
|
|
52
|
+
.replace(/[^\w\s-]/g, "")
|
|
53
|
+
.trim()
|
|
54
|
+
.replace(/\s+/g, "-")
|
|
55
|
+
.toLowerCase()
|
|
56
|
+
.slice(0, 60) || "board");
|
|
57
|
+
}
|
|
58
|
+
/** Resolve a board by id, slug, or case-insensitive name. */
|
|
59
|
+
export function findBoard(boards, identifier) {
|
|
60
|
+
const needle = identifier.trim().toLowerCase();
|
|
61
|
+
return (boards.find((board) => board.id.toLowerCase() === needle) ??
|
|
62
|
+
boards.find((board) => board.slug.toLowerCase() === needle) ??
|
|
63
|
+
boards.find((board) => board.name.toLowerCase() === needle) ??
|
|
64
|
+
null);
|
|
65
|
+
}
|
|
66
|
+
export function createBoard(boards, name, description) {
|
|
67
|
+
const base = slugify(name);
|
|
68
|
+
let slug = base;
|
|
69
|
+
let n = 2;
|
|
70
|
+
const taken = new Set(boards.map((board) => board.slug));
|
|
71
|
+
while (taken.has(slug))
|
|
72
|
+
slug = `${base}-${n++}`;
|
|
73
|
+
const now = new Date().toISOString();
|
|
74
|
+
const board = {
|
|
75
|
+
id: randomUUID().slice(0, 8),
|
|
76
|
+
slug,
|
|
77
|
+
name: name.trim(),
|
|
78
|
+
description: description?.trim() || null,
|
|
79
|
+
createdAt: now,
|
|
80
|
+
updatedAt: now,
|
|
81
|
+
items: [],
|
|
82
|
+
};
|
|
83
|
+
boards.push(board);
|
|
84
|
+
return board;
|
|
85
|
+
}
|
|
86
|
+
export function boardItemFromArtwork(artwork) {
|
|
87
|
+
return {
|
|
88
|
+
id: artwork.id,
|
|
89
|
+
title: artwork.title,
|
|
90
|
+
artist: artwork.artist,
|
|
91
|
+
dateDisplay: artwork.dateDisplay,
|
|
92
|
+
source: artwork.source,
|
|
93
|
+
sourceLabel: sourceLabel(artwork.source),
|
|
94
|
+
medium: artwork.medium,
|
|
95
|
+
culture: artwork.culture,
|
|
96
|
+
imageUrl: artwork.imageUrl,
|
|
97
|
+
thumbnailUrl: artwork.thumbnailUrl,
|
|
98
|
+
iiifUrl: artwork.iiifUrl,
|
|
99
|
+
sourceUrl: artwork.sourceUrl,
|
|
100
|
+
license: artwork.license,
|
|
101
|
+
addedAt: new Date().toISOString(),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/** Add artworks to a board, skipping ids already present. Returns count added. */
|
|
105
|
+
export function addArtworksToBoard(board, artworks) {
|
|
106
|
+
const present = new Set(board.items.map((item) => item.id));
|
|
107
|
+
let added = 0;
|
|
108
|
+
for (const artwork of artworks) {
|
|
109
|
+
if (present.has(artwork.id))
|
|
110
|
+
continue;
|
|
111
|
+
board.items.push(boardItemFromArtwork(artwork));
|
|
112
|
+
present.add(artwork.id);
|
|
113
|
+
added += 1;
|
|
114
|
+
}
|
|
115
|
+
if (added > 0)
|
|
116
|
+
board.updatedAt = new Date().toISOString();
|
|
117
|
+
return added;
|
|
118
|
+
}
|
|
119
|
+
/** Remove items by artwork id. Returns count removed. */
|
|
120
|
+
export function removeItemsFromBoard(board, ids) {
|
|
121
|
+
const remove = new Set(ids);
|
|
122
|
+
const before = board.items.length;
|
|
123
|
+
board.items = board.items.filter((item) => !remove.has(item.id));
|
|
124
|
+
const removed = before - board.items.length;
|
|
125
|
+
if (removed > 0)
|
|
126
|
+
board.updatedAt = new Date().toISOString();
|
|
127
|
+
return removed;
|
|
128
|
+
}
|
|
129
|
+
export function deleteBoard(boards, board) {
|
|
130
|
+
return boards.filter((entry) => entry.id !== board.id);
|
|
131
|
+
}
|