altalandi-mcp 0.1.0 → 0.2.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 CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Altalandi
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.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Altalandi
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,53 +1,71 @@
1
1
  # altalandi-mcp
2
2
 
3
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 (Claude Code, etc.) can
6
- call. stdio transport.
4
+ [Altalandi](https://altalandi.is), native-Icelandic refinement with a
5
+ meaning-preservation check, as a tool any MCP client can call. stdio transport.
7
6
 
8
- ## Use it in Claude Code
7
+ ## Set up (Claude Code or Codex)
9
8
 
10
- Add to your MCP config (`.mcp.json` in the project, or your Claude Code config):
9
+ Connect this computer once. This opens altalandi.is, you approve, and the key
10
+ is stored locally. You never copy it:
11
11
 
12
- ```json
13
- {
14
- "mcpServers": {
15
- "altalandi": {
16
- "command": "npx",
17
- "args": ["-y", "altalandi-mcp"],
18
- "env": { "ALTALANDI_API_KEY": "rtv_..." }
19
- }
20
- }
21
- }
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
22
  ```
23
23
 
24
- Mint a key at <https://altalandi.is/portal>. Then ask Claude to use the
25
- **`refine_icelandic`** tool.
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
+ ```
26
36
 
27
37
  ## The tool
28
38
 
29
- `refine_icelandic({ text, domain?, register?, sourceLang? })` → the native
30
- Icelandic text, a 0–100 nativeness score, and whether meaning was preserved.
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.
31
42
 
32
43
  - `domain`: `general` \| `marketing` \| `data-analysis` \| `childrens-fiction`
33
44
  - `register`: `formal` \| `neutral` \| `warm`
34
45
  - `sourceLang`: `auto` \| `en` \| `is`
35
46
 
36
47
  **Meaning guarantee:** on Icelandic input where the rewrite can't be verified to
37
- preserve meaning, the tool returns the **original unchanged** and flags it —
48
+ preserve meaning, the tool returns the **original unchanged** and flags it,
38
49
  never a silent change.
39
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
+
40
55
  ## Config
41
56
 
42
57
  | env | default | purpose |
43
58
  |-----|---------|---------|
44
- | `ALTALANDI_API_KEY` | — | your key (Bearer). Mint at https://altalandi.is/portal |
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 |
45
61
  | `ALTALANDI_BASE_URL` | `https://altalandi.is/api` | API base (override for self-hosting) |
46
62
 
47
- ## Run standalone
63
+ Each API request has a fixed 300-second deadline, including the response body,
64
+ so a stalled provider cannot leave the tool hanging.
48
65
 
49
- ```bash
50
- ALTALANDI_API_KEY=rtv_... npx -y altalandi-mcp # speaks MCP over stdio
51
- ```
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>.
52
70
 
53
- MIT licensed. Requires Node ≥ 18. The hosted API is metered per key.
71
+ MIT licensed. Requires Node ≥ 18.
package/api-client.mjs ADDED
@@ -0,0 +1,54 @@
1
+ export const REFINE_TIMEOUT_MS = 300_000;
2
+
3
+ /**
4
+ * Send one MCP refinement request under a single deadline. The timer remains
5
+ * active while the response body is consumed, since a server can send headers
6
+ * and then stall indefinitely.
7
+ *
8
+ * Timer/fetch injection is intentionally private test plumbing; production
9
+ * callers use the stable 300-second timeout and platform implementations.
10
+ */
11
+ export async function requestRefine(
12
+ baseUrl,
13
+ apiKey,
14
+ body,
15
+ {
16
+ fetchImpl = globalThis.fetch,
17
+ setTimeoutImpl = globalThis.setTimeout,
18
+ clearTimeoutImpl = globalThis.clearTimeout,
19
+ } = {},
20
+ ) {
21
+ const controller = new AbortController();
22
+ const timeout = setTimeoutImpl(() => controller.abort(), REFINE_TIMEOUT_MS);
23
+
24
+ try {
25
+ let response;
26
+ try {
27
+ response = await fetchImpl(`${baseUrl}/v1/refine`, {
28
+ method: "POST",
29
+ headers: {
30
+ "content-type": "application/json",
31
+ authorization: `Bearer ${apiKey}`,
32
+ },
33
+ body: JSON.stringify(body),
34
+ signal: controller.signal,
35
+ });
36
+ } catch (error) {
37
+ if (controller.signal.aborted) {
38
+ throw new Error(`Altalandi request timed out after ${REFINE_TIMEOUT_MS} ms.`);
39
+ }
40
+ throw error;
41
+ }
42
+
43
+ try {
44
+ return { response, data: await response.json() };
45
+ } catch (error) {
46
+ if (controller.signal.aborted) {
47
+ throw new Error(`Altalandi request timed out after ${REFINE_TIMEOUT_MS} ms.`);
48
+ }
49
+ return { response, data: {} };
50
+ }
51
+ } finally {
52
+ clearTimeoutImpl(timeout);
53
+ }
54
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * What actually happened to the caller's text. `meaningPreserved` alone cannot
3
+ * say: when the candidate veto refuses a broken rewrite, the meaning check has
4
+ * already passed, so `meaningPreserved` is still true while the ORIGINAL comes
5
+ * back. An agent reading "meaning preserved" would then report a rewrite that
6
+ * never shipped.
7
+ *
8
+ * rewritten the output is a verified rewrite
9
+ * refused a rewrite was produced and rejected; the original is returned
10
+ * unchanged the text needed no rewrite
11
+ * unverified meaning could not be established; do not present as verified
12
+ *
13
+ * Responses from older API versions have no `rewriteAttempted` or
14
+ * `rejectionReason`; they fall through to the meaningPreserved verdict, exactly
15
+ * as before.
16
+ */
17
+ export function outcomeOf(data) {
18
+ if (data?.rejectionReason) return "refused";
19
+ if (data?.meaningPreserved === false) return "refused";
20
+ if (data?.rewriteAttempted === false) return "unchanged";
21
+ if (data?.meaningPreserved !== true) return "unverified";
22
+ return "rewritten";
23
+ }
24
+
25
+ export function formatRefineResponse(data) {
26
+ const quality = data?.quality || {};
27
+ const outcome = outcomeOf(data);
28
+ const lines = [
29
+ String(data?.output ?? ""),
30
+ "",
31
+ `— nativeness ${quality.nativenessScore ?? "n/a"}/100`,
32
+ ];
33
+
34
+ if (outcome === "refused") {
35
+ if (data?.meaningPreserved === false) {
36
+ lines.push("— ⚠ MEANING DRIFT — original returned unchanged");
37
+ } else {
38
+ lines.push("— ⚠ REWRITE REFUSED — the original is returned unchanged");
39
+ }
40
+ if (data?.rejectionReason) lines.push(`— reason: ${String(data.rejectionReason)}`);
41
+ } else if (outcome === "unchanged") {
42
+ lines.push("— no rewrite needed — the text is returned as it was");
43
+ } else if (outcome === "unverified") {
44
+ lines.push("— ⚠ MEANING UNVERIFIED — check did not run or could not complete; review before use");
45
+ } else {
46
+ lines.push("— meaning preserved");
47
+ }
48
+
49
+ const warnings = Array.isArray(data?.warnings) ? data.warnings : [];
50
+ if (warnings.length) {
51
+ lines.push("", "Warnings:", ...warnings.map((warning) => `- ${String(warning)}`));
52
+ }
53
+
54
+ return lines.join("\n");
55
+ }
package/key.mjs ADDED
@@ -0,0 +1,30 @@
1
+ // Where the MCP server finds its API key, in order:
2
+ // 1. ALTALANDI_API_KEY (or ALTALANDI_KEY) in its environment;
3
+ // 2. the key `npx altalandi login` stored on this computer.
4
+ // The second makes the MCP server plug-and-play: after one login, adding it
5
+ // to Claude Code or Codex needs no secret in any config file. The location
6
+ // matches the CLI's (tools/cli/auth.mjs); ALTALANDI_CONFIG_DIR overrides it.
7
+
8
+ import { readFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import path from "node:path";
11
+
12
+ export function configDir({ env = process.env, home = homedir(), platform = process.platform } = {}) {
13
+ if (env.ALTALANDI_CONFIG_DIR) return env.ALTALANDI_CONFIG_DIR;
14
+ if (platform === "win32") return path.join(env.APPDATA || path.join(home, "AppData", "Roaming"), "altalandi");
15
+ return path.join(env.XDG_CONFIG_HOME || path.join(home, ".config"), "altalandi");
16
+ }
17
+
18
+ /** { key, source } where source is "env", "login" or "none". Never throws. */
19
+ export function resolveKey(opts = {}) {
20
+ const env = opts.env ?? process.env;
21
+ const fromEnv = env.ALTALANDI_API_KEY || env.ALTALANDI_KEY;
22
+ if (fromEnv) return { key: fromEnv, source: "env" };
23
+ try {
24
+ const data = JSON.parse(readFileSync(path.join(configDir({ ...opts, env }), "credentials.json"), "utf8"));
25
+ if (data && typeof data.apiKey === "string" && data.apiKey) return { key: data.apiKey, source: "login" };
26
+ } catch {
27
+ /* no stored login */
28
+ }
29
+ return { key: "", source: "none" };
30
+ }
package/package.json CHANGED
@@ -1,18 +1,47 @@
1
1
  {
2
2
  "name": "altalandi-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.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
- "bin": { "altalandi-mcp": "./server.mjs" },
7
- "files": ["server.mjs", "README.md", "LICENSE"],
6
+ "bin": {
7
+ "altalandi-mcp": "server.mjs"
8
+ },
9
+ "files": [
10
+ "server.mjs",
11
+ "key.mjs",
12
+ "api-client.mjs",
13
+ "format-response.mjs",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {
18
+ "test": "node --test test/*.test.mjs"
19
+ },
8
20
  "keywords": [
9
- "icelandic", "íslenska", "mcp", "model-context-protocol", "claude",
10
- "claude-code", "altalandi", "nlp", "translation", "agent"
21
+ "icelandic",
22
+ "íslenska",
23
+ "mcp",
24
+ "model-context-protocol",
25
+ "claude",
26
+ "claude-code",
27
+ "codex",
28
+ "altalandi",
29
+ "nlp",
30
+ "translation",
31
+ "agent"
11
32
  ],
12
33
  "homepage": "https://altalandi.is",
13
- "repository": { "type": "git", "url": "git+https://github.com/pbidhjorvar/ritvit.git", "directory": "tools/mcp" },
14
- "bugs": { "url": "https://github.com/pbidhjorvar/ritvit/issues" },
15
- "engines": { "node": ">=18" },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/pbidhjorvar/ritvit.git",
37
+ "directory": "tools/mcp"
38
+ },
39
+ "bugs": {
40
+ "url": "https://github.com/pbidhjorvar/ritvit/issues"
41
+ },
42
+ "engines": {
43
+ "node": ">=18"
44
+ },
16
45
  "dependencies": {
17
46
  "@modelcontextprotocol/sdk": "^1.0.0",
18
47
  "zod": "^3.23.8"
package/server.mjs CHANGED
@@ -2,50 +2,58 @@
2
2
  // Altalandi MCP server — exposes native-Icelandic refinement as a tool that
3
3
  // Claude Code (and any MCP client) can call. stdio transport.
4
4
  //
5
- // ALTALANDI_API_KEY=... npx -y altalandi-mcp
6
- //
7
- // Claude Code config (.mcp.json or your Claude Code config) — no install:
8
- // { "mcpServers": { "altalandi": {
9
- // "command": "npx",
10
- // "args": ["-y", "altalandi-mcp"],
11
- // "env": { "ALTALANDI_API_KEY": "rtv_..." } } } }
5
+ // The key comes from ALTALANDI_API_KEY or, failing that, from the login that
6
+ // `npx altalandi login` stored on this computer, so no config file ever holds
7
+ // a secret:
8
+ // claude mcp add altalandi -s user -- npx -y altalandi-mcp
9
+ // codex mcp add altalandi -- npx -y altalandi-mcp
12
10
 
13
11
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
14
12
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
15
13
  import { z } from "zod";
14
+ import { requestRefine } from "./api-client.mjs";
15
+ import { formatRefineResponse, outcomeOf } from "./format-response.mjs";
16
+ import { resolveKey } from "./key.mjs";
16
17
 
17
18
  const BASE = (process.env.ALTALANDI_BASE_URL || "https://altalandi.is/api").replace(/\/$/, "");
18
- const KEY = process.env.ALTALANDI_API_KEY || process.env.ALTALANDI_KEY;
19
-
20
- const server = new McpServer({ name: "altalandi", version: "0.1.0" });
19
+ const server = new McpServer({ name: "altalandi", version: "0.2.0" });
21
20
 
22
21
  server.tool(
23
22
  "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, and whether meaning was preserved (on drift it returns the original unchanged).",
23
+ "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.",
25
24
  {
26
25
  text: z.string().describe("Text to refine (English or Icelandic)."),
27
26
  domain: z.enum(["general", "marketing", "data-analysis", "childrens-fiction"]).optional()
28
27
  .describe("Tunes register/idiom. Default general."),
29
28
  register: z.enum(["formal", "neutral", "warm"]).optional().describe("Tone. Default neutral."),
29
+ sourceLang: z.enum(["auto", "en", "is"]).optional()
30
+ .describe("Source-language hint. Default auto."),
30
31
  },
31
- async ({ text, domain, register }) => {
32
+ async ({ text, domain, register, sourceLang }) => {
33
+ // Resolved per call, so a login made after the server started is picked up.
34
+ const { key: KEY } = resolveKey();
32
35
  if (!KEY) {
33
- return { content: [{ type: "text", text: "ALTALANDI_API_KEY is not set for the MCP server." }], isError: true };
36
+ return { content: [{ type: "text", text:
37
+ "No Altalandi key found. Run `npx altalandi login` once on this computer, or set ALTALANDI_API_KEY." }],
38
+ isError: true };
34
39
  }
35
40
  try {
36
- const res = await fetch(`${BASE}/v1/refine`, {
37
- method: "POST",
38
- headers: { "content-type": "application/json", authorization: `Bearer ${KEY}` },
39
- body: JSON.stringify({ text, domain, register }),
41
+ const { response, data } = await requestRefine(BASE, KEY, {
42
+ text,
43
+ domain,
44
+ register,
45
+ sourceLang,
40
46
  });
41
- const data = await res.json().catch(() => ({}));
42
- if (!res.ok) {
43
- return { content: [{ type: "text", text: `Altalandi error ${res.status}: ${data.detail || res.statusText}` }], isError: true };
47
+ if (!response.ok) {
48
+ return { content: [{ type: "text", text: `Altalandi error ${response.status}: ${data.detail || response.statusText}` }], isError: true };
44
49
  }
45
- const q = data.quality || {};
46
- const drift = data.meaningPreserved === false ? " (MEANING DRIFT — original returned unchanged)" : "";
47
50
  return {
48
- content: [{ type: "text", text: `${data.output}\n\n— nativeness ${q.nativenessScore ?? "n/a"}/100${drift}` }],
51
+ content: [{ type: "text", text: formatRefineResponse(data) }],
52
+ // MCP callers should not treat a refused rewrite (drift, a lost
53
+ // invariant, or a candidate veto) or an unverified response as a
54
+ // successfully refined artifact. "No rewrite needed" is a success. The
55
+ // full result remains visible so an agent can explain it.
56
+ isError: ["refused", "unverified"].includes(outcomeOf(data)),
49
57
  };
50
58
  } catch (e) {
51
59
  return { content: [{ type: "text", text: `Request failed: ${e.message}` }], isError: true };