artsonia-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,73 @@
1
+ import { readEnvVar, McpToolError } from '@chrischall/mcp-utils';
2
+ import { CookieJar } from './cookies.js';
3
+ const LOGIN_PATH = '/members/login.asp';
4
+ const TARGET_URL = '/members/';
5
+ // Owns the username/password login and the resulting cookie session. Deferred
6
+ // config: constructing with no creds is fine; the error surfaces at ensureLogin.
7
+ // Single-flight: concurrent ensureLogin() calls share one in-flight login.
8
+ export class AuthManager {
9
+ transport;
10
+ jar = new CookieJar();
11
+ username;
12
+ password;
13
+ configError;
14
+ loggedIn = false;
15
+ inflight = null;
16
+ constructor(transport, opts) {
17
+ this.transport = transport;
18
+ const username = opts.username ?? readEnvVar('ARTSONIA_USERNAME') ?? null;
19
+ const password = opts.password ?? readEnvVar('ARTSONIA_PASSWORD') ?? null;
20
+ this.username = username;
21
+ this.password = password;
22
+ this.configError = username && password ? null : new Error('ARTSONIA_USERNAME and ARTSONIA_PASSWORD environment variables are required');
23
+ }
24
+ cookieHeader() {
25
+ return this.jar.header();
26
+ }
27
+ hasSession() {
28
+ return this.loggedIn && this.jar.size > 0;
29
+ }
30
+ async ensureLogin() {
31
+ if (this.hasSession())
32
+ return;
33
+ await this.forceRelogin();
34
+ }
35
+ async forceRelogin() {
36
+ if (this.configError)
37
+ throw this.configError;
38
+ if (this.inflight)
39
+ return this.inflight;
40
+ this.inflight = this.doLogin().finally(() => { this.inflight = null; });
41
+ return this.inflight;
42
+ }
43
+ async doLogin() {
44
+ const body = new URLSearchParams({
45
+ Username: this.username,
46
+ Password: this.password,
47
+ TargetUrl: TARGET_URL,
48
+ Action: 'login',
49
+ }).toString();
50
+ const res = await this.transport.request({
51
+ method: 'POST',
52
+ path: LOGIN_PATH,
53
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: this.jar.header() },
54
+ body,
55
+ redirect: 'manual',
56
+ });
57
+ this.jar.setFromHeaders(res.setCookie);
58
+ const redirectedToDest = (res.status === 301 || res.status === 302) && !!res.location && !/login\.asp/i.test(res.location);
59
+ if (!redirectedToDest || this.jar.size === 0) {
60
+ throw new McpToolError('Artsonia login failed — check your ARTSONIA_USERNAME / ARTSONIA_PASSWORD credentials.', { hint: 'Verify the email/password are correct. Magic-link-only accounts are not supported by this server.' });
61
+ }
62
+ this.loggedIn = true;
63
+ }
64
+ /** Absorb refreshed cookies from a normal response (post-redirect Set-Cookie). */
65
+ absorb(setCookie) {
66
+ if (setCookie.length)
67
+ this.jar.setFromHeaders(setCookie);
68
+ }
69
+ /** Mark the session dead so the next ensureLogin re-authenticates. */
70
+ invalidate() {
71
+ this.loggedIn = false;
72
+ }
73
+ }
@@ -0,0 +1,74 @@
1
+ import { loadDotenvSafely, readEnvVar, McpToolError } from '@chrischall/mcp-utils';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { AuthManager } from './auth.js';
5
+ import { makeTransport } from './transport.js';
6
+ const LOGIN_RE = /\/members\/login\.asp/i;
7
+ const LOGIN_BODY_RE = /You need to log in|Parent \(or Fan\) Login/i;
8
+ function looksUnauthenticated(res) {
9
+ return LOGIN_RE.test(res.url) || LOGIN_BODY_RE.test(res.body) || (res.location ? LOGIN_RE.test(res.location) : false);
10
+ }
11
+ // Thin, tool-facing API over a transport + AuthManager. Reads go through
12
+ // fetchHtml(); writes through write(). Both ensure a live session and retry
13
+ // once across a re-login if the response looks unauthenticated.
14
+ export class ArtsoniaClient {
15
+ transport;
16
+ auth;
17
+ constructor(opts) {
18
+ this.transport = opts.transport;
19
+ this.auth = opts.auth;
20
+ }
21
+ async fetchHtml(path) {
22
+ return (await this.requestWithSession('GET', path)).body;
23
+ }
24
+ async write(path, body) {
25
+ return this.requestWithSession('POST', path, body);
26
+ }
27
+ async requestWithSession(method, path, body) {
28
+ await this.auth.ensureLogin();
29
+ let res = await this.send(method, path, body);
30
+ if (looksUnauthenticated(res)) {
31
+ this.auth.invalidate();
32
+ try {
33
+ await this.auth.forceRelogin();
34
+ }
35
+ catch {
36
+ throw new McpToolError('Artsonia session could not be re-established: login failed after session expired.', {
37
+ hint: 'Your ARTSONIA_USERNAME / ARTSONIA_PASSWORD may be wrong, or the session keeps expiring. Verify the credentials.',
38
+ });
39
+ }
40
+ res = await this.send(method, path, body);
41
+ if (looksUnauthenticated(res)) {
42
+ throw new McpToolError('Artsonia session could not be (re)established after re-login.', {
43
+ hint: 'Your ARTSONIA_USERNAME / ARTSONIA_PASSWORD may be wrong, or the session keeps expiring. Verify the credentials.',
44
+ });
45
+ }
46
+ }
47
+ this.auth.absorb(res.setCookie);
48
+ if (res.status >= 400) {
49
+ throw new McpToolError(`Artsonia request failed: ${method} ${path} -> HTTP ${res.status}`, {
50
+ hint: 'Retry; if it persists the page may have moved or requires a different account.',
51
+ });
52
+ }
53
+ return res;
54
+ }
55
+ send(method, path, body) {
56
+ const headers = { Cookie: this.auth.cookieHeader() };
57
+ if (method === 'POST')
58
+ headers['Content-Type'] = 'application/x-www-form-urlencoded';
59
+ return this.transport.request({ method, path, headers, body, redirect: method === 'POST' ? 'manual' : 'follow' });
60
+ }
61
+ }
62
+ // Module singleton, constructed in this module (not index.ts) so the
63
+ // deferred-config-error pattern holds: the server boots and answers tools/list
64
+ // even with no creds; the error surfaces on the first tool call.
65
+ const __dirname = dirname(fileURLToPath(import.meta.url));
66
+ await loadDotenvSafely({ path: join(__dirname, '..', '.env'), override: false });
67
+ const transport = await makeTransport();
68
+ export const client = new ArtsoniaClient({
69
+ transport,
70
+ auth: new AuthManager(transport, {
71
+ username: readEnvVar('ARTSONIA_USERNAME'),
72
+ password: readEnvVar('ARTSONIA_PASSWORD'),
73
+ }),
74
+ });
@@ -0,0 +1,33 @@
1
+ // Minimal cookie jar for Artsonia's session cookies. We only need name=value
2
+ // pairs for the Cookie request header — attributes (path/expires/HttpOnly) are
3
+ // parsed only to detect deletion (empty value + past expiry).
4
+ export class CookieJar {
5
+ jar = new Map();
6
+ setFromHeaders(setCookie) {
7
+ for (const line of setCookie) {
8
+ const trimmed = (line ?? '').trim();
9
+ if (!trimmed)
10
+ continue;
11
+ const [pair, ...attrs] = trimmed.split(';');
12
+ const eq = pair.indexOf('=');
13
+ if (eq < 0)
14
+ continue;
15
+ const name = pair.slice(0, eq).trim();
16
+ const value = pair.slice(eq + 1).trim();
17
+ if (!name)
18
+ continue;
19
+ const isDeletion = value === '' && attrs.some((a) => /expires=/i.test(a) && /1970/.test(a));
20
+ if (isDeletion) {
21
+ this.jar.delete(name);
22
+ continue;
23
+ }
24
+ this.jar.set(name, value);
25
+ }
26
+ }
27
+ header() {
28
+ return [...this.jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
29
+ }
30
+ get size() {
31
+ return this.jar.size;
32
+ }
33
+ }
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ import { runMcp } from '@chrischall/mcp-utils';
3
+ import { VERSION } from './version.js';
4
+ import { client } from './client.js';
5
+ import { registerHealthcheckTools } from './tools/healthcheck.js';
6
+ import { registerStudentTools } from './tools/students.js';
7
+ import { registerPortfolioTools } from './tools/portfolio.js';
8
+ import { registerFanTools } from './tools/fans.js';
9
+ import { registerWriteTools } from './tools/writes.js';
10
+ // The client is a module-level singleton (constructed in client.ts) so the
11
+ // deferred-config-error pattern holds: the server boots and answers the host's
12
+ // install-time tools/list probe even without ARTSONIA_USERNAME/PASSWORD — the
13
+ // config error only surfaces on the first tool call.
14
+ const tools = [
15
+ (s) => registerHealthcheckTools(s, client),
16
+ (s) => registerStudentTools(s, client),
17
+ (s) => registerPortfolioTools(s, client),
18
+ (s) => registerFanTools(s, client),
19
+ (s) => registerWriteTools(s, client),
20
+ ];
21
+ await runMcp({
22
+ name: 'artsonia-mcp',
23
+ version: VERSION,
24
+ banner: `[artsonia-mcp] v${VERSION} — parent/fan access to Artsonia via username/password login. This project was developed and is maintained by AI (Claude). Use at your own discretion.`,
25
+ tools,
26
+ });
@@ -0,0 +1,144 @@
1
+ import { parse, NodeType } from 'node-html-parser';
2
+ const firstIntIn = (s) => {
3
+ const m = (s ?? '').match(/(\d[\d,]*)/);
4
+ return m ? Number(m[1].replace(/,/g, '')) : null;
5
+ };
6
+ const text = (el) => (el ? el.text.replace(/\s+/g, ' ').trim() : '');
7
+ const attrId = (href, key) => {
8
+ const m = (href ?? '').match(new RegExp(`[?&](?:amp;)?${key}=(\\d+)`));
9
+ return m ? m[1] : null;
10
+ };
11
+ /** Returns the direct text-node content of an element (excludes child element text). */
12
+ const ownText = (el) => el.childNodes
13
+ .filter((n) => n.nodeType === NodeType.TEXT_NODE)
14
+ .map((n) => n.rawText)
15
+ .join('')
16
+ .replace(/\s+/g, ' ')
17
+ .trim();
18
+ export function parseStudents(html) {
19
+ const root = parse(html);
20
+ return root.querySelectorAll('.artist-card').map((card) => {
21
+ const link = card.querySelector('a[href*="portfolio.asp"]');
22
+ const href = link?.getAttribute('href');
23
+ const artist_id = attrId(href, 'id');
24
+ if (!artist_id)
25
+ return null;
26
+ // Name: first a.lightlink text
27
+ const name = text(card.querySelector('a.lightlink'));
28
+ // School/grade: child div whose text matches "Currently at"
29
+ const schoolDiv = card.querySelectorAll('div').find((d) => d.text.includes('Currently at')) ?? null;
30
+ const schoolText = text(schoolDiv);
31
+ const schoolMatch = schoolText.match(/Currently at (.+?) \(Grade/);
32
+ const gradeMatch = schoolText.match(/\(Grade\s+(\w+)\)/i);
33
+ const school = schoolMatch?.[1] ?? '';
34
+ const grade = gradeMatch?.[1] ?? null;
35
+ // Stats: each .stat text is "<n> <label>"
36
+ const statMap = {};
37
+ card.querySelectorAll('.stat').forEach((s) => {
38
+ const t = s.text.trim();
39
+ const m = t.match(/^(\d[\d,]*)\s+(\w+)/);
40
+ if (m)
41
+ statMap[m[2].toLowerCase()] = Number(m[1].replace(/,/g, ''));
42
+ });
43
+ return {
44
+ artist_id,
45
+ name,
46
+ school,
47
+ grade,
48
+ artwork_count: statMap['artworks'] ?? null,
49
+ fan_count: statMap['fans'] ?? null,
50
+ comment_count: statMap['comments'] ?? null,
51
+ feedback_count: statMap['feedback'] ?? null,
52
+ award_count: statMap['awards'] ?? null,
53
+ portfolio_path: href ?? `/artists/portfolio.asp?id=${artist_id}`,
54
+ };
55
+ }).filter((s) => s !== null);
56
+ }
57
+ export function parseNotifications(html) {
58
+ const root = parse(html);
59
+ // Count: from .textSubhead whose text contains "Notifications"
60
+ const subhead = root.querySelectorAll('.textSubhead').find((el) => el.text.includes('Notifications')) ?? null;
61
+ const count = firstIntIn(text(subhead).match(/\((\d+)\)/)?.[1]) ?? 0;
62
+ // Items: each div.notice
63
+ const items = root.querySelectorAll('div.notice').map((notice) => {
64
+ const anchor = notice.querySelector('a.lightlink');
65
+ const title = text(anchor);
66
+ const href = anchor?.getAttribute('href') ?? '';
67
+ // Body: full notice text minus the title text, trimmed
68
+ const fullText = notice.text.replace(/\s+/g, ' ').trim();
69
+ const body = fullText.replace(title, '').replace(/\s+/g, ' ').trim();
70
+ return { title, body, href };
71
+ });
72
+ return { count, items };
73
+ }
74
+ export function parsePortfolio(html) {
75
+ const root = parse(html);
76
+ return root.querySelectorAll('.grid-item').map((item) => {
77
+ const link = item.querySelector('a[href*="art.asp"]');
78
+ const artwork_id = attrId(link?.getAttribute('href'), 'id');
79
+ if (!artwork_id)
80
+ return null;
81
+ // Thumbnail is derived from artwork_id — tile uses CSS background, no usable img src
82
+ const thumbnail = `https://images.artsonia.com/art/small/${artwork_id}.jpg`;
83
+ // Private: tile contains a .private-art element
84
+ const is_private = item.querySelector('.private-art') !== null;
85
+ return { artwork_id, is_private, thumbnail };
86
+ }).filter((a) => a !== null);
87
+ }
88
+ export function parseArtwork(html) {
89
+ const root = parse(html);
90
+ // title + screen-name from <title>: `... "<Title>" by <ScreenName>`
91
+ const rawTitle = text(root.querySelector('title'));
92
+ const title = rawTitle.match(/"([^"]+)"/)?.[1] ?? '';
93
+ const artist_screen_name = rawTitle.match(/by\s+(\S+)\s*$/)?.[1] ?? '';
94
+ // views from .textNormal whose text matches "<n> artwork views"
95
+ let views = null;
96
+ for (const el of root.querySelectorAll('.textNormal')) {
97
+ const m = el.text.match(/(\d+)\s+artwork views?/);
98
+ if (m) {
99
+ views = Number(m[1]);
100
+ break;
101
+ }
102
+ }
103
+ // project from body text: `from school project "<Project>"`
104
+ const bodyText = root.querySelector('body')?.text ?? '';
105
+ const project = bodyText.match(/from school project "([^"]+)"/)?.[1] ?? '';
106
+ // comment entry link
107
+ const link = root.querySelector('a[href*="comments/enter.asp"]');
108
+ const href = link?.getAttribute('href');
109
+ const aId = attrId(href, 'artist');
110
+ const wId = attrId(href, 'art');
111
+ const comment_entry = aId && wId ? { artist_id: aId, artwork_id: wId } : null;
112
+ // NOTE: comment-item markup is UNVERIFIED — 0-comment accounts show no comment elements.
113
+ // Confirm against an artwork that has comments before trusting this.
114
+ // See docs/ARTSONIA-API.md.
115
+ // Using a reasonable selector that degrades to [] on 0-comment pages without throwing.
116
+ const comments = root.querySelectorAll('.comment').map((c) => ({
117
+ author: text(c.querySelector('.comment-author')),
118
+ text: text(c.querySelector('.comment-text')),
119
+ }));
120
+ return { title, artist_screen_name, views, project, comment_entry, comments };
121
+ }
122
+ export function parseFans(html) {
123
+ const root = parse(html);
124
+ return root.querySelectorAll('.fan-card').map((card) => {
125
+ // Name: first a.hiddenlink text
126
+ const nameEl = card.querySelector('a.hiddenlink');
127
+ const name = text(nameEl);
128
+ if (!name)
129
+ return null;
130
+ // Relationship: find a descendant <div> whose own direct text-node content
131
+ // is a non-empty string that is not the fan's name and looks like a relationship word.
132
+ // The real structure: <div>Father<b>Registered</b></div> — own-text is "Father".
133
+ let relationship = '';
134
+ for (const div of card.querySelectorAll('div')) {
135
+ const own = ownText(div);
136
+ // Must be non-empty, not blank, not equal to the name
137
+ if (own && own !== name) {
138
+ relationship = own;
139
+ break;
140
+ }
141
+ }
142
+ return { name, relationship };
143
+ }).filter((f) => f !== null);
144
+ }
@@ -0,0 +1,11 @@
1
+ import { z } from 'zod';
2
+ import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
3
+ import { parseFans } from '../parse.js';
4
+ export function registerFanTools(server, client) {
5
+ server.registerTool('artsonia_get_fans', {
6
+ title: "Get a student's fan club",
7
+ description: "List the fans (name + relationship) in a student's fan club. Pass the artist_id from artsonia_list_students.",
8
+ annotations: toolAnnotations({ title: "Get a student's fan club", openWorld: true }),
9
+ inputSchema: { artist_id: z.string().regex(/^\d+$/, 'must be a numeric id').describe('Student artist_id.') },
10
+ }, async ({ artist_id }) => textResult({ artist_id, fans: parseFans(await client.fetchHtml(`/members/fanclub/?artist=${artist_id}`)) }));
11
+ }
@@ -0,0 +1,35 @@
1
+ import { textResult, toolAnnotations, readEnvVar, messageOf } from '@chrischall/mcp-utils';
2
+ import { parseStudents } from '../parse.js';
3
+ export function registerHealthcheckTools(server, client) {
4
+ server.registerTool('artsonia_healthcheck', {
5
+ title: 'Verify Artsonia auth + connectivity',
6
+ description: 'Confirm credentials are configured, log in, fetch the dashboard, and report {authenticated, transport, student_count} with a plain-English hint distinguishing "no creds" vs "bad creds" vs "site error". Read-only.',
7
+ annotations: toolAnnotations({ title: 'Verify Artsonia auth + connectivity', readOnly: true, idempotent: true, openWorld: true }),
8
+ inputSchema: {},
9
+ }, async () => {
10
+ const transport = readEnvVar('ARTSONIA_TRANSPORT') ?? 'fetch';
11
+ try {
12
+ const students = parseStudents(await client.fetchHtml('/members/'));
13
+ return textResult({
14
+ ok: true,
15
+ authenticated: true,
16
+ transport,
17
+ student_count: students.length,
18
+ hint: students.length > 0 ? 'Logged in; dashboard parsed successfully.' : 'Logged in, but no students parsed — the dashboard markup may have changed.',
19
+ });
20
+ }
21
+ catch (e) {
22
+ const msg = messageOf(e);
23
+ const noCreds = /environment variables are required/.test(msg);
24
+ return textResult({
25
+ ok: false,
26
+ authenticated: false,
27
+ transport,
28
+ error: msg,
29
+ hint: noCreds
30
+ ? 'Set ARTSONIA_USERNAME and ARTSONIA_PASSWORD (in .env or the MCP host env), then retry.'
31
+ : 'Login or fetch failed — check that your credentials are correct and the account is a parent/fan account (magic-link-only accounts are unsupported).',
32
+ });
33
+ }
34
+ });
35
+ }
@@ -0,0 +1,27 @@
1
+ import { z } from 'zod';
2
+ import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
3
+ import { parsePortfolio, parseArtwork } from '../parse.js';
4
+ const NumericId = z.string().regex(/^\d+$/, 'must be a numeric id');
5
+ export function registerPortfolioTools(server, client) {
6
+ server.registerTool('artsonia_get_portfolio', {
7
+ title: "Get a student's portfolio",
8
+ description: "List a student's artworks (artwork_id, is_private flag, thumbnail). Pass the artist_id from artsonia_list_students.",
9
+ annotations: toolAnnotations({ title: "Get a student's portfolio", openWorld: true }),
10
+ inputSchema: { artist_id: NumericId.describe('Student artist_id (from artsonia_list_students).') },
11
+ }, async ({ artist_id }) => textResult({ artist_id, artworks: parsePortfolio(await client.fetchHtml(`/artists/portfolio.asp?id=${artist_id}`)) }));
12
+ server.registerTool('artsonia_get_artwork', {
13
+ title: 'Get artwork detail',
14
+ description: 'Get one artwork: title, artist screen-name, view count, project (assignment name), and the comments on it. Pass an artwork_id from a portfolio.',
15
+ annotations: toolAnnotations({ title: 'Get artwork detail', openWorld: true }),
16
+ inputSchema: { artwork_id: NumericId.describe('Artwork id (from artsonia_get_portfolio).') },
17
+ }, async ({ artwork_id }) => textResult(parseArtwork(await client.fetchHtml(`/museum/art.asp?id=${artwork_id}`))));
18
+ server.registerTool('artsonia_list_comments', {
19
+ title: 'List comments on an artwork',
20
+ description: 'List the comments on a given artwork (author + text). Pass an artwork_id from a portfolio.',
21
+ annotations: toolAnnotations({ title: 'List comments on an artwork', openWorld: true }),
22
+ inputSchema: { artwork_id: NumericId.describe('Artwork id (from artsonia_get_portfolio).') },
23
+ }, async ({ artwork_id }) => {
24
+ const detail = parseArtwork(await client.fetchHtml(`/museum/art.asp?id=${artwork_id}`));
25
+ return textResult({ artwork_id, comments: detail.comments });
26
+ });
27
+ }
@@ -0,0 +1,14 @@
1
+ import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
2
+ import { parseStudents, parseNotifications } from '../parse.js';
3
+ export function registerStudentTools(server, client) {
4
+ server.registerTool('artsonia_list_students', {
5
+ title: 'List followed students',
6
+ description: 'List the student(s) on your Artsonia parent/fan account with their artist_id, name, school, grade, and artwork/fan counts. The artist_id is the selector used by the portfolio, comments, and fan tools.',
7
+ annotations: toolAnnotations({ title: 'List followed students', openWorld: true }),
8
+ }, async () => textResult({ students: parseStudents(await client.fetchHtml('/members/')) }));
9
+ server.registerTool('artsonia_get_activity', {
10
+ title: 'Get account notifications',
11
+ description: 'Return the notification/activity feed on the parent dashboard (e.g. new teacher feedback, fan-club prompts), with a count and the list of notices.',
12
+ annotations: toolAnnotations({ title: 'Get account notifications', openWorld: true }),
13
+ }, async () => textResult(parseNotifications(await client.fetchHtml('/members/'))));
14
+ }
@@ -0,0 +1,138 @@
1
+ import { z } from 'zod';
2
+ import { parse } from 'node-html-parser';
3
+ import { textResult, toolAnnotations, schemaConfirm } from '@chrischall/mcp-utils';
4
+ const NumericId = z.string().regex(/^\d+$/, 'must be a numeric id');
5
+ function previewResult(action, wouldSend, caveat) {
6
+ return textResult({
7
+ preview: true,
8
+ action,
9
+ note: `DRY RUN — nothing was sent. Re-run with confirm: true to perform this write.${caveat ? ` ${caveat}` : ''}`,
10
+ wouldSend,
11
+ });
12
+ }
13
+ const OPTIN_FIELDS = { news: 'OptInNews', artist_activity: 'OptInArtistActivity', promos: 'OptInPromos' };
14
+ const PASSWORD_FIELDS = new Set(['OldPassword', 'NewPassword', 'NewPassword2']);
15
+ export function parseProfileForm(html) {
16
+ const form = parse(html).querySelector('#TheForm');
17
+ if (!form)
18
+ throw new Error('Could not find the Artsonia profile form (#TheForm).');
19
+ const fields = {};
20
+ const checkboxes = {};
21
+ for (const el of form.querySelectorAll('input, select')) {
22
+ const name = el.getAttribute('name');
23
+ if (!name)
24
+ continue;
25
+ const type = (el.getAttribute('type') ?? el.tagName.toLowerCase());
26
+ if (type === 'checkbox') {
27
+ checkboxes[name] = el.hasAttribute('checked');
28
+ }
29
+ else if (el.tagName.toLowerCase() === 'select') {
30
+ const sel = el.querySelector('option[selected]') ?? el.querySelector('option');
31
+ fields[name] = sel?.getAttribute('value') ?? '';
32
+ }
33
+ else {
34
+ fields[name] = el.getAttribute('value') ?? '';
35
+ }
36
+ }
37
+ return { fields, checkboxes };
38
+ }
39
+ export function registerWriteTools(server, client) {
40
+ server.registerTool('artsonia_post_comment', {
41
+ title: 'Post a comment on an artwork',
42
+ 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.",
43
+ annotations: toolAnnotations({ title: 'Post a comment on an artwork', readOnly: false, openWorld: true }),
44
+ inputSchema: {
45
+ artist_id: NumericId.describe('Student artist_id (from artsonia_list_students).'),
46
+ artwork_id: NumericId.describe('Artwork id (from artsonia_get_portfolio).'),
47
+ comment: z.string().min(1).describe('The comment text to post.'),
48
+ confirm: schemaConfirm,
49
+ },
50
+ }, async ({ artist_id, artwork_id, comment, confirm }) => {
51
+ const path = `/museum/enter.asp?artist=${artist_id}&art=${artwork_id}`;
52
+ if (confirm !== true)
53
+ return previewResult('post_comment', { path, Comment: comment });
54
+ const body = new URLSearchParams({ Comment: comment }).toString();
55
+ const res = await client.write(path, body);
56
+ return textResult({ posted: true, artist_id, artwork_id, status: res.status });
57
+ });
58
+ server.registerTool('artsonia_invite_fan', {
59
+ title: "Invite a fan to a student's fan club",
60
+ 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).",
61
+ annotations: toolAnnotations({ title: 'Invite a fan', readOnly: false, openWorld: true }),
62
+ inputSchema: {
63
+ artist_id: NumericId.describe('Student artist_id (from artsonia_list_students).'),
64
+ first_name: z.string().min(1).describe("Fan's first name."),
65
+ last_name: z.string().min(1).describe("Fan's last name."),
66
+ email: z.string().email().describe("Fan's email address (they receive an invite)."),
67
+ relationship_id: NumericId.describe('Relationship code (RelationshipID select value from the Add Fans form).'),
68
+ is_parent: z.boolean().default(false).describe('Whether this fan is also a parent/guardian.'),
69
+ confirm: schemaConfirm,
70
+ },
71
+ }, async ({ artist_id, first_name, last_name, email, relationship_id, is_parent, confirm }) => {
72
+ const path = `/members/fanclub/add.asp?artist=${artist_id}`;
73
+ const params = new URLSearchParams({
74
+ MemberType: 'fan',
75
+ RelationshipID: relationship_id,
76
+ FirstName: first_name,
77
+ LastName: last_name,
78
+ EmailAddress: email,
79
+ ArtistID: artist_id,
80
+ });
81
+ if (is_parent)
82
+ params.set('IsParent', 'on');
83
+ if (confirm !== true) {
84
+ return previewResult('invite_fan', { path, ...Object.fromEntries(params) }, 'Verify RelationshipID against the live Add Fans form; MemberType is assumed "fan".');
85
+ }
86
+ const res = await client.write(path, params.toString());
87
+ return textResult({ invited: true, artist_id, email, status: res.status });
88
+ });
89
+ server.registerTool('artsonia_set_notifications', {
90
+ title: 'Set notification preferences',
91
+ description: "Turn the account's email opt-ins on/off (news, artist activity, promos). Reads your profile, changes only the opt-in(s) you specify, and re-saves — leaving your name/email/password untouched. Without confirm:true this is a DRY RUN showing the resulting state.",
92
+ annotations: toolAnnotations({ title: 'Set notification preferences', readOnly: false, openWorld: true }),
93
+ inputSchema: {
94
+ news: z.boolean().optional().describe('OptInNews — general Artsonia news emails.'),
95
+ artist_activity: z.boolean().optional().describe('OptInArtistActivity — emails about your student(s) activity.'),
96
+ promos: z.boolean().optional().describe('OptInPromos — promotional/keepsake emails.'),
97
+ confirm: schemaConfirm,
98
+ },
99
+ }, async ({ news, artist_activity, promos, confirm }) => {
100
+ const desired = { news, artist_activity, promos };
101
+ if (news === undefined && artist_activity === undefined && promos === undefined) {
102
+ return textResult({ error: 'Specify at least one of news / artist_activity / promos.' });
103
+ }
104
+ const { fields, checkboxes } = parseProfileForm(await client.fetchHtml('/members/profile/'));
105
+ const nextChecks = { ...checkboxes };
106
+ for (const [key, fieldName] of Object.entries(OPTIN_FIELDS)) {
107
+ const want = desired[key];
108
+ if (want !== undefined)
109
+ nextChecks[fieldName] = want;
110
+ }
111
+ const params = new URLSearchParams();
112
+ for (const [name, value] of Object.entries(fields)) {
113
+ if (PASSWORD_FIELDS.has(name)) {
114
+ params.set(name, '');
115
+ continue;
116
+ }
117
+ if (name === 'DidChangePassword') {
118
+ params.set(name, '0');
119
+ continue;
120
+ }
121
+ params.set(name, value);
122
+ }
123
+ for (const [name, on] of Object.entries(nextChecks)) {
124
+ if (on)
125
+ params.set(name, 'on');
126
+ }
127
+ const resultingOptIns = {
128
+ OptInNews: nextChecks['OptInNews'] ?? false,
129
+ OptInArtistActivity: nextChecks['OptInArtistActivity'] ?? false,
130
+ OptInPromos: nextChecks['OptInPromos'] ?? false,
131
+ };
132
+ if (confirm !== true) {
133
+ return previewResult('set_notifications', { ...resultingOptIns }, 'Re-sends your full profile (name/email preserved, password blanked) to flip only the opt-in(s).');
134
+ }
135
+ const res = await client.write('/members/profile/default.asp', params.toString());
136
+ return textResult({ updated: true, optIns: resultingOptIns, status: res.status });
137
+ });
138
+ }
@@ -0,0 +1,30 @@
1
+ import { ARTSONIA_ORIGIN } from './transport.js';
2
+ const DEFAULT_TIMEOUT_MS = 30_000;
3
+ // Default transport: node fetch against the user's cookie session. The login
4
+ // POST passes redirect:'manual' so its 302's Set-Cookie is readable; reads use
5
+ // redirect:'follow' and detect login-redirects by the final URL/body.
6
+ export class FetchArtsoniaTransport {
7
+ async request(req) {
8
+ const url = req.path.startsWith('http') ? req.path : `${ARTSONIA_ORIGIN}${req.path}`;
9
+ const ac = new AbortController();
10
+ const timer = setTimeout(() => ac.abort(), DEFAULT_TIMEOUT_MS);
11
+ try {
12
+ const res = await fetch(url, {
13
+ method: req.method,
14
+ headers: req.headers,
15
+ body: req.body,
16
+ redirect: req.redirect ?? 'follow',
17
+ signal: ac.signal,
18
+ });
19
+ const body = await res.text();
20
+ const setCookie = typeof res.headers.getSetCookie === 'function'
21
+ ? res.headers.getSetCookie()
22
+ : (res.headers.get('set-cookie') ?? '').split(',').map((s) => s.trim()).filter(Boolean);
23
+ const location = res.headers.get('location') ?? undefined;
24
+ return { status: res.status, body, url: res.url || url, setCookie, location };
25
+ }
26
+ finally {
27
+ clearTimeout(timer);
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,36 @@
1
+ import { FetchproxyServer } from '@fetchproxy/server';
2
+ import { ARTSONIA_ORIGIN } from './transport.js';
3
+ import { readEnvVar } from '@chrischall/mcp-utils';
4
+ import { VERSION } from './version.js';
5
+ const DEFAULT_PORT = 37_149; // shared fleet port — do NOT change
6
+ // Optional fallback transport: route requests through the user's signed-in
7
+ // browser tab via @fetchproxy/server. Engaged only when ARTSONIA_TRANSPORT=
8
+ // fetchproxy. Cookies are carried by the browser, so the AuthManager's jar is
9
+ // unused in this mode; login is whatever the browser already holds.
10
+ export class FetchproxyArtsoniaTransport {
11
+ inner;
12
+ started = false;
13
+ constructor() {
14
+ const portEnv = readEnvVar('ARTSONIA_WS_PORT');
15
+ const opts = {
16
+ port: portEnv ? Number(portEnv) : DEFAULT_PORT,
17
+ serverName: 'artsonia-mcp',
18
+ version: VERSION,
19
+ domains: ['artsonia.com'],
20
+ capabilities: ['fetch'],
21
+ };
22
+ this.inner = new FetchproxyServer(opts);
23
+ }
24
+ async ensureStarted() {
25
+ if (this.started)
26
+ return;
27
+ await this.inner.listen();
28
+ this.started = true;
29
+ }
30
+ async request(req) {
31
+ await this.ensureStarted();
32
+ const url = req.path.startsWith('http') ? req.path : `${ARTSONIA_ORIGIN}${req.path}`;
33
+ const r = await this.inner.request(req.method, url, { headers: req.headers, body: req.body });
34
+ return { status: r.status, body: r.body, url: r.url, setCookie: [] };
35
+ }
36
+ }