altalandi-mcp 0.3.0 → 0.6.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/README.md CHANGED
@@ -1,71 +1,102 @@
1
- # altalandi-mcp
2
-
3
- An [MCP](https://modelcontextprotocol.io) server that exposes
4
- [Altalandi](https://altalandi.is), native-Icelandic refinement with a
5
- meaning-preservation check, as a tool any MCP client can call. stdio transport.
6
-
7
- ## Set up (Claude Code or Codex)
8
-
9
- Connect this computer once. This opens altalandi.is, you approve, and the key
10
- is stored locally. You never copy it:
11
-
12
- ```bash
13
- npx altalandi login
14
- ```
15
-
16
- Then add the server. No key goes into any config file; the server reads the
17
- one `altalandi login` stored:
18
-
19
- ```bash
20
- claude mcp add altalandi -s user -- npx -y altalandi-mcp # Claude Code
21
- codex mcp add altalandi -- npx -y altalandi-mcp # Codex
22
- ```
23
-
24
- Ask for Icelandic and mention Altalandi, e.g. *"Write this in proper Icelandic
25
- with Altalandi."* The agent calls the **`refine_icelandic`** tool.
26
-
27
- ### Without `altalandi login` (CI, services, other clients)
28
-
29
- Set `ALTALANDI_API_KEY` in the server's environment, from your host's secret
30
- manager. It takes precedence over a stored login. Mint a key at
31
- <https://altalandi.is/portal>. Never commit a plaintext key in MCP JSON.
32
-
33
- ```json
34
- { "mcpServers": { "altalandi": { "command": "npx", "args": ["-y", "altalandi-mcp"] } } }
35
- ```
36
-
37
- ## The tool
38
-
39
- `refine_icelandic({ text, domain?, register?, sourceLang? })` returns the native
40
- Icelandic text, a 0–100 nativeness score, an explicit meaning verdict, and every
41
- warning returned by the API.
42
-
43
- - `domain`: `general` \| `marketing` \| `data-analysis` \| `childrens-fiction`
44
- - `register`: `formal` \| `neutral` \| `warm`
45
- - `sourceLang`: `auto` \| `en` \| `is`
46
-
47
- **Meaning guarantee:** on Icelandic input where the rewrite can't be verified to
48
- preserve meaning, the tool returns the **original unchanged** and flags it,
49
- never a silent change.
50
-
51
- Only **meaning preserved** is a verified result. **MEANING UNVERIFIED** means the
52
- check did not run or could not complete; review that output before use. API
53
- warnings are included under a clear `Warnings:` section in the tool's result.
54
-
55
- ## Config
56
-
57
- | env | default | purpose |
58
- |-----|---------|---------|
59
- | `ALTALANDI_API_KEY` | (the key from `altalandi login`) | your key (Bearer) |
60
- | `ALTALANDI_CONFIG_DIR` | `%APPDATA%\altalandi` / `~/.config/altalandi` | where `altalandi login` stored it |
61
- | `ALTALANDI_BASE_URL` | `https://altalandi.is/api` | API base (override for self-hosting) |
62
-
63
- Each API request has a fixed 300-second deadline, including the response body,
64
- so a stalled provider cannot leave the tool hanging.
65
-
66
- ## Cost
67
-
68
- Calls are paid from the account's prepaid credit in krónur (a new account
69
- starts with trial credit). See <https://altalandi.is/verd>.
70
-
71
- MIT licensed. Requires Node ≥ 18.
1
+ # altalandi-mcp
2
+
3
+ An [MCP](https://modelcontextprotocol.io) server that exposes
4
+ [Altalandi](https://altalandi.is), native-Icelandic refinement with a
5
+ meaning-preservation check, as a tool any MCP client can call. stdio transport.
6
+
7
+ ## Set up (Claude Code or Codex)
8
+
9
+ Connect this computer once. This opens altalandi.is, you approve, and the key
10
+ is stored locally. You never copy it:
11
+
12
+ ```bash
13
+ npx altalandi login
14
+ ```
15
+
16
+ Then add the server. No key goes into any config file; the server reads the
17
+ one `altalandi login` stored:
18
+
19
+ ```bash
20
+ claude mcp add altalandi -s user -- npx -y altalandi-mcp # Claude Code
21
+ codex mcp add altalandi -- npx -y altalandi-mcp # Codex
22
+ ```
23
+
24
+ Ask for Icelandic and mention Altalandi, e.g. *"Write this in proper Icelandic
25
+ with Altalandi."* The agent calls the **`refine_icelandic`** tool.
26
+
27
+ ### Without `altalandi login` (CI, services, other clients)
28
+
29
+ Set `ALTALANDI_API_KEY` in the server's environment, from your host's secret
30
+ manager. It takes precedence over a stored login. Mint a key at
31
+ <https://altalandi.is/portal>. Never commit a plaintext key in MCP JSON.
32
+
33
+ ```json
34
+ { "mcpServers": { "altalandi": { "command": "npx", "args": ["-y", "altalandi-mcp"] } } }
35
+ ```
36
+
37
+ ## The tool
38
+
39
+ `refine_icelandic({ text, domain?, register?, sourceLang?, report? })` returns two
40
+ parts. The first is one Markdown block: the verdict and nativeness score, the text
41
+ before and after (Fyrir / Eftir), a numbered list of changes with their reasons,
42
+ why a rewrite was refused if it was, and what the call cost with the balance and
43
+ free units left (Kostnaður). The second is the full refined text to apply. The
44
+ tool tells the agent to show the block to you as it is after every call, so you see
45
+ what changed and what it cost in the terminal or the editor, not only on the website.
46
+
47
+ - `domain`: `general` \| `marketing` \| `data-analysis` \| `childrens-fiction`
48
+ - `register`: `formal` \| `neutral` \| `warm`
49
+ - `sourceLang`: `auto` \| `en` \| `is`
50
+ - `noStore`: `true` means Altalandi never keeps this text. Otherwise free use (the free
51
+ daily units) is kept, with personal data scrubbed, to improve the service; paid use only
52
+ with consent ([privacy](https://altalandi.is/personuvernd#textabanki)).
53
+ - `report`: `true` also writes `./altalandi-reports/altalandi-<time>-<id>.md`
54
+ (input, output, every change with its reason and layer, warnings, verdict).
55
+ Default: on when `ALTALANDI_REPORT=1`. Reports contain the text, so they are opt-in.
56
+
57
+ **Meaning guarantee:** on Icelandic input where the rewrite can't be verified to
58
+ preserve meaning, the tool returns the **original unchanged** and flags it,
59
+ never a silent change.
60
+
61
+ ## Proofreading
62
+
63
+ `proofread_icelandic({ text, suggestions?, minPrecision?, noStore? })` fixes only
64
+ proven errors in Icelandic you wrote (punctuation, word boundaries, a misspelt word
65
+ one slip from the right one) and keeps your wording. Everything else comes back as
66
+ numbered **suggestions**, each with its reason and, when known, how sure it is. The
67
+ agent shows you each one and applies only those you accept.
68
+
69
+ - `suggestions`: `offer` (default: you choose) \| `apply` (the kinds of suggestion
70
+ measured right at least `minPrecision` of the time are applied and listed; the rest
71
+ are offered) \| `off` (only the safe fixes).
72
+ - `minPrecision`: 0.5 to 0.99, default 0.9. A suggestion with a digit, or one that
73
+ changes a name, is never applied unasked.
74
+
75
+ Then `record_suggestion_decisions({ resultId, decisions })` tells Altalandi which
76
+ suggestions you used or ignored (numbers only, no text, free). That is how the
77
+ suggestions get better.
78
+
79
+ Only **meaning preserved** is a verified result. **MEANING UNVERIFIED** means the
80
+ check did not run or could not complete; review that output before use. API
81
+ warnings are included under a clear `Warnings:` section in the tool's result.
82
+
83
+ ## Config
84
+
85
+ | env | default | purpose |
86
+ |-----|---------|---------|
87
+ | `ALTALANDI_API_KEY` | (the key from `altalandi login`) | your key (Bearer) |
88
+ | `ALTALANDI_CONFIG_DIR` | `%APPDATA%\altalandi` / `~/.config/altalandi` | where `altalandi login` stored it |
89
+ | `ALTALANDI_BASE_URL` | `https://altalandi.is/api` | API base (override for self-hosting) |
90
+ | `ALTALANDI_REPORT` | — | `1` = write a report for every call |
91
+ | `ALTALANDI_REPORT_DIR` | `./altalandi-reports` (the server's working folder) | where reports go |
92
+
93
+ Each API request has a fixed 300-second deadline, including the response body,
94
+ so a stalled provider cannot leave the tool hanging.
95
+
96
+ ## Cost
97
+
98
+ Every account gets 2,000 free units a day on the standard writer (about 2,000
99
+ words); past that, calls are paid from prepaid credit in krónur, and a new account
100
+ also starts with 500 kr. of trial credit. See <https://altalandi.is/verd>.
101
+
102
+ MIT licensed. Requires Node ≥ 18.
package/api-client.mjs CHANGED
@@ -52,3 +52,34 @@ export async function requestRefine(
52
52
  clearTimeoutImpl(timeout);
53
53
  }
54
54
  }
55
+
56
+ /** The account behind the key (balance, free units), or null. Best-effort:
57
+ * it only fills the cost lines of the review block. */
58
+ export async function requestMe(baseUrl, apiKey, { fetchImpl = globalThis.fetch } = {}) {
59
+ try {
60
+ const response = await fetchImpl(`${baseUrl}/v1/me`, {
61
+ headers: { authorization: `Bearer ${apiKey}`, accept: "application/json" },
62
+ signal: AbortSignal.timeout(15_000),
63
+ });
64
+ return response.ok ? await response.json() : null;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ /** Report what the person chose for each proofreading suggestion (free; no
71
+ * model runs; no text is sent). Returns {ok, recorded, refused} or {ok: false, error}. */
72
+ export async function requestSuggestionDecisions(baseUrl, apiKey, body, { fetchImpl = globalThis.fetch } = {}) {
73
+ try {
74
+ const response = await fetchImpl(`${baseUrl}/v1/suggestions`, {
75
+ method: "POST",
76
+ headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
77
+ body: JSON.stringify(body),
78
+ signal: AbortSignal.timeout(15_000),
79
+ });
80
+ const data = await response.json().catch(() => ({}));
81
+ return response.ok ? data : { ok: false, error: data.detail || response.statusText };
82
+ } catch (error) {
83
+ return { ok: false, error: error.message };
84
+ }
85
+ }
package/device.mjs ADDED
@@ -0,0 +1,80 @@
1
+ // Sign-in from inside the MCP server, for clients where nobody opens a
2
+ // terminal (Claude Desktop). The same device login as `npx altalandi login`:
3
+ // the server asks for a short code, the tool result gives the person a link,
4
+ // they sign in or sign up at altalandi.is and approve, and the server stores
5
+ // the key where the CLI keeps it (key.mjs), so one login serves both.
6
+ //
7
+ // One login runs at a time; while it is pending, every call returns the same
8
+ // link instead of starting another.
9
+
10
+ import { chmod, mkdir, writeFile } from "node:fs/promises";
11
+ import { hostname } from "node:os";
12
+ import path from "node:path";
13
+ import { configDir } from "./key.mjs";
14
+
15
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
16
+ let pending = null; // { link, code, expiresAt }
17
+
18
+ async function postJson(fetchImpl, url, body) {
19
+ const res = await fetchImpl(url, {
20
+ method: "POST",
21
+ headers: { "content-type": "application/json", accept: "application/json" },
22
+ body: JSON.stringify(body),
23
+ });
24
+ let data = {};
25
+ try { data = await res.json(); } catch { /* keep {} */ }
26
+ return { status: res.status, data };
27
+ }
28
+
29
+ async function save(data, env) {
30
+ const dir = configDir({ env });
31
+ await mkdir(dir, { recursive: true });
32
+ const file = path.join(dir, "credentials.json");
33
+ await writeFile(file, JSON.stringify(data, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
34
+ try { await chmod(file, 0o600); } catch { /* Windows: the profile directory protects it */ }
35
+ return file;
36
+ }
37
+
38
+ /**
39
+ * The link and code for the person to approve. Starts a login if none is
40
+ * pending; the approval is picked up in the background and saved.
41
+ * Returns { link, code } or { error }.
42
+ */
43
+ export async function beginLogin(base, { fetchImpl = globalThis.fetch, env = process.env, client = "Claude Desktop", wait = sleep } = {}) {
44
+ if (pending && Date.now() < pending.expiresAt) return { link: pending.link, code: pending.code };
45
+ let start;
46
+ try {
47
+ const where = (hostname() || "").split(".")[0].slice(0, 24);
48
+ start = await postJson(fetchImpl, `${base}/v1/device/start`, { clientName: where ? `${client} á ${where}` : client });
49
+ } catch (e) {
50
+ return { error: `Náði ekki sambandi við Altalandi (${e.message}).` };
51
+ }
52
+ if (start.status !== 200 || !start.data.deviceCode) {
53
+ return { error: start.data.detail || `Innskráning fór ekki af stað (HTTP ${start.status}).` };
54
+ }
55
+ const { deviceCode, userCode, verificationUriComplete, interval = 3, expiresIn = 600 } = start.data;
56
+ pending = { link: verificationUriComplete, code: userCode, expiresAt: Date.now() + expiresIn * 1000 };
57
+ const mine = pending;
58
+ // Poll in the background until approved, refused or expired.
59
+ (async () => {
60
+ while (pending === mine && Date.now() < mine.expiresAt) {
61
+ await wait(Math.max(1, interval) * 1000);
62
+ let poll;
63
+ try { poll = await postJson(fetchImpl, `${base}/v1/device/token`, { deviceCode }); } catch { continue; }
64
+ if (poll.status === 200 && poll.data.status === "approved") {
65
+ try {
66
+ await save({ apiKey: poll.data.apiKey, username: poll.data.username, savedAt: new Date().toISOString() }, env);
67
+ } catch { /* the next call will offer a new login */ }
68
+ break;
69
+ }
70
+ if (poll.status === 200 && poll.data.status === "pending") continue;
71
+ if (poll.status === 429) { await wait(10_000); continue; }
72
+ break; // expired or refused
73
+ }
74
+ if (pending === mine) pending = null;
75
+ })();
76
+ return { link: mine.link, code: mine.code };
77
+ }
78
+
79
+ /** For tests. */
80
+ export function _resetLogin() { pending = null; }
@@ -53,3 +53,40 @@ export function formatRefineResponse(data) {
53
53
 
54
54
  return lines.join("\n");
55
55
  }
56
+
57
+ const LABELS = { both: "both checks agree", one: "one check", rule: "rule (BÍN)" };
58
+
59
+ /** A task=proofread response for an agent: the corrected text, what was fixed,
60
+ * the numbered suggestions to offer, and what to do once the person decides. */
61
+ export function formatProofreadResponse(data) {
62
+ const lines = ["**Altalandi prófarkalestur** (proofreading)", "", "Corrected text (proven errors fixed):", "",
63
+ String(data?.output ?? ""), ""];
64
+ const diffs = Array.isArray(data?.diffs) ? data.diffs : [];
65
+ if (diffs.length) {
66
+ lines.push("Fixed:");
67
+ for (const d of diffs.slice(0, 20)) {
68
+ lines.push(`- „${String(d.before ?? "").trim()}“ → „${String(d.after ?? "").trim()}“ (${d.reason ?? ""})`);
69
+ }
70
+ lines.push("");
71
+ }
72
+ const suggestions = Array.isArray(data?.suggestions) ? data.suggestions : [];
73
+ if (suggestions.length) {
74
+ lines.push("Suggestions — show every one to the person and ask which to apply; apply only those accepted:");
75
+ suggestions.slice(0, 100).forEach((s, i) => {
76
+ const label = LABELS[s.agreement] ? ` · ${LABELS[s.agreement]}` : "";
77
+ lines.push(`${i}. „${s.before}“ → „${s.after}“ · ${s.reason ?? ""}${label}`);
78
+ });
79
+ lines.push("", `When the person has decided, call record_suggestion_decisions with resultId ${data?.id} ` +
80
+ "and a decision (used or ignored) for every number. That is how Altalandi learns which suggestions to trust.");
81
+ } else {
82
+ lines.push("No suggestions.");
83
+ }
84
+ const unresolved = Array.isArray(data?.unresolved) ? data.unresolved : [];
85
+ if (unresolved.length) {
86
+ lines.push("", "Likely errors left as they were (point them out to the person):");
87
+ for (const u of unresolved.slice(0, 10)) {
88
+ lines.push(`- ${(u.words || []).join(", ") || String(u.sentence ?? "").slice(0, 120)}: ${u.note ?? ""}`);
89
+ }
90
+ }
91
+ return lines.join("\n");
92
+ }
package/key.mjs CHANGED
@@ -18,7 +18,10 @@ export function configDir({ env = process.env, home = homedir(), platform = proc
18
18
  /** { key, source } where source is "env", "login" or "none". Never throws. */
19
19
  export function resolveKey(opts = {}) {
20
20
  const env = opts.env ?? process.env;
21
- const fromEnv = env.ALTALANDI_API_KEY || env.ALTALANDI_KEY;
21
+ // A Claude Desktop extension passes its optional key field through; when
22
+ // left empty it can arrive as "" or as the unexpanded "${user_config.api_key}".
23
+ const usable = (v) => (typeof v === "string" && v.trim() && !v.trim().startsWith("${") ? v.trim() : "");
24
+ const fromEnv = usable(env.ALTALANDI_API_KEY) || usable(env.ALTALANDI_KEY);
22
25
  if (fromEnv) return { key: fromEnv, source: "env" };
23
26
  try {
24
27
  const data = JSON.parse(readFileSync(path.join(configDir({ ...opts, env }), "credentials.json"), "utf8"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "altalandi-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.6.0",
4
4
  "description": "MCP server exposing Altalandi (native-Icelandic refinement) as a tool for Claude Code and any MCP client.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "server.mjs",
11
11
  "key.mjs",
12
+ "device.mjs",
12
13
  "api-client.mjs",
13
14
  "format-response.mjs",
14
15
  "report.mjs",
package/report.mjs CHANGED
@@ -1,114 +1,280 @@
1
- // Before/after for people: a terminal view and an optional Markdown report.
2
- //
3
- // The website shows each change inline; in a terminal or an editor the
4
- // before/after got lost. This module renders it the same way everywhere:
5
- // beforeAfterText() plain (or coloured) block for a terminal or a tool result
6
- // reportMarkdown() the report file: input, output, every change with its
7
- // reason, and the verdict
8
- // writeReport() writes it to ./altalandi-reports/ (or ALTALANDI_REPORT_DIR)
9
- //
10
- // Reports are opt-in (--report, or ALTALANDI_REPORT=1): they contain the text.
11
-
12
- import { mkdir, writeFile } from "node:fs/promises";
13
- import { join, resolve } from "node:path";
14
-
15
- const VERDICT = {
16
- rewritten: "Endurskrifað og merking staðfest",
17
- refused: "Endurskrift hafnað: upprunalega textanum skilað óbreyttum",
18
- unchanged: "Engin breyting þurfti",
19
- unverified: "Merking ekki staðfest: farðu yfir áður en þú notar textann",
20
- };
21
-
22
- export function outcomeOf(data) {
23
- if (data?.rejectionReason) return "refused";
24
- if (data?.meaningPreserved === false) return "refused";
25
- if (data?.rewriteAttempted === false) return "unchanged";
26
- if (data?.meaningPreserved !== true) return "unverified";
27
- return "rewritten";
28
- }
29
-
30
- export function reportEnabled(flag) {
31
- if (flag !== undefined && flag !== null) return Boolean(flag);
32
- return ["1", "true", "yes"].includes(String(process.env.ALTALANDI_REPORT || "").toLowerCase());
33
- }
34
-
35
- const paint = (on) => ({
36
- red: (s) => (on ? `\x1b[31m${s}\x1b[0m` : s),
37
- green: (s) => (on ? `\x1b[32m${s}\x1b[0m` : s),
38
- dim: (s) => (on ? `\x1b[2m${s}\x1b[0m` : s),
39
- bold: (s) => (on ? `\x1b[1m${s}\x1b[0m` : s),
40
- });
41
-
42
- /** A before/after block with the list of changes. `color` adds ANSI colours. */
43
- export function beforeAfterText(input, data, { color = false } = {}) {
44
- const c = paint(color);
45
- const diffs = Array.isArray(data?.diffs) ? data.diffs : [];
46
- const rule = (label) => c.dim(`── ${label} ${"─".repeat(Math.max(4, 40 - label.length))}`);
47
- const lines = [
48
- rule("Fyrir"), String(input ?? ""), "",
49
- rule("Eftir"), String(data?.output ?? ""), "",
50
- rule(`Breytingar (${diffs.length})`),
51
- ];
52
- if (!diffs.length) lines.push(c.dim("Engar breytingar."));
53
- diffs.forEach((d, i) => {
54
- lines.push(`${i + 1}. ${c.red("− " + String(d.before ?? "").trim())}`);
55
- lines.push(` ${c.green("+ " + String(d.after ?? "").trim())}`);
56
- if (d.reason) lines.push(` ${c.dim("↳ " + String(d.reason))}`);
57
- });
58
- const q = data?.quality || {};
59
- lines.push("", c.bold(VERDICT[outcomeOf(data)]) + (q.nativenessScore != null ? c.dim(` · innfæddni ${q.nativenessScore}/100`) : ""));
60
- return lines.join("\n");
61
- }
62
-
63
- const fence = (s) => {
64
- const text = String(s ?? "");
65
- const ticks = text.includes("```") ? "````" : "```";
66
- return `${ticks}text\n${text}\n${ticks}`;
67
- };
68
- const cell = (s) => String(s ?? "").replace(/\|/g, "\\|").replace(/\r?\n/g, " ").trim();
69
-
70
- export function reportMarkdown(input, data, options = {}) {
71
- const q = data?.quality || {};
72
- const diffs = Array.isArray(data?.diffs) ? data.diffs : [];
73
- const warnings = Array.isArray(data?.warnings) ? data.warnings : [];
74
- const tier = data?.billedTier || data?.tier || {};
75
- const when = new Date().toISOString().replace("T", " ").slice(0, 19);
76
- const out = [
77
- `# Altalandi: ${data?.id || "yfirlestur"}`,
78
- "",
79
- `| | |`, `|---|---|`,
80
- `| Tími | ${when} UTC |`,
81
- `| Niðurstaða | ${VERDICT[outcomeOf(data)]} |`,
82
- `| Frummál | ${data?.sourceLang || "?"} |`,
83
- `| Tegund texta | ${options.domain || "general"} |`,
84
- `| Málsnið | ${options.register || "neutral"} |`,
85
- `| Innfæddni | ${q.nativenessScore ?? "n/a"}/100 |`,
86
- `| Ritill | ${tier.writer || "standard"} / ${tier.scoring || "?"} |`,
87
- `| Auðkenni | ${data?.id || "?"} |`,
88
- "",
89
- "## Fyrir", "", fence(input), "",
90
- "## Eftir", "", fence(data?.output), "",
91
- `## Breytingar (${diffs.length})`, "",
92
- ];
93
- if (diffs.length) {
94
- out.push("| # | Fyrir | Eftir | Ástæða | Lag |", "|---|---|---|---|---|");
95
- diffs.forEach((d, i) => out.push(`| ${i + 1} | ${cell(d.before)} | ${cell(d.after)} | ${cell(d.reason)} | ${cell(d.layer)} |`));
96
- } else {
97
- out.push("Engar breytingar.");
98
- }
99
- if (data?.rejectionReason) out.push("", "## Af hverju var endurskriftinni hafnað", "", cell(data.rejectionReason));
100
- if (warnings.length) out.push("", "## Viðvaranir", "", ...warnings.map((w) => `- ${cell(w)}`));
101
- if (data?.meaningNotes) out.push("", "## Athugun á merkingu", "", cell(data.meaningNotes));
102
- return out.join("\n") + "\n";
103
- }
104
-
105
- /** Write the report; returns its path. */
106
- export async function writeReport(input, data, options = {}, dir) {
107
- const base = resolve(dir || process.env.ALTALANDI_REPORT_DIR || join(process.cwd(), "altalandi-reports"));
108
- await mkdir(base, { recursive: true });
109
- const stamp = new Date().toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
110
- const id = String(data?.id || "yfirlestur").replace(/[^A-Za-z0-9_-]/g, "");
111
- const path = join(base, `altalandi-${stamp}-${id}.md`);
112
- await writeFile(path, reportMarkdown(input, data, options), "utf8");
113
- return path;
114
- }
1
+ // Before/after for people: a terminal view and an optional Markdown report.
2
+ //
3
+ // The website shows each change inline; in a terminal or an editor the
4
+ // before/after got lost. This module renders it the same way everywhere:
5
+ // beforeAfterText() plain (or coloured) block for a terminal or a tool result
6
+ // reportMarkdown() the report file: input, output, every change with its
7
+ // reason, and the verdict
8
+ // writeReport() writes it to ./altalandi-reports/ (or ALTALANDI_REPORT_DIR)
9
+ //
10
+ // Reports are opt-in (--report, or ALTALANDI_REPORT=1): they contain the text.
11
+
12
+ import { mkdir, writeFile } from "node:fs/promises";
13
+ import { join, resolve } from "node:path";
14
+
15
+ const VERDICT = {
16
+ rewritten: "Endurskrifað og merking staðfest",
17
+ refused: "Endurskrift hafnað: upprunalega textanum skilað óbreyttum",
18
+ unchanged: "Engin breyting þurfti",
19
+ unverified: "Merking ekki staðfest: farðu yfir áður en þú notar textann",
20
+ };
21
+
22
+ export function outcomeOf(data) {
23
+ if (data?.rejectionReason) return "refused";
24
+ if (data?.meaningPreserved === false) return "refused";
25
+ if (data?.rewriteAttempted === false) return "unchanged";
26
+ if (data?.meaningPreserved !== true) return "unverified";
27
+ return "rewritten";
28
+ }
29
+
30
+ export function reportEnabled(flag) {
31
+ if (flag !== undefined && flag !== null) return Boolean(flag);
32
+ return ["1", "true", "yes"].includes(String(process.env.ALTALANDI_REPORT || "").toLowerCase());
33
+ }
34
+
35
+ const paint = (on) => ({
36
+ red: (s) => (on ? `\x1b[31m${s}\x1b[0m` : s),
37
+ green: (s) => (on ? `\x1b[32m${s}\x1b[0m` : s),
38
+ dim: (s) => (on ? `\x1b[2m${s}\x1b[0m` : s),
39
+ bold: (s) => (on ? `\x1b[1m${s}\x1b[0m` : s),
40
+ });
41
+
42
+ /** A before/after block with the list of changes. `color` adds ANSI colours. */
43
+ export function beforeAfterText(input, data, { color = false } = {}) {
44
+ const c = paint(color);
45
+ const diffs = Array.isArray(data?.diffs) ? data.diffs : [];
46
+ const rule = (label) => c.dim(`── ${label} ${"─".repeat(Math.max(4, 40 - label.length))}`);
47
+ const lines = [
48
+ rule("Fyrir"), String(input ?? ""), "",
49
+ rule("Eftir"), String(data?.output ?? ""), "",
50
+ rule(`Breytingar (${diffs.length})`),
51
+ ];
52
+ if (!diffs.length) lines.push(c.dim("Engar breytingar."));
53
+ diffs.forEach((d, i) => {
54
+ lines.push(`${i + 1}. ${c.red("− " + String(d.before ?? "").trim())}`);
55
+ lines.push(` ${c.green("+ " + String(d.after ?? "").trim())}`);
56
+ if (d.reason) lines.push(` ${c.dim("↳ " + String(d.reason))}`);
57
+ });
58
+ const q = data?.quality || {};
59
+ lines.push("", c.bold(VERDICT[outcomeOf(data)]) + (q.nativenessScore != null ? c.dim(` · innfæddni ${q.nativenessScore}/100`) : ""));
60
+ return lines.join("\n");
61
+ }
62
+
63
+ const fence = (s) => {
64
+ const text = String(s ?? "");
65
+ const ticks = text.includes("```") ? "````" : "```";
66
+ return `${ticks}text\n${text}\n${ticks}`;
67
+ };
68
+ const cell = (s) => String(s ?? "").replace(/\|/g, "\\|").replace(/\r?\n/g, " ").trim();
69
+
70
+ export function reportMarkdown(input, data, options = {}) {
71
+ const q = data?.quality || {};
72
+ const diffs = Array.isArray(data?.diffs) ? data.diffs : [];
73
+ const warnings = Array.isArray(data?.warnings) ? data.warnings : [];
74
+ const tier = data?.billedTier || data?.tier || {};
75
+ const when = new Date().toISOString().replace("T", " ").slice(0, 19);
76
+ const out = [
77
+ `# Altalandi: ${data?.id || "yfirlestur"}`,
78
+ "",
79
+ `| | |`, `|---|---|`,
80
+ `| Tími | ${when} UTC |`,
81
+ `| Niðurstaða | ${VERDICT[outcomeOf(data)]} |`,
82
+ `| Frummál | ${data?.sourceLang || "?"} |`,
83
+ `| Tegund texta | ${options.domain || "general"} |`,
84
+ `| Málsnið | ${options.register || "neutral"} |`,
85
+ `| Innfæddni | ${q.nativenessScore ?? "n/a"}/100 |`,
86
+ `| Ritill | ${tier.writer || "standard"} / ${tier.scoring || "?"} |`,
87
+ `| Auðkenni | ${data?.id || "?"} |`,
88
+ "",
89
+ "## Fyrir", "", fence(input), "",
90
+ "## Eftir", "", fence(data?.output), "",
91
+ `## Breytingar (${diffs.length})`, "",
92
+ ];
93
+ if (diffs.length) {
94
+ out.push("| # | Fyrir | Eftir | Ástæða | Lag |", "|---|---|---|---|---|");
95
+ diffs.forEach((d, i) => out.push(`| ${i + 1} | ${cell(d.before)} | ${cell(d.after)} | ${cell(d.reason)} | ${cell(d.layer)} |`));
96
+ } else {
97
+ out.push("Engar breytingar.");
98
+ }
99
+ if (data?.rejectionReason) out.push("", "## Af hverju var endurskriftinni hafnað", "", cell(data.rejectionReason));
100
+ if (warnings.length) out.push("", "## Viðvaranir", "", ...warnings.map((w) => `- ${cell(w)}`));
101
+ if (data?.meaningNotes) out.push("", "## Athugun á merkingu", "", cell(data.meaningNotes));
102
+ return out.join("\n") + "\n";
103
+ }
104
+
105
+ /** Write the report; returns its path. */
106
+ export async function writeReport(input, data, options = {}, dir) {
107
+ const base = resolve(dir || process.env.ALTALANDI_REPORT_DIR || join(process.cwd(), "altalandi-reports"));
108
+ await mkdir(base, { recursive: true });
109
+ const stamp = new Date().toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
110
+ const id = String(data?.id || "yfirlestur").replace(/[^A-Za-z0-9_-]/g, "");
111
+ const path = join(base, `altalandi-${stamp}-${id}.md`);
112
+ await writeFile(path, reportMarkdown(input, data, options), "utf8");
113
+ return path;
114
+ }
115
+
116
+ // ── The review block ─────────────────────────────────────────────────────────
117
+ // One Markdown block an agent pastes into the chat as it is: verdict, the text
118
+ // before and after, every change with its reason, what the call cost and what
119
+ // is left. `--review` prints it; the MCP tool returns it. The agent does not
120
+ // rebuild it from JSON, so every run looks the same.
121
+
122
+ const REJECTION = [
123
+ ["MEANING DRIFT", "merkingin breyttist í endurskriftinni"],
124
+ ["HARD INVARIANT LOST", "tala, dagsetning, nafn eða neitun hefði glatast"],
125
+ ["UNVERIFIED", "ekki tókst að staðfesta að merkingin héldist"],
126
+ ["CANDIDATE VETO", "endurskriftin reyndist ekki betri en frumtextinn"],
127
+ ];
128
+
129
+ export function rejectionText(reason) {
130
+ const raw = String(reason ?? "").trim();
131
+ if (!raw) return "";
132
+ const hit = REJECTION.find(([code]) => raw.toUpperCase().startsWith(code));
133
+ return hit ? `${hit[1][0].toUpperCase()}${hit[1].slice(1)} (${raw})` : raw;
134
+ }
135
+
136
+ /** 1688 → "1.688" (Icelandic thousands separator). */
137
+ export function formatInt(n) {
138
+ return String(Math.round(Number(n))).replace(/\B(?=(\d{3})+(?!\d))/g, ".");
139
+ }
140
+
141
+ /** 487.5 → "487,50 kr.", 500 → "500 kr." */
142
+ export function formatIsk(n) {
143
+ const v = Math.round(Number(n) * 100) / 100;
144
+ const sign = v < 0 ? "−" : "";
145
+ const abs = Math.abs(v);
146
+ const whole = formatInt(Math.trunc(abs));
147
+ const cents = Math.round((abs - Math.trunc(abs)) * 100);
148
+ return `${sign}${whole}${cents ? "," + String(cents).padStart(2, "0") : ""} kr.`;
149
+ }
150
+
151
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
152
+
153
+ /**
154
+ * What the call cost and what is left. `data.billing` (sent by newer engines)
155
+ * is exact; otherwise the cost is the balance difference between /v1/me
156
+ * before and after the call, which parallel calls on the same account blur.
157
+ */
158
+ export function costLines(data, before, after) {
159
+ if (data?.test) return ["- Prófunarlykill: ekkert var rukkað og ekkert líkan keyrði."];
160
+ const b = data?.billing || {};
161
+ let charged = num(b.chargedIsk);
162
+ const exact = charged != null;
163
+ if (charged == null && num(before?.balanceIsk) != null && num(after?.balanceIsk) != null) {
164
+ charged = Math.round((before.balanceIsk - after.balanceIsk) * 100) / 100;
165
+ }
166
+ const units = num(b.units);
167
+ const unitsText = units != null ? ` · ${formatInt(units)} einingar` : "";
168
+ const lines = [];
169
+ if (charged == null) lines.push(`- Þetta kall: kostnaður óþekktur${unitsText}`);
170
+ else if (charged < 0) lines.push(`- Þetta kall: ekki hægt að reikna (inneignin breyttist á meðan)${unitsText}`);
171
+ else if (charged === 0) lines.push(b.paidFrom === "credit"
172
+ ? `- Þetta kall: 0 kr. af inneign${unitsText}`
173
+ : `- Þetta kall: 0 kr., ókeypis dagskammtur${unitsText}`);
174
+ else lines.push(`- Þetta kall: ${formatIsk(charged)} af inneign${exact ? "" : " (mismunur á inneign fyrir og eftir)"}${unitsText}`);
175
+
176
+ const balance = num(b.balanceIsk) ?? num(after?.balanceIsk);
177
+ const left = num(b.freeUnitsLeftToday) ?? num(after?.freeUnitsLeftToday);
178
+ const daily = num(after?.freeDailyUnits) ?? num(before?.freeDailyUnits);
179
+ const parts = [];
180
+ if (balance != null) parts.push(`inneign ${formatIsk(balance)}`);
181
+ if (left != null && daily != null) parts.push(`ókeypis eftir í dag ${formatInt(left)} af ${formatInt(daily)} einingum`);
182
+ else if (left != null) parts.push(`ókeypis eftir í dag ${formatInt(left)} einingar`);
183
+ else if (daily != null && daily > 0) parts.push(`ókeypis ${formatInt(daily)} einingar á dag`);
184
+ if (parts.length) lines.push(`- Staða: ${parts.join(" · ")}`);
185
+ else lines.push("- Staða: náðist ekki að sækja (`npx altalandi whoami`)");
186
+ if (balance != null && balance < 50) lines.push("- Bæta við inneign: https://altalandi.is/portal");
187
+ return lines;
188
+ }
189
+
190
+ function clip(text, max) {
191
+ const s = String(text ?? "");
192
+ if (!max || s.length <= max) return { text: s, clipped: false };
193
+ const cut = s.lastIndexOf(" ", max);
194
+ return { text: s.slice(0, cut > max * 0.6 ? cut : max).trimEnd() + " …", clipped: true };
195
+ }
196
+
197
+ // A quoted line that starts with "#" or ">" would render as a heading or a
198
+ // nested quote; escape that first character.
199
+ const quote = (s) => String(s).split(/\r?\n/)
200
+ .map((l) => (l.trim() ? `> ${l.replace(/^(\s*)([#>])/, "$1\\$2")}` : ">")).join("\n");
201
+ const inline = (s) => String(s ?? "").replace(/\s+/g, " ").trim().replace(/([\\`*_~[\]<>|#])/g, "\\$1");
202
+
203
+ /**
204
+ * The block to show the user. `before`/`after` are /v1/me responses (either
205
+ * may be null). Long texts are shortened for the chat; `fullTextAt` names the
206
+ * file that holds the whole output.
207
+ */
208
+ export function reviewMarkdown(input, data, { before = null, after = null, maxChars = 1500, maxChanges = 30, fullTextAt = "" } = {}) {
209
+ const q = data?.quality || {};
210
+ const outcome = outcomeOf(data);
211
+ const diffs = Array.isArray(data?.diffs) ? data.diffs : [];
212
+ const score = q.nativenessScore != null ? ` · innfæddni ${q.nativenessScore}/100` : "";
213
+ const out = [`**Altalandi:** ${VERDICT[outcome]}${score}`, ""];
214
+
215
+ const inText = clip(input, maxChars);
216
+ out.push("**Fyrir**", quote(inText.text), "");
217
+ const same = String(data?.output ?? "").trim() === String(input ?? "").trim();
218
+ if (same) {
219
+ out.push("**Eftir**", outcome === "refused" ? "> Óbreytt: upprunalega textanum var skilað." : "> Óbreytt.", "");
220
+ } else {
221
+ const outText = clip(data?.output, maxChars);
222
+ out.push("**Eftir**", quote(outText.text), "");
223
+ if (outText.clipped || inText.clipped) {
224
+ out.push(`_Textinn er styttur hér${fullTextAt ? `; allur textinn er í ${fullTextAt}` : ""}._`, "");
225
+ }
226
+ }
227
+
228
+ if (outcome === "refused" && data?.rejectionReason) {
229
+ out.push(`**Af hverju óbreytt:** ${inline(rejectionText(data.rejectionReason))}`, "");
230
+ }
231
+
232
+ if (diffs.length) {
233
+ out.push(`**Breytingar (${diffs.length})**`);
234
+ diffs.slice(0, maxChanges).forEach((d, i) => {
235
+ out.push(`${i + 1}. „${inline(d.before)}“ → „${inline(d.after)}“`);
236
+ if (d.reason) out.push(` Ástæða: ${inline(d.reason)}`);
237
+ });
238
+ if (diffs.length > maxChanges) out.push(` … og ${diffs.length - maxChanges} í viðbót (sjá \`--report\`).`);
239
+ out.push("");
240
+ } else if (data?.diffStatus === "translation" || (data?.sourceLang === "en" && !same)) {
241
+ out.push("**Breytingar:** þýðing úr ensku, svo allur textinn er nýr.", "");
242
+ } else if (!same && data?.diffStatus === "unavailable") {
243
+ out.push("**Breytingar:** textinn breyttist en listi yfir breytingar fékkst ekki. Berðu saman fyrir og eftir.", "");
244
+ } else if (outcome !== "refused") {
245
+ out.push("**Breytingar:** engar.", "");
246
+ }
247
+
248
+ // The refusal reason is already shown above; its warning repeats it.
249
+ const code = REJECTION.find(([c]) => String(data?.rejectionReason ?? "").toUpperCase().startsWith(c))?.[0];
250
+ const warnings = (Array.isArray(data?.warnings) ? data.warnings : [])
251
+ .filter((w) => !String(w).startsWith("TEST KEY") && !(code && String(w).toUpperCase().startsWith(code)));
252
+ if (warnings.length && outcome !== "rewritten") {
253
+ out.push("**Athugasemdir**", ...warnings.slice(0, 3).map((w) => `- ${inline(w)}`), "");
254
+ }
255
+
256
+ const term = data?.terminology;
257
+ if (term?.dictionaries?.length) {
258
+ // Offered is not used: only a term verified in the output (by form or BÍN
259
+ // lemma) is "í textanum"; a stem-only match is "líklega"; an older engine
260
+ // without `usage` is listed as offered. The caller's glossary wins.
261
+ const usage = (x) => x.status === "overridden" ? "overridden"
262
+ : x.usage ?? (x.inOutput === true ? "verified" : x.inOutput === false ? "absent" : "unchecked");
263
+ const tag = { verified: "í textanum", probable: "líklega í textanum", absent: "ekki notað",
264
+ unchecked: "boðið", overridden: "þitt orðasafn gekk framar" };
265
+ const shown = (term.terms || []).filter((x) => !x.inText);
266
+ out.push(`**Íðorð:** ${inline(term.dictionaries.map((d) => d.name).join(", "))} (úr Íðorðabanka Árnastofnunar)`);
267
+ if (!shown.some((x) => x.status !== "overridden")) out.push("- Ekkert íðorð var boðið fyrir þennan texta.");
268
+ shown.slice(0, 12).forEach((x) => {
269
+ const u = usage(x);
270
+ const is = u === "verified" || u === "probable" ? (x.used ?? x.is) : x.is;
271
+ out.push(`- ${inline(x.source)} → ${inline(is)} (${tag[u]})`);
272
+ });
273
+ if (shown.length > 12) out.push(`- … og ${shown.length - 12} í viðbót`);
274
+ out.push("");
275
+ }
276
+
277
+ out.push("**Kostnaður**", ...costLines(data, before, after));
278
+ if (data?.id) out.push(`- Auðkenni: \`${data.id}\``);
279
+ return out.join("\n") + "\n";
280
+ }
package/server.mjs CHANGED
@@ -11,17 +11,37 @@
11
11
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
12
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
13
  import { z } from "zod";
14
- import { requestRefine } from "./api-client.mjs";
15
- import { formatRefineResponse, outcomeOf } from "./format-response.mjs";
14
+ import { requestMe, requestRefine, requestSuggestionDecisions } from "./api-client.mjs";
15
+ import { beginLogin } from "./device.mjs";
16
+ import { formatProofreadResponse, outcomeOf } from "./format-response.mjs";
16
17
  import { resolveKey } from "./key.mjs";
17
- import { beforeAfterText, reportEnabled, writeReport } from "./report.mjs";
18
+ import { reportEnabled, reviewMarkdown, writeReport } from "./report.mjs";
18
19
 
19
20
  const BASE = (process.env.ALTALANDI_BASE_URL || "https://altalandi.is/api").replace(/\/$/, "");
20
- const server = new McpServer({ name: "altalandi", version: "0.3.0" });
21
+ const server = new McpServer({ name: "altalandi", version: "0.6.0" });
22
+
23
+ // No key yet: offer the sign-in instead of an error. The approval is picked up
24
+ // in the background, so the next call simply works (Claude Desktop, where
25
+ // nobody runs `npx altalandi login`).
26
+ async function connectFirst() {
27
+ const r = await beginLogin(BASE, { client: process.env.ALTALANDI_CLIENT_NAME || "Claude Desktop" });
28
+ if (r.error) {
29
+ return { content: [{ type: "text", text: `Altalandi is not connected and the sign-in could not start: ${r.error}` }], isError: true };
30
+ }
31
+ return {
32
+ content: [{ type: "text", text:
33
+ "**Tengdu Altalandi fyrst** (connect Altalandi first).\n\n" +
34
+ `1. Opnaðu ${r.link}\n` +
35
+ "2. Skráðu þig inn eða stofnaðu aðgang (2.000 einingar ókeypis á dag, 500 kr. prufuinneign).\n" +
36
+ `3. Staðfestu kóðann **${r.code}** og smelltu á **Tengja**.\n\n` +
37
+ "Show the person this link and code, wait until they say it is done, then call the tool again with the same text." }],
38
+ isError: true,
39
+ };
40
+ }
21
41
 
22
42
  server.tool(
23
43
  "refine_icelandic",
24
- "Rewrite English or stiff/AI-translated Icelandic into native-sounding Icelandic, with a meaning-preservation check. Use whenever you need Icelandic that must read like a native wrote it (marketing copy, UI strings, prose). Returns the refined text, a 0-100 nativeness score, an explicit meaning verdict, and every API warning. Only 'meaning preserved' is verified; 'MEANING UNVERIFIED' requires review. On drift it returns the original unchanged. After every call, show the user the before/after block from the result (Fyrir / Eftir) and the numbered list of changes with their reasons, then mention the report path if one was written. Never replace the user's text silently.",
44
+ "Rewrite English or stiff/AI-translated Icelandic into native-sounding Icelandic, with a meaning-preservation check. Use whenever you need Icelandic that must read like a native wrote it (marketing copy, UI strings, prose). Returns the refined text, a 0-100 nativeness score, an explicit meaning verdict, and every API warning. Only 'meaning preserved' is verified; 'MEANING UNVERIFIED' requires review. On drift it returns the original unchanged. After every call, show the user the first part of the result (the Altalandi block: verdict, Fyrir, Eftir, every change with its reason, Kostnaður) exactly as it is, before anything else; do not rewrite or summarise it. The second part is the full refined text to apply. Never replace the user's text silently, and never present your own edits as Altalandi's.",
25
45
  {
26
46
  text: z.string().describe("Text to refine (English or Icelandic)."),
27
47
  domain: z.enum(["general", "marketing", "data-analysis", "childrens-fiction"]).optional()
@@ -31,21 +51,21 @@ server.tool(
31
51
  .describe("Source-language hint. Default auto."),
32
52
  report: z.boolean().optional()
33
53
  .describe("Also write a Markdown report (input, output, every change with its reason, verdict) to ./altalandi-reports/. Default: on when ALTALANDI_REPORT=1."),
54
+ noStore: z.boolean().optional()
55
+ .describe("true = Altalandi never keeps this text. Otherwise free use is kept, scrubbed of personal data, to improve the service. Set it when the user asks that their text not be kept, or the text is confidential."),
34
56
  },
35
- async ({ text, domain, register, sourceLang, report }) => {
57
+ async ({ text, domain, register, sourceLang, report, noStore }) => {
36
58
  // Resolved per call, so a login made after the server started is picked up.
37
59
  const { key: KEY } = resolveKey();
38
- if (!KEY) {
39
- return { content: [{ type: "text", text:
40
- "No Altalandi key found. Run `npx altalandi login` once on this computer, or set ALTALANDI_API_KEY." }],
41
- isError: true };
42
- }
60
+ if (!KEY) return connectFirst();
61
+ const before = await requestMe(BASE, KEY);
43
62
  try {
44
63
  const { response, data } = await requestRefine(BASE, KEY, {
45
64
  text,
46
65
  domain,
47
66
  register,
48
67
  sourceLang,
68
+ ...(noStore ? { contribute: false } : {}),
49
69
  });
50
70
  if (!response.ok) {
51
71
  return { content: [{ type: "text", text: `Altalandi error ${response.status}: ${data.detail || response.statusText}` }], isError: true };
@@ -59,7 +79,10 @@ server.tool(
59
79
  }
60
80
  }
61
81
  return {
62
- content: [{ type: "text", text: `${formatRefineResponse(data)}\n\n${beforeAfterText(text, data)}${reportLine}` }],
82
+ content: [
83
+ { type: "text", text: reviewMarkdown(text, data, { before, after: await requestMe(BASE, KEY) }) + reportLine },
84
+ { type: "text", text: `Full refined text (apply only after showing the block above):\n\n${String(data.output ?? "")}` },
85
+ ],
63
86
  // MCP callers should not treat a refused rewrite (drift, a lost
64
87
  // invariant, or a candidate veto) or an unverified response as a
65
88
  // successfully refined artifact. "No rewrite needed" is a success. The
@@ -72,4 +95,71 @@ server.tool(
72
95
  },
73
96
  );
74
97
 
98
+ server.tool(
99
+ "proofread_icelandic",
100
+ "Proofread Icelandic text the person wrote, keeping their wording. Proven errors (punctuation, word boundaries, a misspelt word one slip from the right one) are fixed in the returned text. Other corrections come back as numbered suggestions with a reason and, when known, how sure each is ('both checks agree', 'one check', 'rule (BÍN)'). Show the corrected text and every suggestion, ask which to apply, apply only those the person accepts, then call record_suggestion_decisions with the resultId and a decision for every suggestion. suggestions='apply' applies the kinds measured right at least minPrecision of the time and offers the rest; 'off' returns only the safe fixes.",
101
+ {
102
+ text: z.string().describe("The Icelandic text to proofread."),
103
+ suggestions: z.enum(["offer", "apply", "off"]).optional()
104
+ .describe("What happens to suggestions. Default offer (the person chooses)."),
105
+ minPrecision: z.number().min(0.5).max(0.99).optional()
106
+ .describe("With suggestions='apply': the lowest measured precision a kind of suggestion needs to be applied. Default 0.9."),
107
+ noStore: z.boolean().optional()
108
+ .describe("true = Altalandi never keeps this text (nor the words of its suggestions)."),
109
+ },
110
+ async ({ text, suggestions, minPrecision, noStore }) => {
111
+ const { key: KEY } = resolveKey();
112
+ if (!KEY) return connectFirst();
113
+ try {
114
+ const { response, data } = await requestRefine(BASE, KEY, {
115
+ text, task: "proofread", sourceLang: "is",
116
+ ...(suggestions ? { suggestions } : {}), ...(minPrecision ? { minPrecision } : {}),
117
+ ...(noStore ? { contribute: false } : {}),
118
+ });
119
+ if (!response.ok) {
120
+ return { content: [{ type: "text", text: `Altalandi error ${response.status}: ${data.detail || response.statusText}` }], isError: true };
121
+ }
122
+ return { content: [{ type: "text", text: formatProofreadResponse(data) }] };
123
+ } catch (e) {
124
+ return { content: [{ type: "text", text: `Request failed: ${e.message}` }], isError: true };
125
+ }
126
+ },
127
+ );
128
+
129
+ server.tool(
130
+ "record_suggestion_decisions",
131
+ "After proofread_icelandic: record, for every numbered suggestion, whether the person used it or ignored it. Free; no model runs; no text is sent. Call it once the person has decided.",
132
+ {
133
+ resultId: z.string().regex(/^r_[0-9a-f]{8,32}$/).describe("The resultId proofread_icelandic returned."),
134
+ decisions: z.array(z.object({ index: z.number().int().min(0).max(499), decision: z.enum(["used", "ignored"]) }))
135
+ .min(1).max(100).describe("Each suggestion's number and what the person chose."),
136
+ },
137
+ async ({ resultId, decisions }) => {
138
+ const { key: KEY } = resolveKey();
139
+ if (!KEY) return connectFirst();
140
+ const r = await requestSuggestionDecisions(BASE, KEY, { resultId, decisions, client: "claude" });
141
+ if (!r.ok) return { content: [{ type: "text", text: `Could not record the decisions: ${r.error}` }], isError: true };
142
+ return { content: [{ type: "text", text: `Recorded ${r.recorded} decision(s)${r.refused ? `, ${r.refused} refused` : ""}. Thank you.` }] };
143
+ },
144
+ );
145
+
146
+ server.tool(
147
+ "altalandi_status",
148
+ "The connected Altalandi account: balance, today's free units left, and where to add credit. Use when the person asks about their balance or credit, or before a large batch.",
149
+ {},
150
+ async () => {
151
+ const { key: KEY } = resolveKey();
152
+ if (!KEY) return connectFirst();
153
+ const me = await requestMe(BASE, KEY);
154
+ if (!me) return { content: [{ type: "text", text: "Could not reach Altalandi to read the account." }], isError: true };
155
+ const isk = (n) => (typeof n === "number" ? `${n.toFixed(2).replace(".", ",").replace(/,00$/, "")} kr.` : "—");
156
+ const free = me.freeUnitsLeftToday != null && me.freeDailyUnits != null
157
+ ? `${me.freeUnitsLeftToday} of ${me.freeDailyUnits} free units left today`
158
+ : me.freeDailyUnits ? `${me.freeDailyUnits} free units a day` : "no free daily units";
159
+ return { content: [{ type: "text", text:
160
+ `**Altalandi:** ${me.username ?? "connected"} · inneign ${isk(me.balanceIsk)} · ${free}.\n` +
161
+ "Add credit: https://altalandi.is/dashboard/billing" }] };
162
+ },
163
+ );
164
+
75
165
  await server.connect(new StdioServerTransport());