artsonia-mcp 0.1.0 → 0.3.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 +276 -36
- 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 +87 -0
- package/dist/tools/feedback.js +41 -0
- package/dist/{src/tools → tools}/writes.js +12 -7
- 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 +29 -6
- 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/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.3.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.3.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.3.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,177 @@ 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 } from "node:fs/promises";
|
|
38867
|
+
import { join as join3 } from "node:path";
|
|
38868
|
+
var NumericId3 = external_exports.string().regex(/^\d+$/, "must be a numeric id");
|
|
38869
|
+
function normalizeGrade(g) {
|
|
38870
|
+
return (g ?? "").replace(/grade/i, "").trim().toLowerCase();
|
|
38871
|
+
}
|
|
38872
|
+
function registerDownloadTools(server, client2) {
|
|
38873
|
+
server.registerTool(
|
|
38874
|
+
"artsonia_download_artwork",
|
|
38875
|
+
{
|
|
38876
|
+
title: "Download a student's artwork images",
|
|
38877
|
+
description: "Download full-resolution images of a student's artwork to a local folder. Optionally filter by class/project name (substring), by grade, and/or keep only the most-recent N (Artsonia exposes no real dates, but the portfolio is reliably newest-first). Without confirm:true this is a DRY RUN that lists what WOULD be downloaded and writes nothing. Note: project/grade filters require fetching each artwork's detail page (slower).",
|
|
38878
|
+
annotations: toolAnnotations({ title: "Download a student's artwork images", readOnly: false, openWorld: true }),
|
|
38879
|
+
inputSchema: {
|
|
38880
|
+
artist_id: NumericId3.describe("Student artist_id (from artsonia_list_students)."),
|
|
38881
|
+
dest: external_exports.string().min(1).describe("Local destination folder (a leading ~ is expanded). Created if missing."),
|
|
38882
|
+
project: external_exports.string().min(1).optional().describe("Only artworks whose school-project/class name contains this (case-insensitive)."),
|
|
38883
|
+
grade: external_exports.string().min(1).optional().describe('Only artworks created in this grade, e.g. "6" or "Grade 6".'),
|
|
38884
|
+
limit: external_exports.number().int().positive().optional().describe("Keep only the N most recent matching artworks (portfolio is newest-first)."),
|
|
38885
|
+
resolution: external_exports.enum(["full", "xlarge", "large", "medium", "small"]).default("full").describe('Image resolution. "full" is the original (~0.7 MB each).'),
|
|
38886
|
+
confirm: schemaConfirm
|
|
38887
|
+
}
|
|
38888
|
+
},
|
|
38889
|
+
async ({ artist_id, dest, project, grade, limit, resolution, confirm }) => {
|
|
38890
|
+
const tiles = parsePortfolio(await client2.fetchHtml(`/artists/portfolio.asp?id=${artist_id}`));
|
|
38891
|
+
let items = tiles.map((t) => ({ artwork_id: t.artwork_id }));
|
|
38892
|
+
if (project !== void 0 || grade !== void 0) {
|
|
38893
|
+
const wantGrade = normalizeGrade(grade);
|
|
38894
|
+
const matched = [];
|
|
38895
|
+
for (const t of tiles) {
|
|
38896
|
+
const d = parseArtwork(await client2.fetchHtml(`/museum/art.asp?id=${t.artwork_id}`));
|
|
38897
|
+
const okProject = project === void 0 || (d.project ?? "").toLowerCase().includes(project.toLowerCase());
|
|
38898
|
+
const okGrade = grade === void 0 || normalizeGrade(d.grade) === wantGrade;
|
|
38899
|
+
if (okProject && okGrade) matched.push({ artwork_id: t.artwork_id, title: d.title, project: d.project, grade: d.grade });
|
|
38900
|
+
}
|
|
38901
|
+
items = matched;
|
|
38902
|
+
}
|
|
38903
|
+
if (limit !== void 0) items = items.slice(0, limit);
|
|
38904
|
+
const destDir = expandPath(dest);
|
|
38905
|
+
if (confirm !== true) {
|
|
38906
|
+
return textResult({
|
|
38907
|
+
preview: true,
|
|
38908
|
+
action: "download_artwork",
|
|
38909
|
+
note: `DRY RUN \u2014 would download ${items.length} image(s) at "${resolution}" resolution to ${destDir}. Re-run with confirm: true to download.`,
|
|
38910
|
+
count: items.length,
|
|
38911
|
+
dest: destDir,
|
|
38912
|
+
resolution,
|
|
38913
|
+
artworks: items.slice(0, 100)
|
|
38914
|
+
});
|
|
38915
|
+
}
|
|
38916
|
+
await mkdir(destDir, { recursive: true });
|
|
38917
|
+
const downloaded = [];
|
|
38918
|
+
const failed = [];
|
|
38919
|
+
for (const it of items) {
|
|
38920
|
+
try {
|
|
38921
|
+
const res = await fetch(artworkImageUrl(it.artwork_id, resolution));
|
|
38922
|
+
if (!res.ok) {
|
|
38923
|
+
failed.push({ artwork_id: it.artwork_id, reason: `HTTP ${res.status}` });
|
|
38924
|
+
continue;
|
|
38925
|
+
}
|
|
38926
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
38927
|
+
const file2 = join3(destDir, `${it.artwork_id}.jpg`);
|
|
38928
|
+
await writeFile(file2, buf);
|
|
38929
|
+
downloaded.push({ artwork_id: it.artwork_id, file: file2, bytes: buf.length });
|
|
38930
|
+
} catch (e) {
|
|
38931
|
+
failed.push({ artwork_id: it.artwork_id, reason: messageOf(e) });
|
|
38932
|
+
}
|
|
38933
|
+
}
|
|
38934
|
+
return textResult({
|
|
38935
|
+
downloaded_count: downloaded.length,
|
|
38936
|
+
failed_count: failed.length,
|
|
38937
|
+
dest: destDir,
|
|
38938
|
+
resolution,
|
|
38939
|
+
downloaded,
|
|
38940
|
+
...failed.length ? { failed } : {}
|
|
38941
|
+
});
|
|
38942
|
+
}
|
|
38943
|
+
);
|
|
38944
|
+
}
|
|
38945
|
+
|
|
38707
38946
|
// src/tools/writes.ts
|
|
38708
38947
|
init_zod();
|
|
38709
38948
|
var import_node_html_parser2 = __toESM(require_dist2(), 1);
|
|
38710
38949
|
init_dist();
|
|
38711
|
-
var
|
|
38950
|
+
var NumericId4 = external_exports.string().regex(/^\d+$/, "must be a numeric id");
|
|
38712
38951
|
function previewResult(action, wouldSend, caveat) {
|
|
38713
38952
|
return textResult({
|
|
38714
38953
|
preview: true,
|
|
@@ -38724,12 +38963,14 @@ function parseProfileForm(html) {
|
|
|
38724
38963
|
if (!form) throw new Error("Could not find the Artsonia profile form (#TheForm).");
|
|
38725
38964
|
const fields = {};
|
|
38726
38965
|
const checkboxes = {};
|
|
38966
|
+
const checkboxValues = {};
|
|
38727
38967
|
for (const el of form.querySelectorAll("input, select")) {
|
|
38728
38968
|
const name = el.getAttribute("name");
|
|
38729
38969
|
if (!name) continue;
|
|
38730
38970
|
const type = el.getAttribute("type") ?? el.tagName.toLowerCase();
|
|
38731
38971
|
if (type === "checkbox") {
|
|
38732
38972
|
checkboxes[name] = el.hasAttribute("checked");
|
|
38973
|
+
checkboxValues[name] = el.getAttribute("value") ?? "Y";
|
|
38733
38974
|
} else if (el.tagName.toLowerCase() === "select") {
|
|
38734
38975
|
const sel = el.querySelector("option[selected]") ?? el.querySelector("option");
|
|
38735
38976
|
fields[name] = sel?.getAttribute("value") ?? "";
|
|
@@ -38737,7 +38978,7 @@ function parseProfileForm(html) {
|
|
|
38737
38978
|
fields[name] = el.getAttribute("value") ?? "";
|
|
38738
38979
|
}
|
|
38739
38980
|
}
|
|
38740
|
-
return { fields, checkboxes };
|
|
38981
|
+
return { fields, checkboxes, checkboxValues };
|
|
38741
38982
|
}
|
|
38742
38983
|
function registerWriteTools(server, client2) {
|
|
38743
38984
|
server.registerTool(
|
|
@@ -38747,8 +38988,8 @@ function registerWriteTools(server, client2) {
|
|
|
38747
38988
|
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.",
|
|
38748
38989
|
annotations: toolAnnotations({ title: "Post a comment on an artwork", readOnly: false, openWorld: true }),
|
|
38749
38990
|
inputSchema: {
|
|
38750
|
-
artist_id:
|
|
38751
|
-
artwork_id:
|
|
38991
|
+
artist_id: NumericId4.describe("Student artist_id (from artsonia_list_students)."),
|
|
38992
|
+
artwork_id: NumericId4.describe("Artwork id (from artsonia_get_portfolio)."),
|
|
38752
38993
|
comment: external_exports.string().min(1).describe("The comment text to post."),
|
|
38753
38994
|
confirm: schemaConfirm
|
|
38754
38995
|
}
|
|
@@ -38768,11 +39009,11 @@ function registerWriteTools(server, client2) {
|
|
|
38768
39009
|
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).",
|
|
38769
39010
|
annotations: toolAnnotations({ title: "Invite a fan", readOnly: false, openWorld: true }),
|
|
38770
39011
|
inputSchema: {
|
|
38771
|
-
artist_id:
|
|
39012
|
+
artist_id: NumericId4.describe("Student artist_id (from artsonia_list_students)."),
|
|
38772
39013
|
first_name: external_exports.string().min(1).describe("Fan's first name."),
|
|
38773
39014
|
last_name: external_exports.string().min(1).describe("Fan's last name."),
|
|
38774
39015
|
email: external_exports.string().email().describe("Fan's email address (they receive an invite)."),
|
|
38775
|
-
relationship_id:
|
|
39016
|
+
relationship_id: NumericId4.describe("Relationship code (RelationshipID select value from the Add Fans form)."),
|
|
38776
39017
|
is_parent: external_exports.boolean().default(false).describe("Whether this fan is also a parent/guardian."),
|
|
38777
39018
|
confirm: schemaConfirm
|
|
38778
39019
|
}
|
|
@@ -38813,7 +39054,7 @@ function registerWriteTools(server, client2) {
|
|
|
38813
39054
|
if (news === void 0 && artist_activity === void 0 && promos === void 0) {
|
|
38814
39055
|
return textResult({ error: "Specify at least one of news / artist_activity / promos." });
|
|
38815
39056
|
}
|
|
38816
|
-
const { fields, checkboxes } = parseProfileForm(await client2.fetchHtml("/members/profile/"));
|
|
39057
|
+
const { fields, checkboxes, checkboxValues } = parseProfileForm(await client2.fetchHtml("/members/profile/"));
|
|
38817
39058
|
const nextChecks = { ...checkboxes };
|
|
38818
39059
|
for (const [key, fieldName] of Object.entries(OPTIN_FIELDS)) {
|
|
38819
39060
|
const want = desired[key];
|
|
@@ -38825,14 +39066,10 @@ function registerWriteTools(server, client2) {
|
|
|
38825
39066
|
params.set(name, "");
|
|
38826
39067
|
continue;
|
|
38827
39068
|
}
|
|
38828
|
-
if (name === "DidChangePassword") {
|
|
38829
|
-
params.set(name, "0");
|
|
38830
|
-
continue;
|
|
38831
|
-
}
|
|
38832
39069
|
params.set(name, value);
|
|
38833
39070
|
}
|
|
38834
39071
|
for (const [name, on] of Object.entries(nextChecks)) {
|
|
38835
|
-
if (on) params.set(name, "
|
|
39072
|
+
if (on) params.set(name, checkboxValues[name] ?? "Y");
|
|
38836
39073
|
}
|
|
38837
39074
|
const resultingOptIns = {
|
|
38838
39075
|
OptInNews: nextChecks["OptInNews"] ?? false,
|
|
@@ -38854,6 +39091,9 @@ var tools = [
|
|
|
38854
39091
|
(s) => registerStudentTools(s, client),
|
|
38855
39092
|
(s) => registerPortfolioTools(s, client),
|
|
38856
39093
|
(s) => registerFanTools(s, client),
|
|
39094
|
+
(s) => registerFeedbackTools(s, client),
|
|
39095
|
+
(s) => registerAccountTools(s, client),
|
|
39096
|
+
(s) => registerDownloadTools(s, client),
|
|
38857
39097
|
(s) => registerWriteTools(s, client)
|
|
38858
39098
|
];
|
|
38859
39099
|
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,87 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { textResult, toolAnnotations, schemaConfirm, expandPath, messageOf } from '@chrischall/mcp-utils';
|
|
5
|
+
import { parsePortfolio, parseArtwork, artworkImageUrl } from '../parse.js';
|
|
6
|
+
const NumericId = z.string().regex(/^\d+$/, 'must be a numeric id');
|
|
7
|
+
/** "Grade 6" / "grade 6" / "6" → "6"; "Grade K" → "k". */
|
|
8
|
+
function normalizeGrade(g) {
|
|
9
|
+
return (g ?? '').replace(/grade/i, '').trim().toLowerCase();
|
|
10
|
+
}
|
|
11
|
+
export function registerDownloadTools(server, client) {
|
|
12
|
+
server.registerTool('artsonia_download_artwork', {
|
|
13
|
+
title: "Download a student's artwork images",
|
|
14
|
+
description: "Download full-resolution images of a student's artwork to a local folder. Optionally filter by class/project name (substring), by grade, and/or keep only the most-recent N (Artsonia exposes no real dates, but the portfolio is reliably newest-first). Without confirm:true this is a DRY RUN that lists what WOULD be downloaded and writes nothing. Note: project/grade filters require fetching each artwork's detail page (slower).",
|
|
15
|
+
annotations: toolAnnotations({ title: "Download a student's artwork images", readOnly: false, openWorld: true }),
|
|
16
|
+
inputSchema: {
|
|
17
|
+
artist_id: NumericId.describe('Student artist_id (from artsonia_list_students).'),
|
|
18
|
+
dest: z.string().min(1).describe('Local destination folder (a leading ~ is expanded). Created if missing.'),
|
|
19
|
+
project: z.string().min(1).optional().describe('Only artworks whose school-project/class name contains this (case-insensitive).'),
|
|
20
|
+
grade: z.string().min(1).optional().describe('Only artworks created in this grade, e.g. "6" or "Grade 6".'),
|
|
21
|
+
limit: z.number().int().positive().optional().describe('Keep only the N most recent matching artworks (portfolio is newest-first).'),
|
|
22
|
+
resolution: z.enum(['full', 'xlarge', 'large', 'medium', 'small']).default('full').describe('Image resolution. "full" is the original (~0.7 MB each).'),
|
|
23
|
+
confirm: schemaConfirm,
|
|
24
|
+
},
|
|
25
|
+
}, async ({ artist_id, dest, project, grade, limit, resolution, confirm }) => {
|
|
26
|
+
// 1. Portfolio → artwork ids, newest-first.
|
|
27
|
+
const tiles = parsePortfolio(await client.fetchHtml(`/artists/portfolio.asp?id=${artist_id}`));
|
|
28
|
+
let items = tiles.map((t) => ({ artwork_id: t.artwork_id }));
|
|
29
|
+
// 2. project/grade filters need each artwork's detail page.
|
|
30
|
+
if (project !== undefined || grade !== undefined) {
|
|
31
|
+
const wantGrade = normalizeGrade(grade);
|
|
32
|
+
const matched = [];
|
|
33
|
+
for (const t of tiles) {
|
|
34
|
+
const d = parseArtwork(await client.fetchHtml(`/museum/art.asp?id=${t.artwork_id}`));
|
|
35
|
+
const okProject = project === undefined || (d.project ?? '').toLowerCase().includes(project.toLowerCase());
|
|
36
|
+
const okGrade = grade === undefined || normalizeGrade(d.grade) === wantGrade;
|
|
37
|
+
if (okProject && okGrade)
|
|
38
|
+
matched.push({ artwork_id: t.artwork_id, title: d.title, project: d.project, grade: d.grade });
|
|
39
|
+
}
|
|
40
|
+
items = matched;
|
|
41
|
+
}
|
|
42
|
+
// 3. Most-recent-N.
|
|
43
|
+
if (limit !== undefined)
|
|
44
|
+
items = items.slice(0, limit);
|
|
45
|
+
const destDir = expandPath(dest);
|
|
46
|
+
// 4. Dry run.
|
|
47
|
+
if (confirm !== true) {
|
|
48
|
+
return textResult({
|
|
49
|
+
preview: true,
|
|
50
|
+
action: 'download_artwork',
|
|
51
|
+
note: `DRY RUN — would download ${items.length} image(s) at "${resolution}" resolution to ${destDir}. Re-run with confirm: true to download.`,
|
|
52
|
+
count: items.length,
|
|
53
|
+
dest: destDir,
|
|
54
|
+
resolution,
|
|
55
|
+
artworks: items.slice(0, 100),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
// 5. Download.
|
|
59
|
+
await mkdir(destDir, { recursive: true });
|
|
60
|
+
const downloaded = [];
|
|
61
|
+
const failed = [];
|
|
62
|
+
for (const it of items) {
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch(artworkImageUrl(it.artwork_id, resolution));
|
|
65
|
+
if (!res.ok) {
|
|
66
|
+
failed.push({ artwork_id: it.artwork_id, reason: `HTTP ${res.status}` });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
70
|
+
const file = join(destDir, `${it.artwork_id}.jpg`);
|
|
71
|
+
await writeFile(file, buf);
|
|
72
|
+
downloaded.push({ artwork_id: it.artwork_id, file, bytes: buf.length });
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
failed.push({ artwork_id: it.artwork_id, reason: messageOf(e) });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return textResult({
|
|
79
|
+
downloaded_count: downloaded.length,
|
|
80
|
+
failed_count: failed.length,
|
|
81
|
+
dest: destDir,
|
|
82
|
+
resolution,
|
|
83
|
+
downloaded,
|
|
84
|
+
...(failed.length ? { failed } : {}),
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -18,6 +18,7 @@ export function parseProfileForm(html) {
|
|
|
18
18
|
throw new Error('Could not find the Artsonia profile form (#TheForm).');
|
|
19
19
|
const fields = {};
|
|
20
20
|
const checkboxes = {};
|
|
21
|
+
const checkboxValues = {};
|
|
21
22
|
for (const el of form.querySelectorAll('input, select')) {
|
|
22
23
|
const name = el.getAttribute('name');
|
|
23
24
|
if (!name)
|
|
@@ -25,6 +26,9 @@ export function parseProfileForm(html) {
|
|
|
25
26
|
const type = (el.getAttribute('type') ?? el.tagName.toLowerCase());
|
|
26
27
|
if (type === 'checkbox') {
|
|
27
28
|
checkboxes[name] = el.hasAttribute('checked');
|
|
29
|
+
// Artsonia opt-in checkboxes submit value="Y" when checked — a literal "on"
|
|
30
|
+
// is silently ignored by the server (the save 302s but persists nothing).
|
|
31
|
+
checkboxValues[name] = el.getAttribute('value') ?? 'Y';
|
|
28
32
|
}
|
|
29
33
|
else if (el.tagName.toLowerCase() === 'select') {
|
|
30
34
|
const sel = el.querySelector('option[selected]') ?? el.querySelector('option');
|
|
@@ -34,7 +38,7 @@ export function parseProfileForm(html) {
|
|
|
34
38
|
fields[name] = el.getAttribute('value') ?? '';
|
|
35
39
|
}
|
|
36
40
|
}
|
|
37
|
-
return { fields, checkboxes };
|
|
41
|
+
return { fields, checkboxes, checkboxValues };
|
|
38
42
|
}
|
|
39
43
|
export function registerWriteTools(server, client) {
|
|
40
44
|
server.registerTool('artsonia_post_comment', {
|
|
@@ -101,7 +105,7 @@ export function registerWriteTools(server, client) {
|
|
|
101
105
|
if (news === undefined && artist_activity === undefined && promos === undefined) {
|
|
102
106
|
return textResult({ error: 'Specify at least one of news / artist_activity / promos.' });
|
|
103
107
|
}
|
|
104
|
-
const { fields, checkboxes } = parseProfileForm(await client.fetchHtml('/members/profile/'));
|
|
108
|
+
const { fields, checkboxes, checkboxValues } = parseProfileForm(await client.fetchHtml('/members/profile/'));
|
|
105
109
|
const nextChecks = { ...checkboxes };
|
|
106
110
|
for (const [key, fieldName] of Object.entries(OPTIN_FIELDS)) {
|
|
107
111
|
const want = desired[key];
|
|
@@ -109,20 +113,21 @@ export function registerWriteTools(server, client) {
|
|
|
109
113
|
nextChecks[fieldName] = want;
|
|
110
114
|
}
|
|
111
115
|
const params = new URLSearchParams();
|
|
116
|
+
// Re-send every non-checkbox field verbatim (preserving the form's own
|
|
117
|
+
// DidChangePassword="N" so the server doesn't attempt a password change),
|
|
118
|
+
// except the password fields, which are always blanked.
|
|
112
119
|
for (const [name, value] of Object.entries(fields)) {
|
|
113
120
|
if (PASSWORD_FIELDS.has(name)) {
|
|
114
121
|
params.set(name, '');
|
|
115
122
|
continue;
|
|
116
123
|
}
|
|
117
|
-
if (name === 'DidChangePassword') {
|
|
118
|
-
params.set(name, '0');
|
|
119
|
-
continue;
|
|
120
|
-
}
|
|
121
124
|
params.set(name, value);
|
|
122
125
|
}
|
|
126
|
+
// Checked opt-ins are submitted with the checkbox's real value ("Y"); unchecked
|
|
127
|
+
// boxes are omitted, exactly as the browser form does.
|
|
123
128
|
for (const [name, on] of Object.entries(nextChecks)) {
|
|
124
129
|
if (on)
|
|
125
|
-
params.set(name, '
|
|
130
|
+
params.set(name, checkboxValues[name] ?? 'Y');
|
|
126
131
|
}
|
|
127
132
|
const resultingOptIns = {
|
|
128
133
|
OptInNews: nextChecks['OptInNews'] ?? false,
|
|
@@ -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.3.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,16 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "artsonia-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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>",
|
|
7
|
-
"repository": {
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/chrischall/artsonia-mcp.git"
|
|
10
|
+
},
|
|
8
11
|
"license": "MIT",
|
|
9
|
-
"keywords": [
|
|
12
|
+
"keywords": [
|
|
13
|
+
"mcp",
|
|
14
|
+
"model-context-protocol",
|
|
15
|
+
"claude",
|
|
16
|
+
"ai",
|
|
17
|
+
"artsonia",
|
|
18
|
+
"student-art",
|
|
19
|
+
"portfolio",
|
|
20
|
+
"parent"
|
|
21
|
+
],
|
|
10
22
|
"type": "module",
|
|
11
|
-
"engines": {
|
|
12
|
-
|
|
13
|
-
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18.14"
|
|
25
|
+
},
|
|
26
|
+
"bin": {
|
|
27
|
+
"artsonia-mcp": "dist/index.js"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
".claude-plugin",
|
|
32
|
+
"skills",
|
|
33
|
+
".mcp.json",
|
|
34
|
+
"server.json",
|
|
35
|
+
"manifest.json"
|
|
36
|
+
],
|
|
14
37
|
"scripts": {
|
|
15
38
|
"build": "tsc && npm run bundle",
|
|
16
39
|
"bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --external:dotenv --external:@fetchproxy/server --outfile=dist/bundle.js",
|
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.3.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "artsonia-mcp",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.3.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
|