artsonia-mcp 0.2.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.
@@ -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.2.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.2.0",
18
+ "version": "0.3.0",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "artsonia-mcp",
3
3
  "displayName": "Artsonia",
4
- "version": "0.2.0",
4
+ "version": "0.3.0",
5
5
  "description": "MCP server for Artsonia — natural-language access to student-art portfolios, comments, and fans",
6
6
  "author": {
7
7
  "name": "Chris Hall",
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((resolve) => setTimeout(resolve, pollInterval));
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((resolve, reject) => {
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
- resolve(parseResult.data);
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((resolve, reject) => {
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(resolve, interval);
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 = resolve.call(this, root, ref);
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 resolve(root, ref) {
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 resolve(baseURI, relativeURI, options) {
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((resolve) => setTimeout(resolve, pollInterval));
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((resolve) => {
32032
+ return new Promise((resolve2) => {
32033
32033
  const json2 = serializeMessage(message);
32034
32034
  if (this._stdout.write(json2)) {
32035
- resolve();
32035
+ resolve2();
32036
32036
  } else {
32037
- this._stdout.once("drain", resolve);
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.2.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
- constructor() {
32270
+ starting = null;
32271
+ buildOpts() {
32261
32272
  const portEnv = readEnvVar("ARTSONIA_WS_PORT");
32262
- const opts = {
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
- await this.inner.listen();
32274
- this.started = true;
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: join(__dirname, "..", ".env"), override: false });
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 NumericId2 = external_exports.string().regex(/^\d+$/, "must be a numeric id");
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,
@@ -38749,8 +38988,8 @@ function registerWriteTools(server, client2) {
38749
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.",
38750
38989
  annotations: toolAnnotations({ title: "Post a comment on an artwork", readOnly: false, openWorld: true }),
38751
38990
  inputSchema: {
38752
- artist_id: NumericId2.describe("Student artist_id (from artsonia_list_students)."),
38753
- artwork_id: NumericId2.describe("Artwork id (from artsonia_get_portfolio)."),
38991
+ artist_id: NumericId4.describe("Student artist_id (from artsonia_list_students)."),
38992
+ artwork_id: NumericId4.describe("Artwork id (from artsonia_get_portfolio)."),
38754
38993
  comment: external_exports.string().min(1).describe("The comment text to post."),
38755
38994
  confirm: schemaConfirm
38756
38995
  }
@@ -38770,11 +39009,11 @@ function registerWriteTools(server, client2) {
38770
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).",
38771
39010
  annotations: toolAnnotations({ title: "Invite a fan", readOnly: false, openWorld: true }),
38772
39011
  inputSchema: {
38773
- artist_id: NumericId2.describe("Student artist_id (from artsonia_list_students)."),
39012
+ artist_id: NumericId4.describe("Student artist_id (from artsonia_list_students)."),
38774
39013
  first_name: external_exports.string().min(1).describe("Fan's first name."),
38775
39014
  last_name: external_exports.string().min(1).describe("Fan's last name."),
38776
39015
  email: external_exports.string().email().describe("Fan's email address (they receive an invite)."),
38777
- relationship_id: NumericId2.describe("Relationship code (RelationshipID select value from the Add Fans form)."),
39016
+ relationship_id: NumericId4.describe("Relationship code (RelationshipID select value from the Add Fans form)."),
38778
39017
  is_parent: external_exports.boolean().default(false).describe("Whether this fan is also a parent/guardian."),
38779
39018
  confirm: schemaConfirm
38780
39019
  }
@@ -38852,6 +39091,9 @@ var tools = [
38852
39091
  (s) => registerStudentTools(s, client),
38853
39092
  (s) => registerPortfolioTools(s, client),
38854
39093
  (s) => registerFanTools(s, client),
39094
+ (s) => registerFeedbackTools(s, client),
39095
+ (s) => registerAccountTools(s, client),
39096
+ (s) => registerDownloadTools(s, client),
38855
39097
  (s) => registerWriteTools(s, client)
38856
39098
  ];
38857
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
+ }
@@ -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
- constructor() {
12
+ starting = null;
13
+ buildOpts() {
14
14
  const portEnv = readEnvVar('ARTSONIA_WS_PORT');
15
- const opts = {
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
- await this.inner.listen();
28
- this.started = true;
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();
@@ -1,3 +1,3 @@
1
1
  // Single source of truth for the server version. The marker is what
2
2
  // release-please bumps; versionSyncTest asserts it equals package.json.
3
- export const VERSION = '0.2.0'; // x-release-please-version
3
+ export const VERSION = '0.3.0'; // x-release-please-version
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.2.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,6 +1,6 @@
1
1
  {
2
2
  "name": "artsonia-mcp",
3
- "version": "0.2.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>",
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.2.0",
9
+ "version": "0.3.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "artsonia-mcp",
14
- "version": "0.2.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