artsonia-mcp 0.2.0 → 0.4.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/bundle.js +340 -29
- package/dist/{src/index.js → index.js} +6 -0
- package/dist/{src/parse.js → parse.js} +63 -2
- package/dist/tools/account.js +24 -0
- package/dist/tools/download.js +177 -0
- package/dist/tools/feedback.js +41 -0
- package/dist/{src/transport-fetchproxy.js → transport-fetchproxy.js} +16 -7
- package/dist/{src/version.js → version.js} +1 -1
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- /package/dist/{src/auth.js → auth.js} +0 -0
- /package/dist/{src/client.js → client.js} +0 -0
- /package/dist/{src/cookies.js → cookies.js} +0 -0
- /package/dist/{src/tools → tools}/fans.js +0 -0
- /package/dist/{src/tools → tools}/healthcheck.js +0 -0
- /package/dist/{src/tools → tools}/portfolio.js +0 -0
- /package/dist/{src/tools → tools}/students.js +0 -0
- /package/dist/{src/tools → tools}/writes.js +0 -0
- /package/dist/{src/transport-fetch.js → transport-fetch.js} +0 -0
- /package/dist/{src/transport.js → transport.js} +0 -0
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "MCP server for Artsonia — natural-language access to student-art portfolios, comments, and fans",
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.4.0"
|
|
11
11
|
},
|
|
12
12
|
"plugins": [
|
|
13
13
|
{
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"displayName": "Artsonia",
|
|
16
16
|
"source": "./",
|
|
17
17
|
"description": "MCP server for Artsonia — access student portfolios, post comments, and manage fans via natural language",
|
|
18
|
-
"version": "0.
|
|
18
|
+
"version": "0.4.0",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
package/dist/bundle.js
CHANGED
|
@@ -23004,7 +23004,7 @@ var init_protocol = __esm({
|
|
|
23004
23004
|
return;
|
|
23005
23005
|
}
|
|
23006
23006
|
const pollInterval = task2.pollInterval ?? this._options?.defaultTaskPollInterval ?? 1e3;
|
|
23007
|
-
await new Promise((
|
|
23007
|
+
await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
|
|
23008
23008
|
options?.signal?.throwIfAborted();
|
|
23009
23009
|
}
|
|
23010
23010
|
} catch (error51) {
|
|
@@ -23021,7 +23021,7 @@ var init_protocol = __esm({
|
|
|
23021
23021
|
*/
|
|
23022
23022
|
request(request, resultSchema, options) {
|
|
23023
23023
|
const { relatedRequestId, resumptionToken, onresumptiontoken, task, relatedTask } = options ?? {};
|
|
23024
|
-
return new Promise((
|
|
23024
|
+
return new Promise((resolve2, reject) => {
|
|
23025
23025
|
const earlyReject = (error51) => {
|
|
23026
23026
|
reject(error51);
|
|
23027
23027
|
};
|
|
@@ -23099,7 +23099,7 @@ var init_protocol = __esm({
|
|
|
23099
23099
|
if (!parseResult.success) {
|
|
23100
23100
|
reject(parseResult.error);
|
|
23101
23101
|
} else {
|
|
23102
|
-
|
|
23102
|
+
resolve2(parseResult.data);
|
|
23103
23103
|
}
|
|
23104
23104
|
} catch (error51) {
|
|
23105
23105
|
reject(error51);
|
|
@@ -23360,12 +23360,12 @@ var init_protocol = __esm({
|
|
|
23360
23360
|
}
|
|
23361
23361
|
} catch {
|
|
23362
23362
|
}
|
|
23363
|
-
return new Promise((
|
|
23363
|
+
return new Promise((resolve2, reject) => {
|
|
23364
23364
|
if (signal.aborted) {
|
|
23365
23365
|
reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
|
|
23366
23366
|
return;
|
|
23367
23367
|
}
|
|
23368
|
-
const timeoutId = setTimeout(
|
|
23368
|
+
const timeoutId = setTimeout(resolve2, interval);
|
|
23369
23369
|
signal.addEventListener("abort", () => {
|
|
23370
23370
|
clearTimeout(timeoutId);
|
|
23371
23371
|
reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
|
|
@@ -26392,7 +26392,7 @@ var require_compile = __commonJS({
|
|
|
26392
26392
|
const schOrFunc = root.refs[ref];
|
|
26393
26393
|
if (schOrFunc)
|
|
26394
26394
|
return schOrFunc;
|
|
26395
|
-
let _sch =
|
|
26395
|
+
let _sch = resolve2.call(this, root, ref);
|
|
26396
26396
|
if (_sch === void 0) {
|
|
26397
26397
|
const schema = (_a3 = root.localRefs) === null || _a3 === void 0 ? void 0 : _a3[ref];
|
|
26398
26398
|
const { schemaId } = this.opts;
|
|
@@ -26419,7 +26419,7 @@ var require_compile = __commonJS({
|
|
|
26419
26419
|
function sameSchemaEnv(s1, s2) {
|
|
26420
26420
|
return s1.schema === s2.schema && s1.root === s2.root && s1.baseId === s2.baseId;
|
|
26421
26421
|
}
|
|
26422
|
-
function
|
|
26422
|
+
function resolve2(root, ref) {
|
|
26423
26423
|
let sch;
|
|
26424
26424
|
while (typeof (sch = this.refs[ref]) == "string")
|
|
26425
26425
|
ref = sch;
|
|
@@ -27050,7 +27050,7 @@ var require_fast_uri = __commonJS({
|
|
|
27050
27050
|
}
|
|
27051
27051
|
return uri;
|
|
27052
27052
|
}
|
|
27053
|
-
function
|
|
27053
|
+
function resolve2(baseURI, relativeURI, options) {
|
|
27054
27054
|
const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" };
|
|
27055
27055
|
const resolved = resolveComponent(parse5(baseURI, schemelessOptions), parse5(relativeURI, schemelessOptions), schemelessOptions, true);
|
|
27056
27056
|
schemelessOptions.skipEscape = true;
|
|
@@ -27308,7 +27308,7 @@ var require_fast_uri = __commonJS({
|
|
|
27308
27308
|
var fastUri = {
|
|
27309
27309
|
SCHEMES,
|
|
27310
27310
|
normalize,
|
|
27311
|
-
resolve,
|
|
27311
|
+
resolve: resolve2,
|
|
27312
27312
|
resolveComponent,
|
|
27313
27313
|
equal,
|
|
27314
27314
|
serialize,
|
|
@@ -31435,7 +31435,7 @@ var init_mcp = __esm({
|
|
|
31435
31435
|
let task = createTaskResult.task;
|
|
31436
31436
|
const pollInterval = task.pollInterval ?? 5e3;
|
|
31437
31437
|
while (task.status !== "completed" && task.status !== "failed" && task.status !== "cancelled") {
|
|
31438
|
-
await new Promise((
|
|
31438
|
+
await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
|
|
31439
31439
|
const updatedTask = await extra.taskStore.getTask(taskId);
|
|
31440
31440
|
if (!updatedTask) {
|
|
31441
31441
|
throw new McpError(ErrorCode.InternalError, `Task ${taskId} not found during polling`);
|
|
@@ -32029,12 +32029,12 @@ var init_stdio2 = __esm({
|
|
|
32029
32029
|
this.onclose?.();
|
|
32030
32030
|
}
|
|
32031
32031
|
send(message) {
|
|
32032
|
-
return new Promise((
|
|
32032
|
+
return new Promise((resolve2) => {
|
|
32033
32033
|
const json2 = serializeMessage(message);
|
|
32034
32034
|
if (this._stdout.write(json2)) {
|
|
32035
|
-
|
|
32035
|
+
resolve2();
|
|
32036
32036
|
} else {
|
|
32037
|
-
this._stdout.once("drain",
|
|
32037
|
+
this._stdout.once("drain", resolve2);
|
|
32038
32038
|
}
|
|
32039
32039
|
});
|
|
32040
32040
|
}
|
|
@@ -32130,6 +32130,8 @@ var init_errors4 = __esm({
|
|
|
32130
32130
|
});
|
|
32131
32131
|
|
|
32132
32132
|
// node_modules/@chrischall/mcp-utils/dist/config/index.js
|
|
32133
|
+
import { homedir } from "node:os";
|
|
32134
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
32133
32135
|
function readEnvVar(key, opts = {}) {
|
|
32134
32136
|
const env = opts.env ?? process.env;
|
|
32135
32137
|
const raw = env[key];
|
|
@@ -32141,6 +32143,15 @@ function readEnvVar(key, opts = {}) {
|
|
|
32141
32143
|
}
|
|
32142
32144
|
return opts.default;
|
|
32143
32145
|
}
|
|
32146
|
+
function expandPath(p) {
|
|
32147
|
+
let expanded = p;
|
|
32148
|
+
if (p === "~") {
|
|
32149
|
+
expanded = homedir();
|
|
32150
|
+
} else if (p.startsWith("~/")) {
|
|
32151
|
+
expanded = join(homedir(), p.slice(2));
|
|
32152
|
+
}
|
|
32153
|
+
return isAbsolute(expanded) ? expanded : resolve(expanded);
|
|
32154
|
+
}
|
|
32144
32155
|
async function loadDotenvSafely(opts = {}) {
|
|
32145
32156
|
try {
|
|
32146
32157
|
const mod = await import(
|
|
@@ -32236,7 +32247,7 @@ var VERSION;
|
|
|
32236
32247
|
var init_version = __esm({
|
|
32237
32248
|
"src/version.ts"() {
|
|
32238
32249
|
"use strict";
|
|
32239
|
-
VERSION = "0.
|
|
32250
|
+
VERSION = "0.4.0";
|
|
32240
32251
|
}
|
|
32241
32252
|
});
|
|
32242
32253
|
|
|
@@ -32245,7 +32256,6 @@ var transport_fetchproxy_exports = {};
|
|
|
32245
32256
|
__export(transport_fetchproxy_exports, {
|
|
32246
32257
|
FetchproxyArtsoniaTransport: () => FetchproxyArtsoniaTransport
|
|
32247
32258
|
});
|
|
32248
|
-
import { FetchproxyServer } from "@fetchproxy/server";
|
|
32249
32259
|
var DEFAULT_PORT, FetchproxyArtsoniaTransport;
|
|
32250
32260
|
var init_transport_fetchproxy = __esm({
|
|
32251
32261
|
"src/transport-fetchproxy.ts"() {
|
|
@@ -32255,23 +32265,31 @@ var init_transport_fetchproxy = __esm({
|
|
|
32255
32265
|
init_version();
|
|
32256
32266
|
DEFAULT_PORT = 37149;
|
|
32257
32267
|
FetchproxyArtsoniaTransport = class {
|
|
32258
|
-
inner;
|
|
32268
|
+
inner = null;
|
|
32259
32269
|
started = false;
|
|
32260
|
-
|
|
32270
|
+
starting = null;
|
|
32271
|
+
buildOpts() {
|
|
32261
32272
|
const portEnv = readEnvVar("ARTSONIA_WS_PORT");
|
|
32262
|
-
|
|
32273
|
+
return {
|
|
32263
32274
|
port: portEnv ? Number(portEnv) : DEFAULT_PORT,
|
|
32264
32275
|
serverName: "artsonia-mcp",
|
|
32265
32276
|
version: VERSION,
|
|
32266
32277
|
domains: ["artsonia.com"],
|
|
32267
32278
|
capabilities: ["fetch"]
|
|
32268
32279
|
};
|
|
32269
|
-
this.inner = new FetchproxyServer(opts);
|
|
32270
32280
|
}
|
|
32271
32281
|
async ensureStarted() {
|
|
32272
32282
|
if (this.started) return;
|
|
32273
|
-
|
|
32274
|
-
this.
|
|
32283
|
+
if (this.starting) return this.starting;
|
|
32284
|
+
this.starting = (async () => {
|
|
32285
|
+
const { FetchproxyServer } = await import("@fetchproxy/server");
|
|
32286
|
+
this.inner = new FetchproxyServer(this.buildOpts());
|
|
32287
|
+
await this.inner.listen();
|
|
32288
|
+
this.started = true;
|
|
32289
|
+
})().finally(() => {
|
|
32290
|
+
this.starting = null;
|
|
32291
|
+
});
|
|
32292
|
+
return this.starting;
|
|
32275
32293
|
}
|
|
32276
32294
|
async request(req) {
|
|
32277
32295
|
await this.ensureStarted();
|
|
@@ -38295,7 +38313,7 @@ init_version();
|
|
|
38295
38313
|
|
|
38296
38314
|
// src/client.ts
|
|
38297
38315
|
init_dist();
|
|
38298
|
-
import { dirname, join } from "node:path";
|
|
38316
|
+
import { dirname, join as join2 } from "node:path";
|
|
38299
38317
|
import { fileURLToPath } from "node:url";
|
|
38300
38318
|
|
|
38301
38319
|
// src/auth.ts
|
|
@@ -38457,7 +38475,7 @@ var ArtsoniaClient = class {
|
|
|
38457
38475
|
}
|
|
38458
38476
|
};
|
|
38459
38477
|
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
38460
|
-
await loadDotenvSafely({ path:
|
|
38478
|
+
await loadDotenvSafely({ path: join2(__dirname, "..", ".env"), override: false });
|
|
38461
38479
|
var transport = await makeTransport();
|
|
38462
38480
|
var client = new ArtsoniaClient({
|
|
38463
38481
|
transport,
|
|
@@ -38560,6 +38578,7 @@ function parseArtwork(html) {
|
|
|
38560
38578
|
}
|
|
38561
38579
|
const bodyText = root.querySelector("body")?.text ?? "";
|
|
38562
38580
|
const project = bodyText.match(/from school project "([^"]+)"/)?.[1] ?? "";
|
|
38581
|
+
const grade = bodyText.match(/in Grade\s+(\w+)/i)?.[1] ?? null;
|
|
38563
38582
|
const link = root.querySelector('a[href*="comments/enter.asp"]');
|
|
38564
38583
|
const href = link?.getAttribute("href");
|
|
38565
38584
|
const aId = attrId(href, "artist");
|
|
@@ -38569,7 +38588,10 @@ function parseArtwork(html) {
|
|
|
38569
38588
|
author: text(c.querySelector(".comment-author")),
|
|
38570
38589
|
text: text(c.querySelector(".comment-text"))
|
|
38571
38590
|
}));
|
|
38572
|
-
return { title, artist_screen_name, views, project, comment_entry, comments };
|
|
38591
|
+
return { title, artist_screen_name, views, grade, project, comment_entry, comments };
|
|
38592
|
+
}
|
|
38593
|
+
function artworkImageUrl(artworkId, resolution = "full") {
|
|
38594
|
+
return `https://images.artsonia.com/art/${resolution}/${artworkId}.jpg`;
|
|
38573
38595
|
}
|
|
38574
38596
|
function parseFans(html) {
|
|
38575
38597
|
const root = (0, import_node_html_parser.parse)(html);
|
|
@@ -38588,6 +38610,57 @@ function parseFans(html) {
|
|
|
38588
38610
|
return { name, relationship };
|
|
38589
38611
|
}).filter((f) => f !== null);
|
|
38590
38612
|
}
|
|
38613
|
+
function parseFeedback(html) {
|
|
38614
|
+
const root = (0, import_node_html_parser.parse)(html);
|
|
38615
|
+
return root.querySelectorAll(".comment-row").map((row) => {
|
|
38616
|
+
const link = row.querySelector('.comment-art a[href*="art.asp"]');
|
|
38617
|
+
const artwork_id = attrId(link?.getAttribute("href"), "id");
|
|
38618
|
+
const options = text(row.querySelector(".comment-options"));
|
|
38619
|
+
return {
|
|
38620
|
+
artwork_id,
|
|
38621
|
+
message: text(row.querySelector(".comment")),
|
|
38622
|
+
posted_by: text(row.querySelector(".commenter")),
|
|
38623
|
+
is_read: !/not been marked as read/i.test(options),
|
|
38624
|
+
thumbnail: artwork_id ? `https://images.artsonia.com/art/small/${artwork_id}.jpg` : null
|
|
38625
|
+
};
|
|
38626
|
+
});
|
|
38627
|
+
}
|
|
38628
|
+
function parseAwards(html) {
|
|
38629
|
+
const root = (0, import_node_html_parser.parse)(html);
|
|
38630
|
+
const awards = [];
|
|
38631
|
+
for (const card of root.querySelectorAll(".award-card")) {
|
|
38632
|
+
const content = card.querySelector("div");
|
|
38633
|
+
const lines = content ? content.querySelectorAll("div").map((d) => text(d)).filter(Boolean) : [];
|
|
38634
|
+
const name = lines[0] ?? "";
|
|
38635
|
+
if (!name) continue;
|
|
38636
|
+
awards.push({
|
|
38637
|
+
name,
|
|
38638
|
+
earned: /^earned$/i.test((lines[1] ?? "").trim()),
|
|
38639
|
+
description: lines[2] ?? "",
|
|
38640
|
+
progress: lines[3] ?? "",
|
|
38641
|
+
period: "current"
|
|
38642
|
+
});
|
|
38643
|
+
}
|
|
38644
|
+
for (const card of root.querySelectorAll(".award-card-past")) {
|
|
38645
|
+
const file2 = card.querySelector("img")?.getAttribute("src")?.split("/").pop() ?? "";
|
|
38646
|
+
const slug = file2.replace(/\.gif$/i, "").replace(/_ghosted$/i, "").replace(/^artist_/i, "");
|
|
38647
|
+
const name = slug ? slug.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()) : "Award";
|
|
38648
|
+
awards.push({ name, earned: true, description: "", progress: "", period: "past" });
|
|
38649
|
+
}
|
|
38650
|
+
return awards;
|
|
38651
|
+
}
|
|
38652
|
+
function parseProfile(html) {
|
|
38653
|
+
const form = (0, import_node_html_parser.parse)(html).querySelector("#TheForm");
|
|
38654
|
+
const val = (name) => form?.querySelector(`input[name="${name}"], select[name="${name}"]`)?.getAttribute("value") ?? "";
|
|
38655
|
+
const checked = (name) => form?.querySelector(`input[name="${name}"]`)?.hasAttribute("checked") ?? false;
|
|
38656
|
+
return {
|
|
38657
|
+
first_name: val("FirstName"),
|
|
38658
|
+
last_name: val("LastName"),
|
|
38659
|
+
email: val("EmailAddress"),
|
|
38660
|
+
mobile: val("MobileNumber"),
|
|
38661
|
+
opt_ins: { news: checked("OptInNews"), artist_activity: checked("OptInArtistActivity"), promos: checked("OptInPromos") }
|
|
38662
|
+
};
|
|
38663
|
+
}
|
|
38591
38664
|
|
|
38592
38665
|
// src/tools/healthcheck.ts
|
|
38593
38666
|
function registerHealthcheckTools(server, client2) {
|
|
@@ -38704,11 +38777,246 @@ function registerFanTools(server, client2) {
|
|
|
38704
38777
|
);
|
|
38705
38778
|
}
|
|
38706
38779
|
|
|
38780
|
+
// src/tools/feedback.ts
|
|
38781
|
+
init_zod();
|
|
38782
|
+
init_dist();
|
|
38783
|
+
var NumericId2 = external_exports.string().regex(/^\d+$/, "must be a numeric id");
|
|
38784
|
+
function registerFeedbackTools(server, client2) {
|
|
38785
|
+
server.registerTool(
|
|
38786
|
+
"artsonia_get_feedback",
|
|
38787
|
+
{
|
|
38788
|
+
title: "Get teacher feedback for a student",
|
|
38789
|
+
description: "List the teacher feedback left on a student's artwork \u2014 each item's message, who posted it and when, the artwork it's about, and whether it's been marked as read. Pass the artist_id from artsonia_list_students.",
|
|
38790
|
+
annotations: toolAnnotations({ title: "Get teacher feedback for a student", readOnly: true, openWorld: true }),
|
|
38791
|
+
inputSchema: { artist_id: NumericId2.describe("Student artist_id (from artsonia_list_students).") }
|
|
38792
|
+
},
|
|
38793
|
+
async ({ artist_id }) => {
|
|
38794
|
+
const feedback = parseFeedback(await client2.fetchHtml(`/members/feedback/?artist=${artist_id}`));
|
|
38795
|
+
return textResult({
|
|
38796
|
+
artist_id,
|
|
38797
|
+
unread_count: feedback.filter((f) => !f.is_read).length,
|
|
38798
|
+
feedback
|
|
38799
|
+
});
|
|
38800
|
+
}
|
|
38801
|
+
);
|
|
38802
|
+
server.registerTool(
|
|
38803
|
+
"artsonia_mark_feedback_read",
|
|
38804
|
+
{
|
|
38805
|
+
title: "Mark a student's feedback as read",
|
|
38806
|
+
description: "Mark the student's teacher feedback as read (this is a mark-ALL action \u2014 Artsonia has no per-item control). Without confirm:true this is a DRY RUN that returns a preview and makes no network call.",
|
|
38807
|
+
annotations: toolAnnotations({ title: "Mark a student's feedback as read", readOnly: false, openWorld: true }),
|
|
38808
|
+
inputSchema: {
|
|
38809
|
+
artist_id: NumericId2.describe("Student artist_id (from artsonia_list_students)."),
|
|
38810
|
+
confirm: schemaConfirm
|
|
38811
|
+
}
|
|
38812
|
+
},
|
|
38813
|
+
async ({ artist_id, confirm }) => {
|
|
38814
|
+
const path = `/members/feedback/default.asp?artist=${artist_id}`;
|
|
38815
|
+
const body = new URLSearchParams({ ConfirmAsRead: "Mark as Read" }).toString();
|
|
38816
|
+
if (confirm !== true) {
|
|
38817
|
+
return textResult({
|
|
38818
|
+
preview: true,
|
|
38819
|
+
action: "mark_feedback_read",
|
|
38820
|
+
note: "DRY RUN \u2014 nothing was sent. Re-run with confirm: true to mark ALL of this student's feedback as read.",
|
|
38821
|
+
wouldSend: { path, ConfirmAsRead: "Mark as Read" }
|
|
38822
|
+
});
|
|
38823
|
+
}
|
|
38824
|
+
const res = await client2.write(path, body);
|
|
38825
|
+
return textResult({ marked_read: true, artist_id, status: res.status });
|
|
38826
|
+
}
|
|
38827
|
+
);
|
|
38828
|
+
}
|
|
38829
|
+
|
|
38830
|
+
// src/tools/account.ts
|
|
38831
|
+
init_zod();
|
|
38832
|
+
init_dist();
|
|
38833
|
+
function registerAccountTools(server, client2) {
|
|
38834
|
+
server.registerTool(
|
|
38835
|
+
"artsonia_get_awards",
|
|
38836
|
+
{
|
|
38837
|
+
title: "Get a student's awards & activities",
|
|
38838
|
+
description: "List a student's Artsonia awards/achievement badges \u2014 current-year badges (name, earned/not, criteria, progress) plus badges earned in prior years. Pass the artist_id from artsonia_list_students.",
|
|
38839
|
+
annotations: toolAnnotations({ title: "Get a student's awards & activities", readOnly: true, openWorld: true }),
|
|
38840
|
+
inputSchema: { artist_id: external_exports.string().regex(/^\d+$/, "must be a numeric id").describe("Student artist_id (from artsonia_list_students).") }
|
|
38841
|
+
},
|
|
38842
|
+
async ({ artist_id }) => {
|
|
38843
|
+
const awards = parseAwards(await client2.fetchHtml(`/artists/awards.asp?id=${artist_id}`));
|
|
38844
|
+
return textResult({
|
|
38845
|
+
artist_id,
|
|
38846
|
+
earned_count: awards.filter((a) => a.earned).length,
|
|
38847
|
+
awards
|
|
38848
|
+
});
|
|
38849
|
+
}
|
|
38850
|
+
);
|
|
38851
|
+
server.registerTool(
|
|
38852
|
+
"artsonia_get_profile",
|
|
38853
|
+
{
|
|
38854
|
+
title: "Get your account profile",
|
|
38855
|
+
description: "Show your Artsonia parent/fan account profile: name, email, mobile, and current notification opt-in states (news / artist activity / promos). Read-only complement to artsonia_set_notifications.",
|
|
38856
|
+
annotations: toolAnnotations({ title: "Get your account profile", readOnly: true, openWorld: true }),
|
|
38857
|
+
inputSchema: {}
|
|
38858
|
+
},
|
|
38859
|
+
async () => textResult(parseProfile(await client2.fetchHtml("/members/profile/")))
|
|
38860
|
+
);
|
|
38861
|
+
}
|
|
38862
|
+
|
|
38863
|
+
// src/tools/download.ts
|
|
38864
|
+
init_zod();
|
|
38865
|
+
init_dist();
|
|
38866
|
+
import { mkdir, writeFile, utimes } from "node:fs/promises";
|
|
38867
|
+
import { existsSync } from "node:fs";
|
|
38868
|
+
import { join as join3 } from "node:path";
|
|
38869
|
+
var NumericId3 = external_exports.string().regex(/^\d+$/, "must be a numeric id");
|
|
38870
|
+
var DEFAULT_TEMPLATE = "{grade} - {project} - {title}";
|
|
38871
|
+
var FETCH_CONCURRENCY = 6;
|
|
38872
|
+
var MAX_NAME_LEN = 150;
|
|
38873
|
+
function normalizeGrade(g) {
|
|
38874
|
+
return (g ?? "").replace(/grade/i, "").trim().toLowerCase();
|
|
38875
|
+
}
|
|
38876
|
+
function buildFilename(template, fields, artworkId) {
|
|
38877
|
+
const tokens = {
|
|
38878
|
+
title: fields.title?.trim() ?? "",
|
|
38879
|
+
project: fields.project?.trim() ?? "",
|
|
38880
|
+
grade: fields.grade ? `Grade ${fields.grade}` : "",
|
|
38881
|
+
date: fields.date ?? "",
|
|
38882
|
+
artwork_id: artworkId
|
|
38883
|
+
};
|
|
38884
|
+
let name = template.replace(/\{(title|project|grade|date|artwork_id)\}/g, (_, k) => tokens[k] ?? "");
|
|
38885
|
+
let prev;
|
|
38886
|
+
do {
|
|
38887
|
+
prev = name;
|
|
38888
|
+
name = name.replace(/\s*-\s*-\s*/g, " - ");
|
|
38889
|
+
} while (name !== prev);
|
|
38890
|
+
name = name.replace(/^[\s\-_]+|[\s\-_]+$/g, "");
|
|
38891
|
+
name = name.replace(/[\/\\:*?"<>|\x00-\x1f]/g, "").replace(/^\.+/, "").replace(/\s+/g, " ").trim();
|
|
38892
|
+
if (name.length > MAX_NAME_LEN) name = name.slice(0, MAX_NAME_LEN).trim();
|
|
38893
|
+
if (!template.includes("{artwork_id}") && !name.includes(`(${artworkId})`)) {
|
|
38894
|
+
name = name ? `${name} (${artworkId})` : artworkId;
|
|
38895
|
+
}
|
|
38896
|
+
if (!name) name = artworkId;
|
|
38897
|
+
return `${name}.jpg`;
|
|
38898
|
+
}
|
|
38899
|
+
async function mapLimit(items, limit, fn) {
|
|
38900
|
+
const results = new Array(items.length);
|
|
38901
|
+
let next = 0;
|
|
38902
|
+
await Promise.all(
|
|
38903
|
+
Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
38904
|
+
while (next < items.length) {
|
|
38905
|
+
const i = next++;
|
|
38906
|
+
results[i] = await fn(items[i], i);
|
|
38907
|
+
}
|
|
38908
|
+
})
|
|
38909
|
+
);
|
|
38910
|
+
return results;
|
|
38911
|
+
}
|
|
38912
|
+
function registerDownloadTools(server, client2) {
|
|
38913
|
+
server.registerTool(
|
|
38914
|
+
"artsonia_download_artwork",
|
|
38915
|
+
{
|
|
38916
|
+
title: "Download a student's artwork images",
|
|
38917
|
+
description: `Download full-resolution images of a student's artwork to a local folder, named from the artwork title/project/grade and time-stamped to the image's source date. Optionally filter by class/project (substring), grade, and/or keep only the most-recent N (the portfolio is reliably newest-first). Re-runs are idempotent (skip_existing). Without confirm:true this is a DRY RUN that lists the resolved filenames and writes nothing. Note: descriptive filenames need each artwork's detail page (slower) \u2014 use filename_template "{artwork_id}" for the fast id-only path.`,
|
|
38918
|
+
annotations: toolAnnotations({ title: "Download a student's artwork images", readOnly: false, openWorld: true }),
|
|
38919
|
+
inputSchema: {
|
|
38920
|
+
artist_id: NumericId3.describe("Student artist_id (from artsonia_list_students)."),
|
|
38921
|
+
dest: external_exports.string().min(1).describe("Local destination folder (a leading ~ is expanded). Created if missing."),
|
|
38922
|
+
project: external_exports.string().min(1).optional().describe("Only artworks whose school-project/class name contains this (case-insensitive)."),
|
|
38923
|
+
grade: external_exports.string().min(1).optional().describe('Only artworks created in this grade, e.g. "6" or "Grade 6".'),
|
|
38924
|
+
limit: external_exports.number().int().positive().optional().describe("Keep only the N most recent matching artworks (portfolio is newest-first)."),
|
|
38925
|
+
resolution: external_exports.enum(["full", "xlarge", "large", "medium", "small"]).default("full").describe('Image resolution. "full" is the original (~0.7 MB each).'),
|
|
38926
|
+
filename_template: external_exports.string().min(1).default(DEFAULT_TEMPLATE).describe('Filename pattern. Tokens: {title} {project} {grade} {date} {artwork_id}. The artwork_id is auto-appended for uniqueness if absent. Use "{artwork_id}" for the fast id-only path (no detail fetch).'),
|
|
38927
|
+
set_mtime_from_source: external_exports.boolean().default(true).describe("Set each file's modified time from the image's Last-Modified header (its Artsonia upload date) instead of the download moment."),
|
|
38928
|
+
skip_existing: external_exports.boolean().default(true).describe("Skip artworks whose target file already exists (idempotent re-runs). Set false to overwrite."),
|
|
38929
|
+
confirm: schemaConfirm
|
|
38930
|
+
}
|
|
38931
|
+
},
|
|
38932
|
+
async ({ artist_id, dest, project, grade, limit, resolution, filename_template, set_mtime_from_source, skip_existing, confirm }) => {
|
|
38933
|
+
const template = filename_template;
|
|
38934
|
+
const templateUsesDate = /\{date\}/.test(template);
|
|
38935
|
+
const needDetail = project !== void 0 || grade !== void 0 || /\{(title|project|grade)\}/.test(template);
|
|
38936
|
+
const tiles = parsePortfolio(await client2.fetchHtml(`/artists/portfolio.asp?id=${artist_id}`));
|
|
38937
|
+
let items = tiles.map((t) => ({ artwork_id: t.artwork_id, is_private: t.is_private }));
|
|
38938
|
+
if (needDetail) {
|
|
38939
|
+
const wantGrade = normalizeGrade(grade);
|
|
38940
|
+
const tilesToDetail = limit !== void 0 && project === void 0 && grade === void 0 ? tiles.slice(0, limit) : tiles;
|
|
38941
|
+
const detailed = await mapLimit(tilesToDetail, FETCH_CONCURRENCY, async (t) => {
|
|
38942
|
+
const d = parseArtwork(await client2.fetchHtml(`/museum/art.asp?id=${t.artwork_id}`));
|
|
38943
|
+
return { artwork_id: t.artwork_id, is_private: t.is_private, title: d.title, project: d.project, grade: d.grade };
|
|
38944
|
+
});
|
|
38945
|
+
items = detailed.filter(
|
|
38946
|
+
(it) => (project === void 0 || (it.project ?? "").toLowerCase().includes(project.toLowerCase())) && (grade === void 0 || normalizeGrade(it.grade) === wantGrade)
|
|
38947
|
+
);
|
|
38948
|
+
}
|
|
38949
|
+
if (limit !== void 0) items = items.slice(0, limit);
|
|
38950
|
+
const destDir = expandPath(dest);
|
|
38951
|
+
if (confirm !== true) {
|
|
38952
|
+
return textResult({
|
|
38953
|
+
preview: true,
|
|
38954
|
+
action: "download_artwork",
|
|
38955
|
+
note: `DRY RUN \u2014 would download ${items.length} image(s) at "${resolution}" resolution to ${destDir}. Re-run with confirm: true to download.`,
|
|
38956
|
+
count: items.length,
|
|
38957
|
+
dest: destDir,
|
|
38958
|
+
resolution,
|
|
38959
|
+
filename_template: template,
|
|
38960
|
+
artworks: items.slice(0, 200).map((it) => ({
|
|
38961
|
+
artwork_id: it.artwork_id,
|
|
38962
|
+
...it.title !== void 0 ? { title: it.title } : {},
|
|
38963
|
+
filename: templateUsesDate ? "(finalized with {date} at download)" : buildFilename(template, it, it.artwork_id)
|
|
38964
|
+
}))
|
|
38965
|
+
});
|
|
38966
|
+
}
|
|
38967
|
+
await mkdir(destDir, { recursive: true });
|
|
38968
|
+
const outcomes = await mapLimit(items, FETCH_CONCURRENCY, async (it) => {
|
|
38969
|
+
try {
|
|
38970
|
+
let file2 = templateUsesDate ? null : join3(destDir, buildFilename(template, it, it.artwork_id));
|
|
38971
|
+
if (file2 && skip_existing && existsSync(file2)) return { kind: "skipped", artwork_id: it.artwork_id, file: file2 };
|
|
38972
|
+
const res = await fetch(artworkImageUrl(it.artwork_id, resolution));
|
|
38973
|
+
if (!res.ok) return { kind: "failed", artwork_id: it.artwork_id, reason: `HTTP ${res.status}` };
|
|
38974
|
+
const lastMod = res.headers.get("last-modified");
|
|
38975
|
+
const parsed = lastMod ? new Date(lastMod) : null;
|
|
38976
|
+
const sourceDate = parsed && !Number.isNaN(parsed.getTime()) ? parsed : null;
|
|
38977
|
+
const date5 = sourceDate ? sourceDate.toISOString().slice(0, 10) : "";
|
|
38978
|
+
if (!file2) {
|
|
38979
|
+
file2 = join3(destDir, buildFilename(template, { ...it, date: date5 }, it.artwork_id));
|
|
38980
|
+
if (skip_existing && existsSync(file2)) return { kind: "skipped", artwork_id: it.artwork_id, file: file2 };
|
|
38981
|
+
}
|
|
38982
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
38983
|
+
await writeFile(file2, buf);
|
|
38984
|
+
const fromSource = set_mtime_from_source && sourceDate !== null;
|
|
38985
|
+
if (fromSource) await utimes(file2, sourceDate, sourceDate);
|
|
38986
|
+
return {
|
|
38987
|
+
kind: "downloaded",
|
|
38988
|
+
artwork_id: it.artwork_id,
|
|
38989
|
+
file: file2,
|
|
38990
|
+
bytes: buf.length,
|
|
38991
|
+
date_source: fromSource ? "last-modified" : "download-time",
|
|
38992
|
+
timestamp: (fromSource ? sourceDate : /* @__PURE__ */ new Date()).toISOString()
|
|
38993
|
+
};
|
|
38994
|
+
} catch (e) {
|
|
38995
|
+
return { kind: "failed", artwork_id: it.artwork_id, reason: messageOf(e) };
|
|
38996
|
+
}
|
|
38997
|
+
});
|
|
38998
|
+
const downloaded = outcomes.filter((o) => o.kind === "downloaded");
|
|
38999
|
+
const skipped = outcomes.filter((o) => o.kind === "skipped");
|
|
39000
|
+
const failed = outcomes.filter((o) => o.kind === "failed");
|
|
39001
|
+
return textResult({
|
|
39002
|
+
downloaded_count: downloaded.length,
|
|
39003
|
+
skipped_count: skipped.length,
|
|
39004
|
+
failed_count: failed.length,
|
|
39005
|
+
dest: destDir,
|
|
39006
|
+
resolution,
|
|
39007
|
+
downloaded: downloaded.map(({ kind, ...rest }) => rest),
|
|
39008
|
+
...skipped.length ? { skipped: skipped.map(({ kind, ...rest }) => rest) } : {},
|
|
39009
|
+
...failed.length ? { failed: failed.map(({ kind, ...rest }) => rest) } : {}
|
|
39010
|
+
});
|
|
39011
|
+
}
|
|
39012
|
+
);
|
|
39013
|
+
}
|
|
39014
|
+
|
|
38707
39015
|
// src/tools/writes.ts
|
|
38708
39016
|
init_zod();
|
|
38709
39017
|
var import_node_html_parser2 = __toESM(require_dist2(), 1);
|
|
38710
39018
|
init_dist();
|
|
38711
|
-
var
|
|
39019
|
+
var NumericId4 = external_exports.string().regex(/^\d+$/, "must be a numeric id");
|
|
38712
39020
|
function previewResult(action, wouldSend, caveat) {
|
|
38713
39021
|
return textResult({
|
|
38714
39022
|
preview: true,
|
|
@@ -38749,8 +39057,8 @@ function registerWriteTools(server, client2) {
|
|
|
38749
39057
|
description: "Post a comment on a student's artwork. Without confirm:true this is a DRY RUN that returns a preview and makes no network call.",
|
|
38750
39058
|
annotations: toolAnnotations({ title: "Post a comment on an artwork", readOnly: false, openWorld: true }),
|
|
38751
39059
|
inputSchema: {
|
|
38752
|
-
artist_id:
|
|
38753
|
-
artwork_id:
|
|
39060
|
+
artist_id: NumericId4.describe("Student artist_id (from artsonia_list_students)."),
|
|
39061
|
+
artwork_id: NumericId4.describe("Artwork id (from artsonia_get_portfolio)."),
|
|
38754
39062
|
comment: external_exports.string().min(1).describe("The comment text to post."),
|
|
38755
39063
|
confirm: schemaConfirm
|
|
38756
39064
|
}
|
|
@@ -38770,11 +39078,11 @@ function registerWriteTools(server, client2) {
|
|
|
38770
39078
|
description: "Invite someone (by name + email) to follow a student's Artsonia portfolio. Sends them an invite email. Without confirm:true this is a DRY RUN. Use only real addresses you're authorized to invite (test with @example.com).",
|
|
38771
39079
|
annotations: toolAnnotations({ title: "Invite a fan", readOnly: false, openWorld: true }),
|
|
38772
39080
|
inputSchema: {
|
|
38773
|
-
artist_id:
|
|
39081
|
+
artist_id: NumericId4.describe("Student artist_id (from artsonia_list_students)."),
|
|
38774
39082
|
first_name: external_exports.string().min(1).describe("Fan's first name."),
|
|
38775
39083
|
last_name: external_exports.string().min(1).describe("Fan's last name."),
|
|
38776
39084
|
email: external_exports.string().email().describe("Fan's email address (they receive an invite)."),
|
|
38777
|
-
relationship_id:
|
|
39085
|
+
relationship_id: NumericId4.describe("Relationship code (RelationshipID select value from the Add Fans form)."),
|
|
38778
39086
|
is_parent: external_exports.boolean().default(false).describe("Whether this fan is also a parent/guardian."),
|
|
38779
39087
|
confirm: schemaConfirm
|
|
38780
39088
|
}
|
|
@@ -38852,6 +39160,9 @@ var tools = [
|
|
|
38852
39160
|
(s) => registerStudentTools(s, client),
|
|
38853
39161
|
(s) => registerPortfolioTools(s, client),
|
|
38854
39162
|
(s) => registerFanTools(s, client),
|
|
39163
|
+
(s) => registerFeedbackTools(s, client),
|
|
39164
|
+
(s) => registerAccountTools(s, client),
|
|
39165
|
+
(s) => registerDownloadTools(s, client),
|
|
38855
39166
|
(s) => registerWriteTools(s, client)
|
|
38856
39167
|
];
|
|
38857
39168
|
await runMcp({
|
|
@@ -6,6 +6,9 @@ import { registerHealthcheckTools } from './tools/healthcheck.js';
|
|
|
6
6
|
import { registerStudentTools } from './tools/students.js';
|
|
7
7
|
import { registerPortfolioTools } from './tools/portfolio.js';
|
|
8
8
|
import { registerFanTools } from './tools/fans.js';
|
|
9
|
+
import { registerFeedbackTools } from './tools/feedback.js';
|
|
10
|
+
import { registerAccountTools } from './tools/account.js';
|
|
11
|
+
import { registerDownloadTools } from './tools/download.js';
|
|
9
12
|
import { registerWriteTools } from './tools/writes.js';
|
|
10
13
|
// The client is a module-level singleton (constructed in client.ts) so the
|
|
11
14
|
// deferred-config-error pattern holds: the server boots and answers the host's
|
|
@@ -16,6 +19,9 @@ const tools = [
|
|
|
16
19
|
(s) => registerStudentTools(s, client),
|
|
17
20
|
(s) => registerPortfolioTools(s, client),
|
|
18
21
|
(s) => registerFanTools(s, client),
|
|
22
|
+
(s) => registerFeedbackTools(s, client),
|
|
23
|
+
(s) => registerAccountTools(s, client),
|
|
24
|
+
(s) => registerDownloadTools(s, client),
|
|
19
25
|
(s) => registerWriteTools(s, client),
|
|
20
26
|
];
|
|
21
27
|
await runMcp({
|
|
@@ -100,9 +100,10 @@ export function parseArtwork(html) {
|
|
|
100
100
|
break;
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
|
-
// project from body text: `from school project "<Project>"`
|
|
103
|
+
// project + grade from body text: `created by <name> in Grade <N> … from school project "<Project>"`
|
|
104
104
|
const bodyText = root.querySelector('body')?.text ?? '';
|
|
105
105
|
const project = bodyText.match(/from school project "([^"]+)"/)?.[1] ?? '';
|
|
106
|
+
const grade = bodyText.match(/in Grade\s+(\w+)/i)?.[1] ?? null;
|
|
106
107
|
// comment entry link
|
|
107
108
|
const link = root.querySelector('a[href*="comments/enter.asp"]');
|
|
108
109
|
const href = link?.getAttribute('href');
|
|
@@ -117,7 +118,10 @@ export function parseArtwork(html) {
|
|
|
117
118
|
author: text(c.querySelector('.comment-author')),
|
|
118
119
|
text: text(c.querySelector('.comment-text')),
|
|
119
120
|
}));
|
|
120
|
-
return { title, artist_screen_name, views, project, comment_entry, comments };
|
|
121
|
+
return { title, artist_screen_name, views, grade, project, comment_entry, comments };
|
|
122
|
+
}
|
|
123
|
+
export function artworkImageUrl(artworkId, resolution = 'full') {
|
|
124
|
+
return `https://images.artsonia.com/art/${resolution}/${artworkId}.jpg`;
|
|
121
125
|
}
|
|
122
126
|
export function parseFans(html) {
|
|
123
127
|
const root = parse(html);
|
|
@@ -142,3 +146,60 @@ export function parseFans(html) {
|
|
|
142
146
|
return { name, relationship };
|
|
143
147
|
}).filter((f) => f !== null);
|
|
144
148
|
}
|
|
149
|
+
export function parseFeedback(html) {
|
|
150
|
+
const root = parse(html);
|
|
151
|
+
return root.querySelectorAll('.comment-row').map((row) => {
|
|
152
|
+
const link = row.querySelector('.comment-art a[href*="art.asp"]');
|
|
153
|
+
const artwork_id = attrId(link?.getAttribute('href'), 'id');
|
|
154
|
+
const options = text(row.querySelector('.comment-options'));
|
|
155
|
+
return {
|
|
156
|
+
artwork_id,
|
|
157
|
+
message: text(row.querySelector('.comment')),
|
|
158
|
+
posted_by: text(row.querySelector('.commenter')),
|
|
159
|
+
is_read: !/not been marked as read/i.test(options),
|
|
160
|
+
thumbnail: artwork_id ? `https://images.artsonia.com/art/small/${artwork_id}.jpg` : null,
|
|
161
|
+
};
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
export function parseAwards(html) {
|
|
165
|
+
const root = parse(html);
|
|
166
|
+
const awards = [];
|
|
167
|
+
// Current-year achievement badges: name / status / criteria / progress.
|
|
168
|
+
// The card is `<img><div>(content)<div>name</div><div>status</div>…</div>`;
|
|
169
|
+
// walk the content wrapper's child divs (the first descendant div is the wrapper).
|
|
170
|
+
for (const card of root.querySelectorAll('.award-card')) {
|
|
171
|
+
const content = card.querySelector('div');
|
|
172
|
+
const lines = content ? content.querySelectorAll('div').map((d) => text(d)).filter(Boolean) : [];
|
|
173
|
+
const name = lines[0] ?? '';
|
|
174
|
+
if (!name)
|
|
175
|
+
continue;
|
|
176
|
+
awards.push({
|
|
177
|
+
name,
|
|
178
|
+
earned: /^earned$/i.test((lines[1] ?? '').trim()),
|
|
179
|
+
description: lines[2] ?? '',
|
|
180
|
+
progress: lines[3] ?? '',
|
|
181
|
+
period: 'current',
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
// Past-year earned badges: just an icon (artist_<name>[_ghosted].gif).
|
|
185
|
+
for (const card of root.querySelectorAll('.award-card-past')) {
|
|
186
|
+
const file = card.querySelector('img')?.getAttribute('src')?.split('/').pop() ?? '';
|
|
187
|
+
const slug = file.replace(/\.gif$/i, '').replace(/_ghosted$/i, '').replace(/^artist_/i, '');
|
|
188
|
+
const name = slug ? slug.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) : 'Award';
|
|
189
|
+
awards.push({ name, earned: true, description: '', progress: '', period: 'past' });
|
|
190
|
+
}
|
|
191
|
+
return awards;
|
|
192
|
+
}
|
|
193
|
+
/** Read-only view of the parent profile form (`#TheForm` on /members/profile/). */
|
|
194
|
+
export function parseProfile(html) {
|
|
195
|
+
const form = parse(html).querySelector('#TheForm');
|
|
196
|
+
const val = (name) => form?.querySelector(`input[name="${name}"], select[name="${name}"]`)?.getAttribute('value') ?? '';
|
|
197
|
+
const checked = (name) => form?.querySelector(`input[name="${name}"]`)?.hasAttribute('checked') ?? false;
|
|
198
|
+
return {
|
|
199
|
+
first_name: val('FirstName'),
|
|
200
|
+
last_name: val('LastName'),
|
|
201
|
+
email: val('EmailAddress'),
|
|
202
|
+
mobile: val('MobileNumber'),
|
|
203
|
+
opt_ins: { news: checked('OptInNews'), artist_activity: checked('OptInArtistActivity'), promos: checked('OptInPromos') },
|
|
204
|
+
};
|
|
205
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
|
|
3
|
+
import { parseAwards, parseProfile } from '../parse.js';
|
|
4
|
+
export function registerAccountTools(server, client) {
|
|
5
|
+
server.registerTool('artsonia_get_awards', {
|
|
6
|
+
title: "Get a student's awards & activities",
|
|
7
|
+
description: "List a student's Artsonia awards/achievement badges — current-year badges (name, earned/not, criteria, progress) plus badges earned in prior years. Pass the artist_id from artsonia_list_students.",
|
|
8
|
+
annotations: toolAnnotations({ title: "Get a student's awards & activities", readOnly: true, openWorld: true }),
|
|
9
|
+
inputSchema: { artist_id: z.string().regex(/^\d+$/, 'must be a numeric id').describe('Student artist_id (from artsonia_list_students).') },
|
|
10
|
+
}, async ({ artist_id }) => {
|
|
11
|
+
const awards = parseAwards(await client.fetchHtml(`/artists/awards.asp?id=${artist_id}`));
|
|
12
|
+
return textResult({
|
|
13
|
+
artist_id,
|
|
14
|
+
earned_count: awards.filter((a) => a.earned).length,
|
|
15
|
+
awards,
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
server.registerTool('artsonia_get_profile', {
|
|
19
|
+
title: 'Get your account profile',
|
|
20
|
+
description: 'Show your Artsonia parent/fan account profile: name, email, mobile, and current notification opt-in states (news / artist activity / promos). Read-only complement to artsonia_set_notifications.',
|
|
21
|
+
annotations: toolAnnotations({ title: 'Get your account profile', readOnly: true, openWorld: true }),
|
|
22
|
+
inputSchema: {},
|
|
23
|
+
}, async () => textResult(parseProfile(await client.fetchHtml('/members/profile/'))));
|
|
24
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { mkdir, writeFile, utimes } from 'node:fs/promises';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { textResult, toolAnnotations, schemaConfirm, expandPath, messageOf } from '@chrischall/mcp-utils';
|
|
6
|
+
import { parsePortfolio, parseArtwork, artworkImageUrl } from '../parse.js';
|
|
7
|
+
const NumericId = z.string().regex(/^\d+$/, 'must be a numeric id');
|
|
8
|
+
const DEFAULT_TEMPLATE = '{grade} - {project} - {title}';
|
|
9
|
+
const FETCH_CONCURRENCY = 6;
|
|
10
|
+
const MAX_NAME_LEN = 150;
|
|
11
|
+
/** "Grade 6" / "grade 6" / "6" → "6"; "Grade K" → "k". */
|
|
12
|
+
function normalizeGrade(g) {
|
|
13
|
+
return (g ?? '').replace(/grade/i, '').trim().toLowerCase();
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Resolve a filename template to a safe, unique `.jpg` name.
|
|
17
|
+
* Tokens: {title} {project} {grade}(→"Grade N") {date} {artwork_id}. Empty tokens
|
|
18
|
+
* and their surrounding " - " separators are dropped. The result is slugified for
|
|
19
|
+
* filesystem safety, and the artwork_id is always appended (unless the template
|
|
20
|
+
* already includes it) so names are guaranteed unique AND a pure function of the
|
|
21
|
+
* artwork (→ idempotent re-runs). Untitled/empty → just the artwork_id.
|
|
22
|
+
*/
|
|
23
|
+
export function buildFilename(template, fields, artworkId) {
|
|
24
|
+
const tokens = {
|
|
25
|
+
title: fields.title?.trim() ?? '',
|
|
26
|
+
project: fields.project?.trim() ?? '',
|
|
27
|
+
grade: fields.grade ? `Grade ${fields.grade}` : '',
|
|
28
|
+
date: fields.date ?? '',
|
|
29
|
+
artwork_id: artworkId,
|
|
30
|
+
};
|
|
31
|
+
let name = template.replace(/\{(title|project|grade|date|artwork_id)\}/g, (_, k) => tokens[k] ?? '');
|
|
32
|
+
// Collapse separators left by empty tokens, e.g. "Grade 6 - - Title" → "Grade 6 - Title".
|
|
33
|
+
let prev;
|
|
34
|
+
do {
|
|
35
|
+
prev = name;
|
|
36
|
+
name = name.replace(/\s*-\s*-\s*/g, ' - ');
|
|
37
|
+
} while (name !== prev);
|
|
38
|
+
name = name.replace(/^[\s\-_]+|[\s\-_]+$/g, '');
|
|
39
|
+
// Slugify: drop filesystem-unsafe chars + control chars + leading dots; collapse whitespace.
|
|
40
|
+
name = name.replace(/[\/\\:*?"<>|\x00-\x1f]/g, '').replace(/^\.+/, '').replace(/\s+/g, ' ').trim();
|
|
41
|
+
if (name.length > MAX_NAME_LEN)
|
|
42
|
+
name = name.slice(0, MAX_NAME_LEN).trim();
|
|
43
|
+
// Guarantee uniqueness + idempotency: every name carries the artwork_id in its
|
|
44
|
+
// canonical "(id)" form unless the template already places the id explicitly.
|
|
45
|
+
if (!template.includes('{artwork_id}') && !name.includes(`(${artworkId})`)) {
|
|
46
|
+
name = name ? `${name} (${artworkId})` : artworkId;
|
|
47
|
+
}
|
|
48
|
+
if (!name)
|
|
49
|
+
name = artworkId;
|
|
50
|
+
return `${name}.jpg`;
|
|
51
|
+
}
|
|
52
|
+
/** Bounded-concurrency map that preserves input order in the result. */
|
|
53
|
+
async function mapLimit(items, limit, fn) {
|
|
54
|
+
const results = new Array(items.length);
|
|
55
|
+
let next = 0;
|
|
56
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
57
|
+
while (next < items.length) {
|
|
58
|
+
const i = next++;
|
|
59
|
+
results[i] = await fn(items[i], i);
|
|
60
|
+
}
|
|
61
|
+
}));
|
|
62
|
+
return results;
|
|
63
|
+
}
|
|
64
|
+
export function registerDownloadTools(server, client) {
|
|
65
|
+
server.registerTool('artsonia_download_artwork', {
|
|
66
|
+
title: "Download a student's artwork images",
|
|
67
|
+
description: "Download full-resolution images of a student's artwork to a local folder, named from the artwork title/project/grade and time-stamped to the image's source date. Optionally filter by class/project (substring), grade, and/or keep only the most-recent N (the portfolio is reliably newest-first). Re-runs are idempotent (skip_existing). Without confirm:true this is a DRY RUN that lists the resolved filenames and writes nothing. Note: descriptive filenames need each artwork's detail page (slower) — use filename_template \"{artwork_id}\" for the fast id-only path.",
|
|
68
|
+
annotations: toolAnnotations({ title: "Download a student's artwork images", readOnly: false, openWorld: true }),
|
|
69
|
+
inputSchema: {
|
|
70
|
+
artist_id: NumericId.describe('Student artist_id (from artsonia_list_students).'),
|
|
71
|
+
dest: z.string().min(1).describe('Local destination folder (a leading ~ is expanded). Created if missing.'),
|
|
72
|
+
project: z.string().min(1).optional().describe('Only artworks whose school-project/class name contains this (case-insensitive).'),
|
|
73
|
+
grade: z.string().min(1).optional().describe('Only artworks created in this grade, e.g. "6" or "Grade 6".'),
|
|
74
|
+
limit: z.number().int().positive().optional().describe('Keep only the N most recent matching artworks (portfolio is newest-first).'),
|
|
75
|
+
resolution: z.enum(['full', 'xlarge', 'large', 'medium', 'small']).default('full').describe('Image resolution. "full" is the original (~0.7 MB each).'),
|
|
76
|
+
filename_template: z.string().min(1).default(DEFAULT_TEMPLATE).describe('Filename pattern. Tokens: {title} {project} {grade} {date} {artwork_id}. The artwork_id is auto-appended for uniqueness if absent. Use "{artwork_id}" for the fast id-only path (no detail fetch).'),
|
|
77
|
+
set_mtime_from_source: z.boolean().default(true).describe("Set each file's modified time from the image's Last-Modified header (its Artsonia upload date) instead of the download moment."),
|
|
78
|
+
skip_existing: z.boolean().default(true).describe('Skip artworks whose target file already exists (idempotent re-runs). Set false to overwrite.'),
|
|
79
|
+
confirm: schemaConfirm,
|
|
80
|
+
},
|
|
81
|
+
}, async ({ artist_id, dest, project, grade, limit, resolution, filename_template, set_mtime_from_source, skip_existing, confirm }) => {
|
|
82
|
+
const template = filename_template;
|
|
83
|
+
const templateUsesDate = /\{date\}/.test(template);
|
|
84
|
+
const needDetail = project !== undefined || grade !== undefined || /\{(title|project|grade)\}/.test(template);
|
|
85
|
+
// 1. Portfolio → artwork ids (newest-first) + private flags.
|
|
86
|
+
const tiles = parsePortfolio(await client.fetchHtml(`/artists/portfolio.asp?id=${artist_id}`));
|
|
87
|
+
let items = tiles.map((t) => ({ artwork_id: t.artwork_id, is_private: t.is_private }));
|
|
88
|
+
// 2. Detail fetch (for naming and/or project/grade filtering).
|
|
89
|
+
if (needDetail) {
|
|
90
|
+
const wantGrade = normalizeGrade(grade);
|
|
91
|
+
// With a limit and no filter, only the newest N need a detail page — don't
|
|
92
|
+
// fetch the whole portfolio just to throw most of it away.
|
|
93
|
+
const tilesToDetail = limit !== undefined && project === undefined && grade === undefined ? tiles.slice(0, limit) : tiles;
|
|
94
|
+
const detailed = await mapLimit(tilesToDetail, FETCH_CONCURRENCY, async (t) => {
|
|
95
|
+
const d = parseArtwork(await client.fetchHtml(`/museum/art.asp?id=${t.artwork_id}`));
|
|
96
|
+
return { artwork_id: t.artwork_id, is_private: t.is_private, title: d.title, project: d.project, grade: d.grade };
|
|
97
|
+
});
|
|
98
|
+
items = detailed.filter((it) => (project === undefined || (it.project ?? '').toLowerCase().includes(project.toLowerCase())) &&
|
|
99
|
+
(grade === undefined || normalizeGrade(it.grade) === wantGrade));
|
|
100
|
+
}
|
|
101
|
+
// 3. Most-recent-N (no-op when already capped above; still needed for the filtered path).
|
|
102
|
+
if (limit !== undefined)
|
|
103
|
+
items = items.slice(0, limit);
|
|
104
|
+
const destDir = expandPath(dest);
|
|
105
|
+
// 4. Dry run — show resolved filenames (date-token names finalize at download time).
|
|
106
|
+
if (confirm !== true) {
|
|
107
|
+
return textResult({
|
|
108
|
+
preview: true,
|
|
109
|
+
action: 'download_artwork',
|
|
110
|
+
note: `DRY RUN — would download ${items.length} image(s) at "${resolution}" resolution to ${destDir}. Re-run with confirm: true to download.`,
|
|
111
|
+
count: items.length,
|
|
112
|
+
dest: destDir,
|
|
113
|
+
resolution,
|
|
114
|
+
filename_template: template,
|
|
115
|
+
artworks: items.slice(0, 200).map((it) => ({
|
|
116
|
+
artwork_id: it.artwork_id,
|
|
117
|
+
...(it.title !== undefined ? { title: it.title } : {}),
|
|
118
|
+
filename: templateUsesDate ? '(finalized with {date} at download)' : buildFilename(template, it, it.artwork_id),
|
|
119
|
+
})),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
// 5. Download (bounded concurrency).
|
|
123
|
+
await mkdir(destDir, { recursive: true });
|
|
124
|
+
const outcomes = await mapLimit(items, FETCH_CONCURRENCY, async (it) => {
|
|
125
|
+
try {
|
|
126
|
+
// Filenames without {date} are known up-front → skip before fetching.
|
|
127
|
+
let file = templateUsesDate ? null : join(destDir, buildFilename(template, it, it.artwork_id));
|
|
128
|
+
if (file && skip_existing && existsSync(file))
|
|
129
|
+
return { kind: 'skipped', artwork_id: it.artwork_id, file };
|
|
130
|
+
const res = await fetch(artworkImageUrl(it.artwork_id, resolution));
|
|
131
|
+
if (!res.ok)
|
|
132
|
+
return { kind: 'failed', artwork_id: it.artwork_id, reason: `HTTP ${res.status}` };
|
|
133
|
+
// Parse Last-Modified defensively — a malformed header must NOT fail the
|
|
134
|
+
// download; it just falls back to download-time.
|
|
135
|
+
const lastMod = res.headers.get('last-modified');
|
|
136
|
+
const parsed = lastMod ? new Date(lastMod) : null;
|
|
137
|
+
const sourceDate = parsed && !Number.isNaN(parsed.getTime()) ? parsed : null;
|
|
138
|
+
const date = sourceDate ? sourceDate.toISOString().slice(0, 10) : '';
|
|
139
|
+
if (!file) {
|
|
140
|
+
file = join(destDir, buildFilename(template, { ...it, date }, it.artwork_id));
|
|
141
|
+
if (skip_existing && existsSync(file))
|
|
142
|
+
return { kind: 'skipped', artwork_id: it.artwork_id, file };
|
|
143
|
+
}
|
|
144
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
145
|
+
await writeFile(file, buf);
|
|
146
|
+
// date_source / timestamp reflect the file's ACTUAL mtime.
|
|
147
|
+
const fromSource = set_mtime_from_source && sourceDate !== null;
|
|
148
|
+
if (fromSource)
|
|
149
|
+
await utimes(file, sourceDate, sourceDate);
|
|
150
|
+
return {
|
|
151
|
+
kind: 'downloaded',
|
|
152
|
+
artwork_id: it.artwork_id,
|
|
153
|
+
file,
|
|
154
|
+
bytes: buf.length,
|
|
155
|
+
date_source: fromSource ? 'last-modified' : 'download-time',
|
|
156
|
+
timestamp: (fromSource ? sourceDate : new Date()).toISOString(),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
catch (e) {
|
|
160
|
+
return { kind: 'failed', artwork_id: it.artwork_id, reason: messageOf(e) };
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
const downloaded = outcomes.filter((o) => o.kind === 'downloaded');
|
|
164
|
+
const skipped = outcomes.filter((o) => o.kind === 'skipped');
|
|
165
|
+
const failed = outcomes.filter((o) => o.kind === 'failed');
|
|
166
|
+
return textResult({
|
|
167
|
+
downloaded_count: downloaded.length,
|
|
168
|
+
skipped_count: skipped.length,
|
|
169
|
+
failed_count: failed.length,
|
|
170
|
+
dest: destDir,
|
|
171
|
+
resolution,
|
|
172
|
+
downloaded: downloaded.map(({ kind, ...rest }) => rest),
|
|
173
|
+
...(skipped.length ? { skipped: skipped.map(({ kind, ...rest }) => rest) } : {}),
|
|
174
|
+
...(failed.length ? { failed: failed.map(({ kind, ...rest }) => rest) } : {}),
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { textResult, toolAnnotations, schemaConfirm } from '@chrischall/mcp-utils';
|
|
3
|
+
import { parseFeedback } from '../parse.js';
|
|
4
|
+
const NumericId = z.string().regex(/^\d+$/, 'must be a numeric id');
|
|
5
|
+
export function registerFeedbackTools(server, client) {
|
|
6
|
+
server.registerTool('artsonia_get_feedback', {
|
|
7
|
+
title: 'Get teacher feedback for a student',
|
|
8
|
+
description: "List the teacher feedback left on a student's artwork — each item's message, who posted it and when, the artwork it's about, and whether it's been marked as read. Pass the artist_id from artsonia_list_students.",
|
|
9
|
+
annotations: toolAnnotations({ title: 'Get teacher feedback for a student', readOnly: true, openWorld: true }),
|
|
10
|
+
inputSchema: { artist_id: NumericId.describe('Student artist_id (from artsonia_list_students).') },
|
|
11
|
+
}, async ({ artist_id }) => {
|
|
12
|
+
const feedback = parseFeedback(await client.fetchHtml(`/members/feedback/?artist=${artist_id}`));
|
|
13
|
+
return textResult({
|
|
14
|
+
artist_id,
|
|
15
|
+
unread_count: feedback.filter((f) => !f.is_read).length,
|
|
16
|
+
feedback,
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
server.registerTool('artsonia_mark_feedback_read', {
|
|
20
|
+
title: 'Mark a student\'s feedback as read',
|
|
21
|
+
description: "Mark the student's teacher feedback as read (this is a mark-ALL action — Artsonia has no per-item control). Without confirm:true this is a DRY RUN that returns a preview and makes no network call.",
|
|
22
|
+
annotations: toolAnnotations({ title: "Mark a student's feedback as read", readOnly: false, openWorld: true }),
|
|
23
|
+
inputSchema: {
|
|
24
|
+
artist_id: NumericId.describe('Student artist_id (from artsonia_list_students).'),
|
|
25
|
+
confirm: schemaConfirm,
|
|
26
|
+
},
|
|
27
|
+
}, async ({ artist_id, confirm }) => {
|
|
28
|
+
const path = `/members/feedback/default.asp?artist=${artist_id}`;
|
|
29
|
+
const body = new URLSearchParams({ ConfirmAsRead: 'Mark as Read' }).toString();
|
|
30
|
+
if (confirm !== true) {
|
|
31
|
+
return textResult({
|
|
32
|
+
preview: true,
|
|
33
|
+
action: 'mark_feedback_read',
|
|
34
|
+
note: 'DRY RUN — nothing was sent. Re-run with confirm: true to mark ALL of this student\'s feedback as read.',
|
|
35
|
+
wouldSend: { path, ConfirmAsRead: 'Mark as Read' },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
const res = await client.write(path, body);
|
|
39
|
+
return textResult({ marked_read: true, artist_id, status: res.status });
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { FetchproxyServer } from '@fetchproxy/server';
|
|
2
1
|
import { ARTSONIA_ORIGIN } from './transport.js';
|
|
3
2
|
import { readEnvVar } from '@chrischall/mcp-utils';
|
|
4
3
|
import { VERSION } from './version.js';
|
|
@@ -8,24 +7,34 @@ const DEFAULT_PORT = 37_149; // shared fleet port — do NOT change
|
|
|
8
7
|
// fetchproxy. Cookies are carried by the browser, so the AuthManager's jar is
|
|
9
8
|
// unused in this mode; login is whatever the browser already holds.
|
|
10
9
|
export class FetchproxyArtsoniaTransport {
|
|
11
|
-
inner;
|
|
10
|
+
inner = null;
|
|
12
11
|
started = false;
|
|
13
|
-
|
|
12
|
+
starting = null;
|
|
13
|
+
buildOpts() {
|
|
14
14
|
const portEnv = readEnvVar('ARTSONIA_WS_PORT');
|
|
15
|
-
|
|
15
|
+
return {
|
|
16
16
|
port: portEnv ? Number(portEnv) : DEFAULT_PORT,
|
|
17
17
|
serverName: 'artsonia-mcp',
|
|
18
18
|
version: VERSION,
|
|
19
19
|
domains: ['artsonia.com'],
|
|
20
20
|
capabilities: ['fetch'],
|
|
21
21
|
};
|
|
22
|
-
this.inner = new FetchproxyServer(opts);
|
|
23
22
|
}
|
|
24
23
|
async ensureStarted() {
|
|
25
24
|
if (this.started)
|
|
26
25
|
return;
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
// Single-flight: concurrent first-calls share one init so we never construct
|
|
27
|
+
// two FetchproxyServers / bind the shared port twice.
|
|
28
|
+
if (this.starting)
|
|
29
|
+
return this.starting;
|
|
30
|
+
this.starting = (async () => {
|
|
31
|
+
// Lazy import: only loaded when fetchproxy mode is actually used.
|
|
32
|
+
const { FetchproxyServer } = await import('@fetchproxy/server');
|
|
33
|
+
this.inner = new FetchproxyServer(this.buildOpts());
|
|
34
|
+
await this.inner.listen();
|
|
35
|
+
this.started = true;
|
|
36
|
+
})().finally(() => { this.starting = null; });
|
|
37
|
+
return this.starting;
|
|
29
38
|
}
|
|
30
39
|
async request(req) {
|
|
31
40
|
await this.ensureStarted();
|
package/manifest.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"manifest_version": "0.3",
|
|
4
4
|
"name": "artsonia-mcp",
|
|
5
5
|
"display_name": "Artsonia",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.4.0",
|
|
7
7
|
"description": "Artsonia student-art portfolios, comments, and fans for Claude — access via natural language",
|
|
8
8
|
"author": {
|
|
9
9
|
"name": "Chris Chall",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "artsonia-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"mcpName": "io.github.chrischall/artsonia-mcp",
|
|
5
5
|
"description": "Artsonia MCP server for Claude — developed and maintained by AI (Claude Code)",
|
|
6
6
|
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/chrischall/artsonia-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.4.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "artsonia-mcp",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.4.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|