@mrfentmen/smk-mcp 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -2
- package/dist/api.d.ts +17 -0
- package/dist/api.js +159 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +12 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +45 -0
- package/package.json +41 -4
package/README.md
CHANGED
|
@@ -1,3 +1,33 @@
|
|
|
1
|
-
#
|
|
1
|
+
# SMK National Gallery of Denmark
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
SMK open collection: search 200k+ artworks by keyword or artist and read object details with images - keyless.
|
|
4
|
+
|
|
5
|
+
Keyless — no signup, no API key, no environment variables.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx -y @mrfentmen/smk-mcp
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or add it to your MCP client:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"mcpServers": {
|
|
18
|
+
"smk-mcp": { "command": "npx", "args": ["-y", "@mrfentmen/smk-mcp"] }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Tools
|
|
24
|
+
|
|
25
|
+
| Tool | What it does |
|
|
26
|
+
|---|---|
|
|
27
|
+
| `search_art` | Search the SMK collection by keyword and list matching artworks with dates and artists. |
|
|
28
|
+
| `search_by_artist` | Find artworks by an artist name, filtered from a keyword search. |
|
|
29
|
+
| `artwork_detail` | One artwork in full: title, artist(s), production date, techniques, dimensions and image links. |
|
|
30
|
+
|
|
31
|
+
## Data source
|
|
32
|
+
|
|
33
|
+
SMK (Statens Museum for Kunst) open API (api.smk.dk), no key required
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare class SourceError extends Error {
|
|
2
|
+
}
|
|
3
|
+
export declare function getJson(path: string, accept?: string, timeoutMs?: number): Promise<any>;
|
|
4
|
+
export declare function clampLimit(n: number | undefined, fallback: number, max: number): number;
|
|
5
|
+
export declare function clean(s: unknown): string;
|
|
6
|
+
export declare function bullet(label: string, value: unknown): string;
|
|
7
|
+
export declare function searchArt(a: {
|
|
8
|
+
query: string;
|
|
9
|
+
limit?: number;
|
|
10
|
+
}): Promise<string>;
|
|
11
|
+
export declare function searchByArtist(a: {
|
|
12
|
+
artist: string;
|
|
13
|
+
limit?: number;
|
|
14
|
+
}): Promise<string>;
|
|
15
|
+
export declare function artworkDetail(a: {
|
|
16
|
+
objectNumber: string;
|
|
17
|
+
}): Promise<string>;
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SMK National Gallery of Denmark — keyless public API client.
|
|
3
|
+
*/
|
|
4
|
+
const BASE = "https://api.smk.dk/api/v1";
|
|
5
|
+
const SOURCE = "SMK National Gallery of Denmark";
|
|
6
|
+
export class SourceError extends Error {
|
|
7
|
+
}
|
|
8
|
+
async function request(path, accept = "application/json", timeoutMs = 25000) {
|
|
9
|
+
const ctrl = new AbortController();
|
|
10
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
11
|
+
try {
|
|
12
|
+
const res = await fetch(path.startsWith("http") ? path : BASE + path, {
|
|
13
|
+
signal: ctrl.signal,
|
|
14
|
+
headers: { "user-agent": "smk-mcp", accept },
|
|
15
|
+
});
|
|
16
|
+
if (!res.ok)
|
|
17
|
+
throw new SourceError(`${SOURCE} responded ${res.status}`);
|
|
18
|
+
return res;
|
|
19
|
+
}
|
|
20
|
+
catch (e) {
|
|
21
|
+
if (e instanceof SourceError)
|
|
22
|
+
throw e;
|
|
23
|
+
if (e.name === "AbortError")
|
|
24
|
+
throw new SourceError(`${SOURCE} request timed out`);
|
|
25
|
+
throw new SourceError(`${SOURCE} request failed: ${e.message}`);
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
clearTimeout(timer);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export async function getJson(path, accept = "application/json", timeoutMs = 25000) {
|
|
32
|
+
const res = await request(path, accept, timeoutMs);
|
|
33
|
+
const body = await res.text();
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(body);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
throw new SourceError(`${SOURCE} returned malformed JSON`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function clampLimit(n, fallback, max) {
|
|
42
|
+
const v = n ?? fallback;
|
|
43
|
+
if (!Number.isInteger(v) || v < 1 || v > max)
|
|
44
|
+
throw new SourceError(`limit must be between 1 and ${max}`);
|
|
45
|
+
return v;
|
|
46
|
+
}
|
|
47
|
+
export function clean(s) {
|
|
48
|
+
return String(s ?? "").replace(/\s+/g, " ").trim();
|
|
49
|
+
}
|
|
50
|
+
export function bullet(label, value) {
|
|
51
|
+
const v = value === undefined || value === null || value === "" ? "" : String(value);
|
|
52
|
+
return v ? `${label}: ${v}` : "";
|
|
53
|
+
}
|
|
54
|
+
function title(item) {
|
|
55
|
+
if (Array.isArray(item?.titles) && item.titles.length)
|
|
56
|
+
return clean(item.titles[0]?.title);
|
|
57
|
+
return clean(item?.title);
|
|
58
|
+
}
|
|
59
|
+
function artists(item) {
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const key of ["artist", "artists"]) {
|
|
62
|
+
const value = item?.[key];
|
|
63
|
+
if (Array.isArray(value)) {
|
|
64
|
+
for (const a of value)
|
|
65
|
+
out.push(clean(a?.name ?? a?.full_name ?? a));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return [...new Set(out.filter(Boolean))];
|
|
69
|
+
}
|
|
70
|
+
function dates(item) {
|
|
71
|
+
const value = item?.production_date;
|
|
72
|
+
if (Array.isArray(value) && value.length) {
|
|
73
|
+
const first = value[0];
|
|
74
|
+
if (Array.isArray(first?.period))
|
|
75
|
+
return first.period.map((p) => clean(p)).filter(Boolean).join("-");
|
|
76
|
+
return clean(first?.period ?? first);
|
|
77
|
+
}
|
|
78
|
+
return clean(item?.production_date);
|
|
79
|
+
}
|
|
80
|
+
function thumbs(item) {
|
|
81
|
+
return clean(item?.image_thumbnail ?? item?.image_native ?? "");
|
|
82
|
+
}
|
|
83
|
+
async function search(keys, limit) {
|
|
84
|
+
const data = await getJson(`/art/search/?keys=${encodeURIComponent(keys)}&offset=0&limit=${Math.max(limit, 10)}&filters=%5Bhas_image%3Atrue%5D`, "application/json", 30000);
|
|
85
|
+
const items = data?.items;
|
|
86
|
+
if (!Array.isArray(items))
|
|
87
|
+
throw new SourceError("SMK returned an unexpected payload");
|
|
88
|
+
return items;
|
|
89
|
+
}
|
|
90
|
+
export async function searchArt(a) {
|
|
91
|
+
const limit = clampLimit(a.limit, 10, 50);
|
|
92
|
+
const q = clean(a.query);
|
|
93
|
+
const items = await search(q, limit);
|
|
94
|
+
const withImages = items.filter((i) => thumbs(i));
|
|
95
|
+
const shown = (withImages.length ? withImages : items).slice(0, limit);
|
|
96
|
+
if (!shown.length)
|
|
97
|
+
return `SMK has no artworks matching "${q}".`;
|
|
98
|
+
return [
|
|
99
|
+
`${items.length} result(s) for "${q}" (showing ${shown.length}${withImages.length ? ", images only" : ""}):`,
|
|
100
|
+
"",
|
|
101
|
+
...shown.map((item, i) => {
|
|
102
|
+
const who = artists(item);
|
|
103
|
+
const when = dates(item);
|
|
104
|
+
const bits = [when, who.slice(0, 2).join(", ")].filter(Boolean);
|
|
105
|
+
return `${i + 1}. ${title(item) || "(untitled)"} · ${clean(item?.object_number)}${bits.length ? `\n ${bits.join(" · ")}` : ""}`;
|
|
106
|
+
}),
|
|
107
|
+
].join("\n");
|
|
108
|
+
}
|
|
109
|
+
export async function searchByArtist(a) {
|
|
110
|
+
const limit = clampLimit(a.limit, 10, 50);
|
|
111
|
+
const name = clean(a.artist);
|
|
112
|
+
const items = await search(name, Math.max(limit, 30));
|
|
113
|
+
const needle = name.toLowerCase();
|
|
114
|
+
const hits = items.filter((item) => artists(item).some((aName) => aName.toLowerCase().includes(needle)));
|
|
115
|
+
if (!hits.length)
|
|
116
|
+
return `No SMK artworks found with an artist matching "${name}".`;
|
|
117
|
+
return [
|
|
118
|
+
`${hits.length} artwork(s) with an artist matching "${name}" (showing ${Math.min(limit, hits.length)}):`,
|
|
119
|
+
"",
|
|
120
|
+
...hits.slice(0, limit).map((item, i) => {
|
|
121
|
+
const who = artists(item);
|
|
122
|
+
const when = dates(item);
|
|
123
|
+
return `${i + 1}. ${title(item) || "(untitled)"} · ${clean(item?.object_number)}${who.length ? `\n ${who.join(", ")}${when ? ` · ${when}` : ""}` : ""}`;
|
|
124
|
+
}),
|
|
125
|
+
].join("\n");
|
|
126
|
+
}
|
|
127
|
+
export async function artworkDetail(a) {
|
|
128
|
+
const id = clean(a.objectNumber);
|
|
129
|
+
if (!/^[A-Za-z0-9._-]{2,40}$/.test(id))
|
|
130
|
+
throw new SourceError(`"${id}" is not a valid SMK object number`);
|
|
131
|
+
const data = await getJson(`/art/?object_number=${encodeURIComponent(id)}`, "application/json", 30000);
|
|
132
|
+
const item = Array.isArray(data?.items) ? data.items[0] : undefined;
|
|
133
|
+
if (!item)
|
|
134
|
+
throw new SourceError(`SMK has no object "${id}"`);
|
|
135
|
+
const techniques = Array.isArray(item?.techniques) ? item.techniques.map((t) => clean(t)).filter(Boolean) : [];
|
|
136
|
+
const dims = [];
|
|
137
|
+
for (const d of Array.isArray(item?.dimensions) ? item.dimensions : []) {
|
|
138
|
+
const parts = [clean(d?.type), d?.value !== undefined ? `${clean(d.value)} ${clean(d?.unit)}`.trim() : ""].filter(Boolean);
|
|
139
|
+
if (parts.length)
|
|
140
|
+
dims.push(parts.join(" "));
|
|
141
|
+
}
|
|
142
|
+
const description = clean(item?.content_description ?? item?.description);
|
|
143
|
+
const image = thumbs(item);
|
|
144
|
+
const native = clean(item?.image_native);
|
|
145
|
+
return [
|
|
146
|
+
`${title(item) || "(untitled)"} (${clean(item?.object_number)})`,
|
|
147
|
+
"",
|
|
148
|
+
bullet("artist(s)", artists(item).join(", ")),
|
|
149
|
+
bullet("production", dates(item)),
|
|
150
|
+
bullet("techniques", techniques.join(", ")),
|
|
151
|
+
bullet("dimensions", dims.slice(0, 4).join("; ")),
|
|
152
|
+
bullet("department", item?.responsible_department),
|
|
153
|
+
bullet("acquisition", item?.acquisition_date ? clean(item.acquisition_date).slice(0, 10) : ""),
|
|
154
|
+
"",
|
|
155
|
+
description ? description.slice(0, 600) : "No catalogue description on record.",
|
|
156
|
+
image ? `\nThumbnail: ${image}` : "",
|
|
157
|
+
native ? `Image: ${native}` : "",
|
|
158
|
+
].filter(Boolean).join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
159
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
2
|
+
import { createServer } from "./server.js";
|
|
3
|
+
async function main() {
|
|
4
|
+
const server = createServer();
|
|
5
|
+
const transport = new StdioServerTransport();
|
|
6
|
+
await server.connect(transport);
|
|
7
|
+
console.error("smk-mcp running on stdio");
|
|
8
|
+
}
|
|
9
|
+
main().catch((err) => {
|
|
10
|
+
console.error("Fatal error:", err);
|
|
11
|
+
process.exit(1);
|
|
12
|
+
});
|
package/dist/server.d.ts
ADDED
package/dist/server.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { artworkDetail, searchArt, searchByArtist, SourceError } from "./api.js";
|
|
4
|
+
const text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
5
|
+
function errorMessage(e) {
|
|
6
|
+
if (e instanceof SourceError || e instanceof Error)
|
|
7
|
+
return `Error: ${e.message}`;
|
|
8
|
+
return `Error: ${String(e)}`;
|
|
9
|
+
}
|
|
10
|
+
export function createServer() {
|
|
11
|
+
const server = new McpServer({ name: "smk-mcp", version: "1.0.0" });
|
|
12
|
+
server.tool("search_art", "Search the SMK collection by keyword and list matching artworks with dates and artists.", {
|
|
13
|
+
query: z.string().min(1).describe("Search words, e.g. portrait or Christmas"),
|
|
14
|
+
limit: z.number().int().min(1).max(50).default(10).describe("Artworks to return")
|
|
15
|
+
}, async (a) => {
|
|
16
|
+
try {
|
|
17
|
+
return text(await searchArt(a));
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
return { content: [{ type: "text", text: errorMessage(e) }], isError: true };
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
server.tool("search_by_artist", "Find artworks by an artist name, filtered from a keyword search.", {
|
|
24
|
+
artist: z.string().min(2).describe("Artist name, e.g. Hammershøi"),
|
|
25
|
+
limit: z.number().int().min(1).max(50).default(10).describe("Artworks to return")
|
|
26
|
+
}, async (a) => {
|
|
27
|
+
try {
|
|
28
|
+
return text(await searchByArtist(a));
|
|
29
|
+
}
|
|
30
|
+
catch (e) {
|
|
31
|
+
return { content: [{ type: "text", text: errorMessage(e) }], isError: true };
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
server.tool("artwork_detail", "One artwork in full: title, artist(s), production date, techniques, dimensions and image links.", {
|
|
35
|
+
objectNumber: z.string().min(2).describe("SMK object number, e.g. KKSgb2964")
|
|
36
|
+
}, async (a) => {
|
|
37
|
+
try {
|
|
38
|
+
return text(await artworkDetail(a));
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
return { content: [{ type: "text", text: errorMessage(e) }], isError: true };
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
return server;
|
|
45
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrfentmen/smk-mcp",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "SMK open collection: search 200k+ artworks by keyword or artist and read object details with images - keyless.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"mcpName": "io.github.mrfentmen/smk-mcp",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/mrfentmen/awesome-servers.git"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"smk-mcp": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -p tsconfig.json",
|
|
20
|
+
"start": "node dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"mcp",
|
|
24
|
+
"art",
|
|
25
|
+
"museum",
|
|
26
|
+
"denmark",
|
|
27
|
+
"collection",
|
|
28
|
+
"culture",
|
|
29
|
+
"keyless"
|
|
30
|
+
],
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
34
|
+
"zod": "^3.23.8"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "^22.0.0",
|
|
38
|
+
"typescript": "^5.6.0"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=20"
|
|
42
|
+
}
|
|
43
|
+
}
|