mathter-mcp 0.0.0-stage → 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mathter (mathter.ca)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,196 @@
1
- # Temporary Holding Version
1
+ # mathter-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Ask your AI assistant for a math worksheet pack. Get a printable PDF on your computer.
4
+
5
+ > *"Make me a grade 4 times-tables pack for Friday."*
6
+ >
7
+ > → `mathter-grade-4-multiplication-2026-10-04-1532.pdf` in your Downloads folder, answer keys
8
+ > included. (Cover and lesson pages come with the Standard plan and up.)
9
+
10
+ This is a small [Model Context Protocol](https://modelcontextprotocol.io) server that connects
11
+ [Mathter](https://mathter.ca) to Claude Desktop, Claude Code, Cursor, or any other MCP client. It
12
+ does one thing: it turns a plain-English request into a real worksheet pack from your own Mathter
13
+ account, saved as a file you can open and print.
14
+
15
+ You need a Mathter account and an API key. Both are free to start: the free plan makes packs of
16
+ up to 2 worksheets with answer keys, and paid plans raise the size and add cover and lesson
17
+ pages.
18
+
19
+ ---
20
+
21
+ ## Setup (about two minutes)
22
+
23
+ ### 1. Get your key
24
+
25
+ Sign in at [mathter.ca/account](https://mathter.ca/account), find **API access**, and create a
26
+ key. It looks like `mk_live_…` and is shown **once** — copy it before you close the page. If you
27
+ lose it, revoke it and make another; that is normal and costs nothing.
28
+
29
+ ### 2. Tell your assistant about it
30
+
31
+ **Claude Desktop** — open Settings → Developer → Edit Config, and add the
32
+ `mathter` entry to the `mcpServers` section (or merge this block into your config file). If the
33
+ file already holds other settings such as `preferences`, `mcpServers` goes next to them at the top
34
+ level, **not inside them** — inside `preferences` it is silently ignored:
35
+
36
+ ```json
37
+ {
38
+ "mcpServers": {
39
+ "mathter": {
40
+ "command": "npx",
41
+ "args": ["-y", "mathter-mcp"],
42
+ "env": { "MATHTER_API_KEY": "mk_live_YOUR_KEY" }
43
+ }
44
+ }
45
+ }
46
+ ```
47
+
48
+ Then fully quit Claude Desktop (right-click its icon in the system tray and choose Quit — closing
49
+ the window leaves it running) and open it again. (The config file lives at
50
+ `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS and
51
+ `%APPDATA%\Claude\claude_desktop_config.json` on Windows, if you would rather edit it directly.)
52
+
53
+ **Claude Code** — one command:
54
+
55
+ ```bash
56
+ claude mcp add mathter --env MATHTER_API_KEY=mk_live_YOUR_KEY -- npx -y mathter-mcp
57
+ ```
58
+
59
+ **Cursor, Windsurf and friends** — same JSON block as Claude Desktop, in whatever file that
60
+ app uses for MCP servers. Some editors (Zed, for instance) wrap the entry differently — check their
61
+ MCP docs for the exact shape; the command, arguments and `MATHTER_API_KEY` are the same everywhere.
62
+ The server speaks stdio; there is nothing to host and no port to open.
63
+
64
+ You do not need to install anything first. `npx -y mathter-mcp` downloads it from npm on demand,
65
+ so the first start takes a little longer than later ones. You need Node 18 or newer.
66
+
67
+ ### 3. Ask for worksheets
68
+
69
+ > "What math skills can Mathter do for grade 3?"
70
+ > "Make a 5-page grade 3 subtraction pack called Warm-Ups, easy difficulty."
71
+ > "A blank multiplication chart for grade 4."
72
+ > "Ten worksheets of grade 6 fractions, hard, with worked solutions."
73
+
74
+ The assistant replies with the path to the PDF. Open it and print.
75
+
76
+ ---
77
+
78
+ ## What the assistant can do
79
+
80
+ ### `list_skills`
81
+
82
+ Asks mathter.ca which skills exist right now and which grades each one covers. Your assistant
83
+ should call this before generating, so it picks a real skill rather than guessing. No key is sent
84
+ — that list is public.
85
+
86
+ ### `generate_worksheet_pack`
87
+
88
+ Makes the pack. Only `skill` and `grade` are required; everything else has a sensible default.
89
+
90
+ | Option | What it does | Notes |
91
+ |---|---|---|
92
+ | `skill` | **Required.** Which skill to drill, e.g. `multiplication` | Must be one `list_skills` returns, and must be taught at that grade |
93
+ | `grade` | **Required.** 0 (kindergarten) to 12 | — |
94
+ | `theme` | One of ten colour palettes | Defaults to `teal`; every plan has all ten |
95
+ | `style` | `modern`, `classic`, `notebook`, `studio`, `ledger`, `blueprint` | Plan-gated: free has `modern`, Standard adds `classic`/`notebook`, Premium has all six |
96
+ | `difficulty` | `easy`, `medium`, `hard` | Default `medium` |
97
+ | `title` | Printed on the pack | Default "Math Lesson", trimmed at 80 characters |
98
+ | `worksheetCount` | How many sheets | Capped by plan: free 2, Standard 10, Premium 25. Left out, you get 10 or your cap, whichever is smaller |
99
+ | `problemsPerPage` | Questions per page | Default 12, capped by what fits the page |
100
+ | `includeAnswerKeys` | Answer keys | On by default |
101
+ | `includeCover` / `includeLesson` | Cover page, "how to" page | On by default, Standard and up |
102
+ | `workedSolutions` | Step-by-step solutions in the key | Where the skill has steps authored |
103
+ | `focus` / `focusOrder` | Single-number drill, e.g. the 7 times table | Addition, subtraction, multiplication, division only |
104
+ | `chart` | `{ "mode": "filled" }` or `"blank"` — a times-table chart | Multiplication only |
105
+ | `timed` | Mad-minute drill | The four fact skills only |
106
+ | `diagrams`, `graphBlank`, `unitSystem` | Geometry figures, blank grids (default; `graphBlank: false` for labelled), metric/imperial | Only where the skill uses them |
107
+ | `shopName` | Branding name on the pack | Premium only; otherwise quietly replaced |
108
+ | `seed` | Reproduce an identical pack | Default random |
109
+
110
+ Your logo comes from your Mathter account, not from here — upload it once at mathter.ca and every
111
+ pack carries it, however the pack was made.
112
+
113
+ ---
114
+
115
+ ## Configuration
116
+
117
+ | Variable | Required | Default |
118
+ |---|---|---|
119
+ | `MATHTER_API_KEY` | **yes** | — (the server refuses to start without it) |
120
+ | `MATHTER_BASE_URL` | no | `https://mathter.ca` |
121
+ | `MATHTER_OUT_DIR` | no | your Downloads folder, or the system temp folder if there isn't one |
122
+
123
+ Set `MATHTER_OUT_DIR` if you would rather packs landed in, say, a shared school folder:
124
+
125
+ ```json
126
+ "env": {
127
+ "MATHTER_API_KEY": "mk_live_YOUR_KEY",
128
+ "MATHTER_OUT_DIR": "/Users/you/Documents/Worksheets"
129
+ }
130
+ ```
131
+
132
+ The folder is created if it does not exist. Names never collide: a second pack of the same kind
133
+ in the same minute becomes `…-2.pdf`.
134
+
135
+ ---
136
+
137
+ ## Billing, in one paragraph
138
+
139
+ Packs made here are billed to your Mathter account exactly as if you had made them on the
140
+ website: same plan, same monthly allowance, same caps on pack size and styles. There is no
141
+ per-call charge and no separate API plan. Run out of credits and the assistant will tell you so
142
+ in those words. Plans are at [mathter.ca/pricing](https://mathter.ca/pricing).
143
+
144
+ ## Your key
145
+
146
+ Your key stays on your machine. This server sends it to one place — the `Authorization` header of
147
+ `POST https://mathter.ca/api/generate` — and nowhere else. It is never sent to the public skills
148
+ endpoint, never written to a file, and never included in anything the server prints or hands back
149
+ to the assistant: all output is filtered for key-shaped text first, including error messages that
150
+ came from the server. That matters because chat transcripts get pasted into emails and bug
151
+ reports.
152
+
153
+ If a key is ever exposed anyway, revoke it at [mathter.ca/account](https://mathter.ca/account);
154
+ revocation takes effect immediately. A key can spend your account's allowance; it cannot change
155
+ your branding, your password, or your billing, and it cannot create other keys.
156
+
157
+ ## When something goes wrong
158
+
159
+ The server answers in sentences, not status codes. The ones you may meet:
160
+
161
+ | What you see | What to do |
162
+ |---|---|
163
+ | "MATHTER_API_KEY is not set" | The key never reached the server — check the `env` block and restart the app |
164
+ | "Mathter did not accept the API key" | Wrong, mistyped, or revoked. Make a new one at mathter.ca/account |
165
+ | "Out of worksheet credits for the month" | The allowance resets next billing period, or upgrade |
166
+ | "That style is not available on this account's plan" | Drop the style, or upgrade |
167
+ | "This account is not allowed to generate packs" | An admin/support account, or a school licence that no longer covers you |
168
+ | "Mathter is rate-limiting this API key" | One pack at a time; wait the time it names. Your key is fine, nothing needs revoking |
169
+ | "Mathter is rate-limiting the public skill list" | Per-computer limit on the skill list, which never sends your key. Wait a minute |
170
+ | "The renderer is busy" | Backpressure, not breakage. Retry in half a minute; nothing was charged |
171
+ | "Mathter rejected that configuration" | Usually a skill that isn't taught at that grade — ask for the skill list again |
172
+
173
+ Still stuck? [mathter.ca/contact](https://mathter.ca/contact).
174
+
175
+ ---
176
+
177
+ ## For developers
178
+
179
+ A thin HTTP client over two endpoints, stdio transport. One direct runtime dependency —
180
+ [`@modelcontextprotocol/sdk`](https://www.npmjs.com/package/@modelcontextprotocol/sdk) — which
181
+ brings its own tree, so a real install is around 90 packages, the SDK's HTTP-transport
182
+ dependencies among them. No telemetry, no analytics, no network calls other than the two documented
183
+ above. `MATHTER_BASE_URL` points it at a dev instance.
184
+
185
+ ```bash
186
+ npm ci # a lockfile is committed; plain `npm install` may need --legacy-peer-deps
187
+ npm test # vitest, against a local stub HTTP server — never the real API
188
+ npm run typecheck
189
+ npm run build # tsc -> dist/
190
+ ```
191
+
192
+ The published tarball is `dist/`, this README and the licence; sources and tests stay in the
193
+ repository.
194
+
195
+ MIT licensed. Mathter is at [mathter.ca](https://mathter.ca); the API itself is documented at
196
+ [mathter.ca/api](https://mathter.ca/api).
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * mathter-mcp — a Model Context Protocol server for Mathter (https://mathter.ca).
4
+ *
5
+ * It is a thin HTTP client and nothing else. Two tools:
6
+ * - `list_skills` GET {baseUrl}/api/skills (public, no key sent)
7
+ * - `generate_worksheet_pack` POST {baseUrl}/api/generate (Authorization: Bearer mk_live_…)
8
+ *
9
+ * Design rules this file is held to, because it runs on a teacher's machine inside a chat
10
+ * transcript they may paste somewhere else:
11
+ *
12
+ * 1. The API key appears in exactly one place: the Authorization header of the generate call.
13
+ * Everything leaving this process passes one of exactly TWO redaction boundaries —
14
+ * `redactResult` for every tool result and `say` for every stderr line — rather than a
15
+ * `redact()` call per message, so a branch added later cannot forget. Server-supplied text
16
+ * is never trusted to be key-free; see `redact`'s comment.
17
+ * 2. Nothing is ever written to stdout. stdout is the MCP transport; a stray console.log there
18
+ * corrupts the protocol stream. Diagnostics go to stderr.
19
+ * 3. The generate tool returns a FILE PATH, not base64. The person asking for a worksheet pack
20
+ * wants something they can open and print.
21
+ * 4. The input schema below is read by a model to decide what to send, so it is documentation as
22
+ * much as validation. Every cap in it mirrors a line of the real route — see the citations.
23
+ * The one list it deliberately does NOT carry is the ~70 skill keys: those change, and a
24
+ * stale copy here would turn into unexplained `400 Invalid configuration.` responses. The
25
+ * model is pointed at `list_skills` instead.
26
+ */
27
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
28
+ import { type CallToolResult, type Tool } from "@modelcontextprotocol/sdk/types.js";
29
+ export interface MathterConfig {
30
+ apiKey: string;
31
+ /** No trailing slash. */
32
+ baseUrl: string;
33
+ outDir: string;
34
+ }
35
+ export declare const MISSING_KEY_MESSAGE: string;
36
+ /**
37
+ * Strip anything key-shaped out of a string before it leaves the process.
38
+ *
39
+ * Both halves matter. The exact-key pass covers our own interpolations; the pattern pass covers
40
+ * text we did not write — an error body from the server, a proxy's echo of the request headers, a
41
+ * captive-portal page. We do include the server's own error message in tool output because it is
42
+ * genuinely useful, and that is exactly the untrusted path, so it is filtered here.
43
+ */
44
+ export declare function redact(text: string, apiKey?: string): string;
45
+ /**
46
+ * Where packs land when MATHTER_OUT_DIR is unset: the OS downloads folder if there is one,
47
+ * otherwise the temp dir. Never the process working directory — for an MCP server launched by a
48
+ * desktop app that is some unrelated place the person will never find.
49
+ */
50
+ export declare function defaultOutDir(env?: NodeJS.ProcessEnv): string;
51
+ export declare function loadConfig(env?: NodeJS.ProcessEnv): MathterConfig;
52
+ export declare const TOOLS: Tool[];
53
+ export interface ErrorContext {
54
+ /** "key" = the authenticated generate endpoint; "public" = /api/skills, which sends no key. */
55
+ caller: "key" | "public";
56
+ /** The URL that was actually requested — quoted back when the base URL looks wrong. */
57
+ url: string;
58
+ }
59
+ /**
60
+ * Turn a non-2xx response into something the person reading the chat can act on. The status alone
61
+ * ("402") tells them nothing; "this account is out of worksheet credits for the month" tells them
62
+ * what to do next. The statuses and bodies below are the ones Mathter's API actually returns.
63
+ */
64
+ export declare function describeHttpError(status: number, body: {
65
+ error?: unknown;
66
+ code?: unknown;
67
+ } | null, retryAfter: string | null, ctx: ErrorContext): string;
68
+ type Args = Record<string, unknown>;
69
+ /**
70
+ * Build the POST body from the model's arguments — an allowlist, not a pass-through. Anything the
71
+ * route does not read is dropped here rather than sent and ignored, and anything the caller left
72
+ * out is omitted entirely so the server's own default applies.
73
+ */
74
+ export declare function buildGenerateBody(args: Args): Record<string, unknown>;
75
+ /** `mathter-grade-4-multiplication-2026-10-04-1532.pdf` — sortable, searchable, obviously ours. */
76
+ export declare function packFilename(skill: string, grade: number, now?: Date): string;
77
+ export declare function generateWorksheetPack(args: Args, cfg: MathterConfig): Promise<string>;
78
+ export declare function listSkills(cfg: MathterConfig): Promise<string>;
79
+ export declare function handleCallTool(name: string, args: Args, cfg: MathterConfig): Promise<CallToolResult>;
80
+ export declare function createMcpServer(cfg: MathterConfig): Server;
81
+ export interface MainIO {
82
+ stderr: (line: string) => void;
83
+ exit: (code: number) => void;
84
+ }
85
+ export declare function main(env?: NodeJS.ProcessEnv, io?: MainIO): Promise<void>;
86
+ /**
87
+ * Is this file the program being run, as opposed to a module someone imported?
88
+ *
89
+ * The naive comparison — `import.meta.url === pathToFileURL(process.argv[1]).href` — is WRONG
90
+ * for the way this package is actually installed. `npm`/`npx` put a `bin` into
91
+ * `node_modules/.bin` as a SYMLINK; Node resolves symlinks when it computes `import.meta.url`
92
+ * but leaves `process.argv[1]` as the symlink path it was given. The two never match, `main()`
93
+ * never runs, and `npx -y mathter-mcp` exits 0 having printed nothing — on macOS and Linux
94
+ * only, because npm writes a `.cmd` shim with the real path on Windows. Resolve the symlink
95
+ * first. See the child-process test, which runs the built file through a symlink exactly as
96
+ * npm would.
97
+ */
98
+ export declare function isDirectRun(metaUrl: string, argv1: string | undefined): boolean;
99
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,622 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * mathter-mcp — a Model Context Protocol server for Mathter (https://mathter.ca).
4
+ *
5
+ * It is a thin HTTP client and nothing else. Two tools:
6
+ * - `list_skills` GET {baseUrl}/api/skills (public, no key sent)
7
+ * - `generate_worksheet_pack` POST {baseUrl}/api/generate (Authorization: Bearer mk_live_…)
8
+ *
9
+ * Design rules this file is held to, because it runs on a teacher's machine inside a chat
10
+ * transcript they may paste somewhere else:
11
+ *
12
+ * 1. The API key appears in exactly one place: the Authorization header of the generate call.
13
+ * Everything leaving this process passes one of exactly TWO redaction boundaries —
14
+ * `redactResult` for every tool result and `say` for every stderr line — rather than a
15
+ * `redact()` call per message, so a branch added later cannot forget. Server-supplied text
16
+ * is never trusted to be key-free; see `redact`'s comment.
17
+ * 2. Nothing is ever written to stdout. stdout is the MCP transport; a stray console.log there
18
+ * corrupts the protocol stream. Diagnostics go to stderr.
19
+ * 3. The generate tool returns a FILE PATH, not base64. The person asking for a worksheet pack
20
+ * wants something they can open and print.
21
+ * 4. The input schema below is read by a model to decide what to send, so it is documentation as
22
+ * much as validation. Every cap in it mirrors a line of the real route — see the citations.
23
+ * The one list it deliberately does NOT carry is the ~70 skill keys: those change, and a
24
+ * stale copy here would turn into unexplained `400 Invalid configuration.` responses. The
25
+ * model is pointed at `list_skills` instead.
26
+ */
27
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
28
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
29
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
30
+ import { existsSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
31
+ import { homedir, tmpdir } from "node:os";
32
+ import { join } from "node:path";
33
+ import { pathToFileURL } from "node:url";
34
+ /** Kept in step with package.json by hand; it is only ever reported, never compared. */
35
+ const VERSION = "0.1.0";
36
+ const DEFAULT_BASE_URL = "https://mathter.ca";
37
+ /**
38
+ * A pack render drives headless Chrome on the server, so it is slow by nature — minutes, not
39
+ * seconds, for a large pack. Long enough not to abandon a pack that is genuinely coming, short
40
+ * enough that a dead connection surfaces as an error instead of a hung chat.
41
+ */
42
+ const REQUEST_TIMEOUT_MS = 180_000;
43
+ const REDACTED = "[redacted API key]";
44
+ export const MISSING_KEY_MESSAGE = "MATHTER_API_KEY is not set. mathter-mcp cannot do anything without it, so it is stopping now " +
45
+ "rather than failing later with a confusing 401.\n" +
46
+ "Create a key at https://mathter.ca/account (API access), then put it in this server's env, e.g.\n" +
47
+ ' "mathter": { "command": "npx", "args": ["-y", "mathter-mcp"], ' +
48
+ '"env": { "MATHTER_API_KEY": "<paste your key here>" } }\n' +
49
+ "Restart your MCP client after saving the config.";
50
+ /**
51
+ * Strip anything key-shaped out of a string before it leaves the process.
52
+ *
53
+ * Both halves matter. The exact-key pass covers our own interpolations; the pattern pass covers
54
+ * text we did not write — an error body from the server, a proxy's echo of the request headers, a
55
+ * captive-portal page. We do include the server's own error message in tool output because it is
56
+ * genuinely useful, and that is exactly the untrusted path, so it is filtered here.
57
+ */
58
+ export function redact(text, apiKey) {
59
+ let out = text;
60
+ if (apiKey && apiKey.length >= 8)
61
+ out = out.split(apiKey).join(REDACTED);
62
+ return out.replace(/\bmk_[a-z]+_[A-Za-z0-9_-]+/g, REDACTED);
63
+ }
64
+ /**
65
+ * Where packs land when MATHTER_OUT_DIR is unset: the OS downloads folder if there is one,
66
+ * otherwise the temp dir. Never the process working directory — for an MCP server launched by a
67
+ * desktop app that is some unrelated place the person will never find.
68
+ */
69
+ export function defaultOutDir(env = process.env) {
70
+ const candidates = [];
71
+ // Honoured on Linux desktops that have localised or relocated the folder.
72
+ if (env.XDG_DOWNLOAD_DIR)
73
+ candidates.push(env.XDG_DOWNLOAD_DIR);
74
+ const home = env.HOME || env.USERPROFILE || homedir();
75
+ if (home)
76
+ candidates.push(join(home, "Downloads"));
77
+ for (const dir of candidates) {
78
+ if (existsSync(dir))
79
+ return dir;
80
+ }
81
+ return tmpdir();
82
+ }
83
+ export function loadConfig(env = process.env) {
84
+ const apiKey = (env.MATHTER_API_KEY ?? "").trim();
85
+ if (!apiKey)
86
+ throw new Error(MISSING_KEY_MESSAGE);
87
+ const raw = (env.MATHTER_BASE_URL ?? DEFAULT_BASE_URL).trim();
88
+ let baseUrl;
89
+ try {
90
+ const parsed = new URL(raw);
91
+ baseUrl = parsed.origin + parsed.pathname.replace(/\/+$/, "");
92
+ }
93
+ catch {
94
+ throw new Error(`MATHTER_BASE_URL is not a valid URL: "${raw}". Leave it unset to use ${DEFAULT_BASE_URL}.`);
95
+ }
96
+ const outDir = (env.MATHTER_OUT_DIR ?? "").trim() || defaultOutDir(env);
97
+ return { apiKey, baseUrl, outDir };
98
+ }
99
+ // ---------------------------------------------------------------------------------------------
100
+ // Tool definitions
101
+ // ---------------------------------------------------------------------------------------------
102
+ // Every cap below mirrors what POST https://mathter.ca/api/generate actually enforces, read
103
+ // off the endpoint's own implementation and its API documentation on 2026-10-04. Re-check them
104
+ // against the live endpoint before changing any number here.
105
+ const GENERATE_SCHEMA = {
106
+ type: "object",
107
+ properties: {
108
+ skill: {
109
+ type: "string",
110
+ description: "Which math skill the pack drills, e.g. \"multiplication\". Call list_skills first and " +
111
+ "use one of the keys it returns: the set changes, and the skill must be one the chosen " +
112
+ "grade is taught. An unknown or out-of-grade skill is rejected with 400.",
113
+ },
114
+ grade: {
115
+ type: "integer",
116
+ minimum: 0,
117
+ maximum: 12,
118
+ description: "Grade level, 0 (kindergarten) to 12. Must be a grade the chosen skill teaches — " +
119
+ "list_skills returns the grades for each skill.",
120
+ },
121
+ theme: {
122
+ type: "string",
123
+ enum: ["teal", "tangerine", "ocean", "plum", "berry", "forest", "slate", "crimson", "grape", "cocoa"],
124
+ default: "teal",
125
+ description: "Colour palette for the pack. Available on every plan. Defaults to teal.",
126
+ },
127
+ style: {
128
+ type: "string",
129
+ enum: ["modern", "classic", "notebook", "studio", "ledger", "blueprint"],
130
+ description: "Typography/layout preset, default \"modern\". Plan-gated and rejected outright if the " +
131
+ "account cannot use it: free accounts have \"modern\" only, Standard adds \"classic\" " +
132
+ "and \"notebook\", Premium has all six. Omit it unless the person asked for a look.",
133
+ },
134
+ difficulty: {
135
+ type: "string",
136
+ enum: ["easy", "medium", "hard"],
137
+ description: "Difficulty band, default \"medium\".",
138
+ },
139
+ title: {
140
+ type: "string",
141
+ maxLength: 80,
142
+ description: "Title printed on the pack, default \"Math Lesson\". Truncated at 80 characters.",
143
+ },
144
+ worksheetCount: {
145
+ type: "integer",
146
+ minimum: 1,
147
+ description: "How many worksheets in the pack. Clamped to the account's plan — free 2, Standard 10, " +
148
+ "Premium 25 — so asking for more is harmless but gets fewer. Omitted, it is 10 or the " +
149
+ "plan's cap, whichever is smaller: a free account's default pack is 2 sheets.",
150
+ },
151
+ problemsPerPage: {
152
+ type: "integer",
153
+ minimum: 1,
154
+ description: "Questions per page, default 12. Clamped down to whatever actually fits one page for " +
155
+ "this skill, grade and layout, so a large number simply gives the densest legal page.",
156
+ },
157
+ includeAnswerKeys: {
158
+ type: "boolean",
159
+ description: "Print answer keys. Default true on every plan; send false to omit them.",
160
+ },
161
+ includeCover: {
162
+ type: "boolean",
163
+ description: "Print a cover page. Default true, but silently off for free accounts (Standard and up).",
164
+ },
165
+ includeLesson: {
166
+ type: "boolean",
167
+ description: "Print the \"how to\" lesson page. Default true, silently off for free accounts (Standard and up).",
168
+ },
169
+ workedSolutions: {
170
+ type: "boolean",
171
+ description: "Step-by-step worked solutions in the answer key. Only has an effect for skills that " +
172
+ "have steps authored; silently ignored elsewhere.",
173
+ },
174
+ diagrams: {
175
+ type: "boolean",
176
+ description: "Geometry skills only: labelled figures instead of text prompts. Ignored for other skills.",
177
+ },
178
+ graphBlank: {
179
+ type: "boolean",
180
+ description: "Coordinate grids are blank (unlabelled) by default so students set their own scale. Pass false for a labelled ±10 grid. Only meaningful where the skill prints a graph.",
181
+ },
182
+ unitSystem: {
183
+ type: "string",
184
+ enum: ["metric", "imperial"],
185
+ description: "Units for measurement questions, default metric. Mainly affects unit-conversion skills.",
186
+ },
187
+ focus: {
188
+ type: "integer",
189
+ minimum: 1,
190
+ maximum: 12,
191
+ description: "Single-number drill, e.g. 7 for the seven times table. Only applies to the four fact " +
192
+ "skills: addition, subtraction, multiplication, division.",
193
+ },
194
+ focusOrder: {
195
+ type: "string",
196
+ enum: ["sequential", "shuffled"],
197
+ description: "Order of a focus drill, default sequential. Only applies when focus is set.",
198
+ },
199
+ chart: {
200
+ type: "object",
201
+ properties: {
202
+ mode: { type: "string", enum: ["filled", "blank"] },
203
+ },
204
+ required: ["mode"],
205
+ additionalProperties: false,
206
+ description: "Multiplication only: print a times-table chart instead of worksheets. \"filled\" is a " +
207
+ "reference chart, \"blank\" is one to fill in.",
208
+ },
209
+ timed: {
210
+ type: "boolean",
211
+ description: "Mad-minute timed drill. Only applies to addition, subtraction, multiplication and division.",
212
+ },
213
+ shopName: {
214
+ type: "string",
215
+ maxLength: 60,
216
+ description: "Branding name printed on the pack. Premium only — on any other account it is silently " +
217
+ "replaced with \"Mathter.ca\" and the pack still generates. Members of a school " +
218
+ "organisation always get the organisation's own branding instead.",
219
+ },
220
+ seed: {
221
+ type: "integer",
222
+ description: "Fixes the random number generator, so the same seed reproduces the same pack. Default random.",
223
+ },
224
+ },
225
+ required: ["skill", "grade"],
226
+ additionalProperties: false,
227
+ };
228
+ export const TOOLS = [
229
+ {
230
+ name: "generate_worksheet_pack",
231
+ title: "Generate a Mathter worksheet pack",
232
+ description: "Generate a printable math worksheet pack as a PDF and save it to this computer, then " +
233
+ "return the file path. The pack is billed to the Mathter account that owns MATHTER_API_KEY, " +
234
+ "under that account's normal monthly allowance — the same as making it on mathter.ca. " +
235
+ "Call list_skills first to choose a valid skill for the grade. Logo and branding come from " +
236
+ "the account itself and cannot be set here.",
237
+ // `as const` above keeps the literal types (so a typo in an enum value is a compile error
238
+ // here rather than a silent runtime oddity); the SDK's Tool type wants a mutable JSON Schema
239
+ // object, hence the one cast.
240
+ inputSchema: GENERATE_SCHEMA,
241
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
242
+ },
243
+ {
244
+ name: "list_skills",
245
+ title: "List Mathter skills",
246
+ description: "List every math skill Mathter can generate right now and the grades each one covers. The " +
247
+ "live list, straight from mathter.ca — use it to pick the `skill` and `grade` for " +
248
+ "generate_worksheet_pack rather than guessing a key.",
249
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
250
+ annotations: { readOnlyHint: true, openWorldHint: true },
251
+ },
252
+ ];
253
+ /**
254
+ * Turn a non-2xx response into something the person reading the chat can act on. The status alone
255
+ * ("402") tells them nothing; "this account is out of worksheet credits for the month" tells them
256
+ * what to do next. The statuses and bodies below are the ones Mathter's API actually returns.
257
+ */
258
+ export function describeHttpError(status, body, retryAfter, ctx) {
259
+ const seconds = Number(retryAfter);
260
+ const wait = Number.isFinite(seconds) && seconds > 0 ? `${Math.ceil(seconds)} seconds` : null;
261
+ // Cloudflare's own 5xx range: the edge answered, the origin did not. Different advice from a
262
+ // 500, which means Mathter ran and failed.
263
+ if (status >= 520 && status <= 527) {
264
+ return (`Mathter's server could not be reached (HTTP ${status} from its edge network). That is an ` +
265
+ "outage or a network problem between the edge and the site, not something wrong with this " +
266
+ "request. Try again in a few minutes.");
267
+ }
268
+ switch (status) {
269
+ case 400:
270
+ return ("Mathter rejected that configuration. The usual cause is a skill key that does not exist, " +
271
+ "or a skill that is not taught at the grade asked for. Call list_skills and pick a skill " +
272
+ "whose grade list contains that grade, then try again.");
273
+ case 401:
274
+ return ("Mathter did not accept the API key. MATHTER_API_KEY is wrong, mistyped, or the key has " +
275
+ "been revoked. Create a fresh key at https://mathter.ca/account, put it in this server's " +
276
+ "env, and restart the MCP client — nothing else will work until then.");
277
+ case 402:
278
+ if (body?.code === "pro_style") {
279
+ return ("That style is not available on this account's plan. Free accounts have \"modern\" only; " +
280
+ "Standard adds \"classic\" and \"notebook\"; Premium has all six. Either leave the style " +
281
+ "out and try again, or upgrade at https://mathter.ca/pricing.");
282
+ }
283
+ return ("This account is out of worksheet credits for the month. The allowance resets at the start " +
284
+ "of the next billing period; to generate more now, upgrade at https://mathter.ca/pricing. " +
285
+ "Nothing was charged for this attempt.");
286
+ case 403:
287
+ return ("This account is not allowed to generate packs. Two kinds of account hit this: an admin/support " +
288
+ "account, which has no worksheet access at all, and a member of a school organisation " +
289
+ "whose licence no longer covers them. Use a key from an ordinary teacher account, or ask " +
290
+ "the school's Mathter admin to restore access.");
291
+ case 429:
292
+ // list_skills sends no key at all and is limited per IP, so blaming "this key" there is
293
+ // not just imprecise — it invites a teacher to revoke a perfectly good key over it.
294
+ return ctx.caller === "key"
295
+ ? "Mathter is rate-limiting this API key — too fast, too many requests. " +
296
+ (wait ? `Wait ${wait} and try again.` : "Wait a minute or two and try again.") +
297
+ " Generating one pack at a time avoids this. Your key is fine; nothing needs revoking."
298
+ : "Mathter is rate-limiting the public skill list, which is limited per computer (by IP) " +
299
+ "and never sees your API key. " +
300
+ (wait ? `Wait ${wait} and try again.` : "Wait a minute and try again.") +
301
+ " Your key is fine; nothing needs revoking.";
302
+ case 503:
303
+ return ("Mathter's worksheet renderer is busy and could not take this pack. " +
304
+ (wait ? `Retry in ${wait}.` : "Retry in about 30 seconds.") +
305
+ " Nothing was charged and nothing is broken — it is queue backpressure.");
306
+ case 404:
307
+ return (`Nothing is listening at ${ctx.url}. Mathter's own address has that endpoint, so the ` +
308
+ "usual cause is a wrong MATHTER_BASE_URL — unset it to use https://mathter.ca, or correct " +
309
+ "it if you meant to point at a different instance.");
310
+ case 502:
311
+ case 504:
312
+ return (`Mathter's server did not answer its front door (HTTP ${status}). That is an outage or a ` +
313
+ "network problem, not something wrong with this request. Try again in a few minutes.");
314
+ case 500:
315
+ return ("Mathter hit a server error. This is not your request's fault — the same call will " +
316
+ "probably work in a moment, so try again, and report it via https://mathter.ca/contact if " +
317
+ "it keeps happening.");
318
+ default:
319
+ return (`Mathter returned an unexpected HTTP ${status}. Try again in a moment; if it persists, ` +
320
+ "report it via https://mathter.ca/contact.");
321
+ }
322
+ }
323
+ async function readErrorBody(res) {
324
+ let text = "";
325
+ try {
326
+ text = await res.text();
327
+ }
328
+ catch {
329
+ return { parsed: null, text: "" };
330
+ }
331
+ try {
332
+ const parsed = JSON.parse(text);
333
+ return { parsed, text: typeof parsed.error === "string" ? parsed.error : text };
334
+ }
335
+ catch {
336
+ return { parsed: null, text };
337
+ }
338
+ }
339
+ /**
340
+ * The server's own words, appended for context — truncated. NOT redacted here: everything this
341
+ * function feeds goes out through `redactResult`, and scattering the filter is what let two
342
+ * paths quietly skip it the first time round.
343
+ */
344
+ function serverSaid(text) {
345
+ const cleaned = text.replace(/\s+/g, " ").trim();
346
+ if (!cleaned)
347
+ return "";
348
+ return `\n\nMathter said: ${cleaned.slice(0, 200)}`;
349
+ }
350
+ class ToolError extends Error {
351
+ }
352
+ function asInt(value) {
353
+ if (typeof value === "number" && Number.isFinite(value))
354
+ return Math.trunc(value);
355
+ // Models routinely send numbers as strings; accept that rather than bouncing a usable request.
356
+ if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) {
357
+ return Math.trunc(Number(value));
358
+ }
359
+ return undefined;
360
+ }
361
+ function asBool(value) {
362
+ if (typeof value === "boolean")
363
+ return value;
364
+ if (value === "true")
365
+ return true;
366
+ if (value === "false")
367
+ return false;
368
+ return undefined;
369
+ }
370
+ function asString(value) {
371
+ return typeof value === "string" && value.trim() !== "" ? value : undefined;
372
+ }
373
+ /**
374
+ * Build the POST body from the model's arguments — an allowlist, not a pass-through. Anything the
375
+ * route does not read is dropped here rather than sent and ignored, and anything the caller left
376
+ * out is omitted entirely so the server's own default applies.
377
+ */
378
+ export function buildGenerateBody(args) {
379
+ const skill = asString(args.skill);
380
+ if (!skill)
381
+ throw new ToolError("The `skill` option is required. Call list_skills to see the valid skill keys.");
382
+ const grade = asInt(args.grade);
383
+ if (grade === undefined || grade < 0 || grade > 12) {
384
+ throw new ToolError("The `grade` option is required and must be a whole number from 0 (kindergarten) to 12. " +
385
+ "list_skills says which grades each skill covers.");
386
+ }
387
+ const body = {
388
+ skill,
389
+ grade,
390
+ // The route requires a theme and 400s without one; a missing theme is a palette the caller
391
+ // did not care about, not an error worth bouncing back to them.
392
+ theme: asString(args.theme) ?? "teal",
393
+ };
394
+ const put = (key, value) => {
395
+ if (value !== undefined)
396
+ body[key] = value;
397
+ };
398
+ put("style", asString(args.style));
399
+ put("difficulty", asString(args.difficulty));
400
+ put("title", asString(args.title));
401
+ put("shopName", asString(args.shopName));
402
+ put("unitSystem", asString(args.unitSystem));
403
+ put("worksheetCount", asInt(args.worksheetCount));
404
+ put("problemsPerPage", asInt(args.problemsPerPage));
405
+ put("seed", asInt(args.seed));
406
+ put("includeAnswerKeys", asBool(args.includeAnswerKeys));
407
+ put("includeCover", asBool(args.includeCover));
408
+ put("includeLesson", asBool(args.includeLesson));
409
+ put("workedSolutions", asBool(args.workedSolutions));
410
+ put("diagrams", asBool(args.diagrams));
411
+ put("graphBlank", asBool(args.graphBlank));
412
+ put("timed", asBool(args.timed));
413
+ const focus = asInt(args.focus);
414
+ if (focus !== undefined && focus >= 1 && focus <= 12) {
415
+ body.focus = focus;
416
+ put("focusOrder", asString(args.focusOrder));
417
+ }
418
+ const chart = args.chart;
419
+ const chartMode = chart && typeof chart === "object" ? asString(chart.mode) : undefined;
420
+ if (chartMode === "filled" || chartMode === "blank")
421
+ body.chart = { mode: chartMode };
422
+ return body;
423
+ }
424
+ function two(n) {
425
+ return String(n).padStart(2, "0");
426
+ }
427
+ /** `mathter-grade-4-multiplication-2026-10-04-1532.pdf` — sortable, searchable, obviously ours. */
428
+ export function packFilename(skill, grade, now = new Date()) {
429
+ const slug = skill.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase() || "pack";
430
+ const stamp = `${now.getFullYear()}-${two(now.getMonth() + 1)}-${two(now.getDate())}` +
431
+ `-${two(now.getHours())}${two(now.getMinutes())}`;
432
+ return `mathter-grade-${grade}-${slug}-${stamp}.pdf`;
433
+ }
434
+ function uniquePath(dir, filename) {
435
+ const base = filename.replace(/\.pdf$/, "");
436
+ let candidate = join(dir, filename);
437
+ for (let i = 2; existsSync(candidate) && i < 1000; i++)
438
+ candidate = join(dir, `${base}-${i}.pdf`);
439
+ return candidate;
440
+ }
441
+ // ---------------------------------------------------------------------------------------------
442
+ // The tools themselves
443
+ // ---------------------------------------------------------------------------------------------
444
+ async function request(url, init, cfg) {
445
+ try {
446
+ return await fetch(url, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
447
+ }
448
+ catch (e) {
449
+ const name = e?.name;
450
+ if (name === "TimeoutError" || name === "AbortError") {
451
+ throw new ToolError(`Mathter did not answer within ${Math.round(REQUEST_TIMEOUT_MS / 1000)} seconds. Large packs ` +
452
+ "are slow to render — try again, or ask for fewer worksheets.");
453
+ }
454
+ throw new ToolError(`Could not reach Mathter at ${cfg.baseUrl}. Check the internet connection and MATHTER_BASE_URL. ` +
455
+ `(${String(e?.message ?? e)})`);
456
+ }
457
+ }
458
+ export async function generateWorksheetPack(args, cfg) {
459
+ const body = buildGenerateBody(args);
460
+ const res = await request(`${cfg.baseUrl}/api/generate`, {
461
+ method: "POST",
462
+ headers: {
463
+ // The one and only place the key is used.
464
+ Authorization: `Bearer ${cfg.apiKey}`,
465
+ "Content-Type": "application/json",
466
+ Accept: "application/pdf, application/json",
467
+ "User-Agent": `mathter-mcp/${VERSION}`,
468
+ },
469
+ body: JSON.stringify(body),
470
+ }, cfg);
471
+ if (!res.ok) {
472
+ const { parsed, text } = await readErrorBody(res);
473
+ throw new ToolError(describeHttpError(res.status, parsed, res.headers.get("retry-after"), {
474
+ caller: "key",
475
+ url: `${cfg.baseUrl}/api/generate`,
476
+ }) + serverSaid(text));
477
+ }
478
+ const bytes = Buffer.from(await res.arrayBuffer());
479
+ if (bytes.length === 0) {
480
+ throw new ToolError("Mathter returned an empty file. Nothing was saved; try again.");
481
+ }
482
+ // A 200 is not proof of a PDF. A proxy, a captive portal or a misrouted MATHTER_BASE_URL can
483
+ // all answer 200 with HTML or JSON, and saving that under a .pdf name gives the teacher a file
484
+ // that silently will not open. Four bytes settle it.
485
+ if (bytes.subarray(0, 4).toString("latin1") !== "%PDF") {
486
+ throw new ToolError(`Mathter answered, but not with a PDF — nothing was saved. Check MATHTER_BASE_URL points at ` +
487
+ `Mathter (currently ${cfg.baseUrl}) and that no proxy is intercepting the request.`);
488
+ }
489
+ mkdirSync(cfg.outDir, { recursive: true });
490
+ const path = uniquePath(cfg.outDir, packFilename(String(body.skill), Number(body.grade)));
491
+ writeFileSync(path, bytes);
492
+ const savedToAccount = !!res.headers.get("x-pack-id");
493
+ const count = typeof body.worksheetCount === "number" ? body.worksheetCount : 10;
494
+ const kb = Math.max(1, Math.round(bytes.length / 1024));
495
+ return (`Saved a grade ${body.grade} ${body.skill} pack to ${path} ` +
496
+ `(${count} worksheets requested — the account's plan may cap the actual number — ${kb} KB). ` +
497
+ "Open or print that file." +
498
+ (savedToAccount
499
+ ? ` A copy is also saved in the Mathter account under "Your packs" (${cfg.baseUrl}/account#packs); ` +
500
+ "on a paid plan it can be downloaded again from any computer after signing in."
501
+ : ""));
502
+ }
503
+ export async function listSkills(cfg) {
504
+ // No Authorization header: /api/skills is public, so sending the key would widen its exposure
505
+ // for nothing.
506
+ const res = await request(`${cfg.baseUrl}/api/skills`, { method: "GET", headers: { Accept: "application/json", "User-Agent": `mathter-mcp/${VERSION}` } }, cfg);
507
+ if (!res.ok) {
508
+ const { parsed, text } = await readErrorBody(res);
509
+ throw new ToolError(describeHttpError(res.status, parsed, res.headers.get("retry-after"), {
510
+ caller: "public",
511
+ url: `${cfg.baseUrl}/api/skills`,
512
+ }) + serverSaid(text));
513
+ }
514
+ const data = (await res.json().catch(() => null));
515
+ const skills = Array.isArray(data?.skills) ? data.skills : [];
516
+ if (skills.length === 0) {
517
+ throw new ToolError(`Mathter returned no skills from ${cfg.baseUrl}/api/skills. That is unexpected — try again shortly.`);
518
+ }
519
+ const lines = skills.map((s) => {
520
+ const grades = Array.isArray(s.grades) ? s.grades.join(", ") : "";
521
+ return `- ${s.key} — ${s.label ?? s.key} — grades ${grades}`;
522
+ });
523
+ return `${skills.length} skills available (grade 0 is kindergarten):\n${lines.join("\n")}`;
524
+ }
525
+ /**
526
+ * The single outbound filter for tool results. Every text block of every result — success,
527
+ * mapped error, unexpected throw, unknown tool name — passes through here on its way to the
528
+ * client, so a branch added later cannot opt out of redaction by forgetting to call it. The
529
+ * first version of this file spread `redact()` over six call sites and two paths had already
530
+ * missed it; one boundary is the fix, not more diligence.
531
+ */
532
+ function redactResult(result, apiKey) {
533
+ return {
534
+ ...result,
535
+ content: result.content.map((block) => block.type === "text" && typeof block.text === "string"
536
+ ? { ...block, text: redact(block.text, apiKey) }
537
+ : block),
538
+ };
539
+ }
540
+ export async function handleCallTool(name, args, cfg) {
541
+ return redactResult(await runTool(name, args, cfg), cfg.apiKey);
542
+ }
543
+ async function runTool(name, args, cfg) {
544
+ try {
545
+ if (name === "generate_worksheet_pack") {
546
+ return { content: [{ type: "text", text: await generateWorksheetPack(args ?? {}, cfg) }] };
547
+ }
548
+ if (name === "list_skills") {
549
+ return { content: [{ type: "text", text: await listSkills(cfg) }] };
550
+ }
551
+ return {
552
+ isError: true,
553
+ content: [{
554
+ type: "text",
555
+ text: `mathter-mcp has no tool called "${name}". It offers generate_worksheet_pack and list_skills.`,
556
+ }],
557
+ };
558
+ }
559
+ catch (e) {
560
+ const message = e instanceof ToolError ? e.message : `mathter-mcp failed: ${String(e?.message ?? e)}`;
561
+ return { isError: true, content: [{ type: "text", text: message }] };
562
+ }
563
+ }
564
+ // ---------------------------------------------------------------------------------------------
565
+ // Server wiring
566
+ // ---------------------------------------------------------------------------------------------
567
+ export function createMcpServer(cfg) {
568
+ const server = new Server({ name: "mathter-mcp", version: VERSION }, { capabilities: { tools: {} } });
569
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
570
+ server.setRequestHandler(CallToolRequestSchema, async (req) => handleCallTool(req.params.name, (req.params.arguments ?? {}), cfg));
571
+ return server;
572
+ }
573
+ const processIO = {
574
+ // stderr, never stdout: stdout is the MCP transport.
575
+ stderr: (line) => process.stderr.write(`${line}\n`),
576
+ exit: (code) => process.exit(code),
577
+ };
578
+ export async function main(env = process.env, io = processIO) {
579
+ // The second (and last) redaction boundary: nothing reaches stderr except through here.
580
+ const say = (line, apiKey) => io.stderr(redact(line, apiKey));
581
+ let cfg;
582
+ try {
583
+ cfg = loadConfig(env);
584
+ }
585
+ catch (e) {
586
+ // Fail here, loudly, rather than letting a missing key become a 401 on the first worksheet.
587
+ say(String(e?.message ?? e));
588
+ io.exit(1);
589
+ return;
590
+ }
591
+ const server = createMcpServer(cfg);
592
+ await server.connect(new StdioServerTransport());
593
+ say(`mathter-mcp ${VERSION} ready — ${cfg.baseUrl}, packs saved to ${cfg.outDir}`, cfg.apiKey);
594
+ }
595
+ /**
596
+ * Is this file the program being run, as opposed to a module someone imported?
597
+ *
598
+ * The naive comparison — `import.meta.url === pathToFileURL(process.argv[1]).href` — is WRONG
599
+ * for the way this package is actually installed. `npm`/`npx` put a `bin` into
600
+ * `node_modules/.bin` as a SYMLINK; Node resolves symlinks when it computes `import.meta.url`
601
+ * but leaves `process.argv[1]` as the symlink path it was given. The two never match, `main()`
602
+ * never runs, and `npx -y mathter-mcp` exits 0 having printed nothing — on macOS and Linux
603
+ * only, because npm writes a `.cmd` shim with the real path on Windows. Resolve the symlink
604
+ * first. See the child-process test, which runs the built file through a symlink exactly as
605
+ * npm would.
606
+ */
607
+ export function isDirectRun(metaUrl, argv1) {
608
+ if (!argv1)
609
+ return false;
610
+ let resolved = argv1;
611
+ try {
612
+ resolved = realpathSync(argv1);
613
+ }
614
+ catch {
615
+ // argv[1] deleted, or on a filesystem we cannot stat. Fall back to the raw path rather than
616
+ // taking the process down over a liveness check.
617
+ }
618
+ return metaUrl === pathToFileURL(resolved).href;
619
+ }
620
+ if (isDirectRun(import.meta.url, process.argv[1])) {
621
+ void main();
622
+ }
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "mathter-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "MCP server for Mathter \u2014 ask your AI assistant for a printable math worksheet pack and get a PDF on disk.",
5
+ "license": "MIT",
6
+ "author": "Mathter (https://mathter.ca)",
7
+ "type": "module",
8
+ "keywords": [
9
+ "mcp",
10
+ "modelcontextprotocol",
11
+ "math",
12
+ "worksheets",
13
+ "teachers",
14
+ "mathter"
15
+ ],
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/gahrons/mathter-mcp.git"
19
+ },
20
+ "homepage": "https://mathter.ca/api",
21
+ "bugs": {
22
+ "url": "https://mathter.ca/contact"
23
+ },
24
+ "bin": {
25
+ "mathter-mcp": "dist/index.js"
26
+ },
27
+ "main": "dist/index.js",
28
+ "types": "dist/index.d.ts",
29
+ "files": [
30
+ "dist",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "engines": {
35
+ "node": ">=18"
36
+ },
37
+ "scripts": {
38
+ "build": "tsc -p tsconfig.json",
39
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json",
40
+ "test": "vitest run",
41
+ "test:watch": "vitest",
42
+ "prepare": "npm run build"
43
+ },
44
+ "dependencies": {
45
+ "@modelcontextprotocol/sdk": "^1.32.0"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^22.10.0",
49
+ "typescript": "^5.9.3",
50
+ "vitest": "^4.1.9"
51
+ }
52
+ }