itfin-mcp 0.1.5 → 0.1.6

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
@@ -6,7 +6,9 @@ An unofficial tool. It is not made, endorsed or supported by ITFin.
6
6
 
7
7
  ## Install (about 2 minutes)
8
8
 
9
- You need macOS, Node.js 22+ and Chrome, Edge, Brave, Arc or Chromium.
9
+ You need macOS or Linux, Node.js 22+ and Chrome, Edge, Brave or Chromium (Arc on macOS only).
10
+
11
+ On Linux you also need `secret-tool` (Debian/Ubuntu: `sudo apt install libsecret-tools`) with a keyring such as GNOME Keyring. Reminders use `systemd-run` and `notify-send`. Claude Desktop has no Linux build, so use Claude Code or Codex.
10
12
 
11
13
  1. Run:
12
14
  ```bash
@@ -45,7 +47,7 @@ CLI mode installs:
45
47
  1. Say **"log in to ITFin"**.
46
48
  2. Pick your Google account in the window that opens.
47
49
 
48
- ITFin tokens last 7 days and can't be extended. A macOS notification reminds you at the start of your working day before yours expires.
50
+ ITFin tokens last 7 days and can't be extended. A desktop notification reminds you at the start of your working day before yours expires.
49
51
 
50
52
  ## Settings
51
53
 
package/dist/index.js CHANGED
@@ -9,9 +9,9 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
9
9
  import { runCli } from "./cli.js";
10
10
  import { systemClock } from "./clock.js";
11
11
  import { DEFAULT_CONFIG_FILE, loadConfig } from "./config.js";
12
- import { LaunchdReminderScheduler } from "./reminders.js";
12
+ import { createReminderScheduler } from "./reminders.js";
13
13
  import { createItfinServer } from "./server.js";
14
- import { KeychainTokenStore } from "./tokenStore.js";
14
+ import { createTokenStore } from "./tokenStore.js";
15
15
  // `npx itfin-mcp install [flags]` runs the installer shipped in the package. `itfin-mcp cli ...` is
16
16
  // CLI mode (the installer's `itfin` command runs it). Without arguments this is the MCP server.
17
17
  if (process.argv[2] === "install") {
@@ -20,11 +20,11 @@ if (process.argv[2] === "install") {
20
20
  process.exit(result.status ?? 1);
21
21
  }
22
22
  const config = loadConfig();
23
- const reminders = new LaunchdReminderScheduler();
23
+ const reminders = createReminderScheduler();
24
24
  const server = createItfinServer({
25
25
  config,
26
26
  clock: systemClock,
27
- tokenStore: new KeychainTokenStore("itfin-mcp", config.workspaceUrl),
27
+ tokenStore: createTokenStore("itfin-mcp", config.workspaceUrl),
28
28
  reminders,
29
29
  });
30
30
  if (process.argv[2] !== "cli") {
package/dist/login.js CHANGED
@@ -2,14 +2,36 @@ import { spawn } from "node:child_process";
2
2
  import { existsSync, mkdirSync } from "node:fs";
3
3
  import { createServer } from "node:net";
4
4
  import { homedir } from "node:os";
5
- import { join } from "node:path";
6
- const BROWSERS = {
5
+ import { delimiter, join } from "node:path";
6
+ const MAC_BROWSERS = {
7
7
  chrome: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
8
8
  edge: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
9
9
  brave: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
10
10
  arc: "/Applications/Arc.app/Contents/MacOS/Arc",
11
11
  chromium: "/Applications/Chromium.app/Contents/MacOS/Chromium",
12
12
  };
13
+ /** Linux browsers are commands on PATH; the first one found wins. */
14
+ const LINUX_BROWSERS = {
15
+ chrome: ["google-chrome", "google-chrome-stable"],
16
+ edge: ["microsoft-edge", "microsoft-edge-stable"],
17
+ brave: ["brave-browser", "brave"],
18
+ chromium: ["chromium", "chromium-browser"],
19
+ };
20
+ const IS_MAC = process.platform === "darwin";
21
+ function findOnPath(command) {
22
+ return (process.env.PATH ?? "").split(delimiter).map((dir) => join(dir, command)).find((file) => existsSync(file));
23
+ }
24
+ function resolveBrowser(name) {
25
+ if (IS_MAC)
26
+ return MAC_BROWSERS[name.toLowerCase()] ?? name;
27
+ const commands = LINUX_BROWSERS[name.toLowerCase()];
28
+ return (commands && commands.map(findOnPath).find(Boolean)) || name;
29
+ }
30
+ function defaultProfileDir() {
31
+ if (IS_MAC)
32
+ return join(homedir(), "Library", "Application Support", "itfin-mcp", "browser-profile");
33
+ return join(process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"), "itfin-mcp", "browser-profile");
34
+ }
13
35
  const TOKEN_COOKIE = "itfin-jwt";
14
36
  /**
15
37
  * Opens the workspace in a Chromium-based browser with a dedicated profile and watches, over the
@@ -17,10 +39,10 @@ const TOKEN_COOKIE = "itfin-jwt";
17
39
  * Nothing is clicked or typed: the user signs in themselves.
18
40
  */
19
41
  export async function startBrowserLogin(opts) {
20
- const executable = BROWSERS[opts.browser.toLowerCase()] ?? opts.browser;
42
+ const executable = resolveBrowser(opts.browser);
21
43
  if (!existsSync(executable))
22
- throw new Error(`Browser not found: ${executable}. Set ITFIN_BROWSER to chrome, edge, brave, arc, chromium or a path.`);
23
- const profileDir = opts.profileDir ?? join(homedir(), "Library", "Application Support", "itfin-mcp", "browser-profile");
44
+ throw new Error(`Browser not found: ${executable}. Set ITFIN_BROWSER to ${IS_MAC ? "chrome, edge, brave, arc, chromium" : "chrome, edge, brave, chromium"} or a path.`);
45
+ const profileDir = opts.profileDir ?? defaultProfileDir();
24
46
  mkdirSync(profileDir, { recursive: true });
25
47
  const port = await freePort();
26
48
  const child = spawn(executable, [`--remote-debugging-port=${port}`, `--user-data-dir=${profileDir}`, "--no-first-run", "--no-default-browser-check", "about:blank"], { stdio: "ignore", detached: false });
package/dist/reminders.js CHANGED
@@ -49,6 +49,32 @@ export class LaunchdReminderScheduler {
49
49
  await run("/bin/sh", ["-c", notificationCommand(title, message)]).catch(() => undefined);
50
50
  }
51
51
  }
52
+ /** One-shot relogin reminders as transient systemd user timers that remove themselves after firing. */
53
+ export class SystemdReminderScheduler {
54
+ async schedule(at, workspaceUrl) {
55
+ const slug = createHash("sha1").update(workspaceUrl).digest("hex").slice(0, 10);
56
+ const unit = `itfin-mcp-relogin-${slug}`;
57
+ // Stop the old timer and its service, and clear failed state, so systemd-run can reuse the unit names.
58
+ for (const name of [`${unit}.timer`, `${unit}.service`]) {
59
+ await run("systemctl", ["--user", "stop", name]).catch(() => undefined);
60
+ await run("systemctl", ["--user", "reset-failed", name]).catch(() => undefined);
61
+ }
62
+ if (at.getTime() - Date.now() < 60_000) {
63
+ await this.notify("ITFin", reminderText(workspaceUrl));
64
+ return;
65
+ }
66
+ // UTC, so the time does not depend on which TZ the server process and the systemd manager each see.
67
+ const when = `${at.toISOString().slice(0, 16).replace("T", " ")}:00 UTC`;
68
+ await run("systemd-run", ["--user", `--unit=${unit}`, `--on-calendar=${when}`, "--timer-property=AccuracySec=1s", "--collect", "--quiet", "notify-send", "ITFin", reminderText(workspaceUrl)]);
69
+ }
70
+ async notify(title, message) {
71
+ await run("notify-send", [title, message]).catch(() => undefined);
72
+ }
73
+ }
74
+ /** The OS reminder scheduler: launchd on macOS, systemd user timers on Linux. */
75
+ export function createReminderScheduler() {
76
+ return process.platform === "darwin" ? new LaunchdReminderScheduler() : new SystemdReminderScheduler();
77
+ }
52
78
  function reminderText(workspaceUrl) {
53
79
  return `Your ITFin token for ${new URL(workspaceUrl).host} expires soon. Ask Claude to log in to ITFin.`;
54
80
  }
package/dist/server.js CHANGED
@@ -4,7 +4,7 @@ import { z } from "zod";
4
4
  import { ToolError } from "./errors.js";
5
5
  import { ItfinClient } from "./itfinClient.js";
6
6
  import { startBrowserLogin } from "./login.js";
7
- import { LaunchdReminderScheduler } from "./reminders.js";
7
+ import { createReminderScheduler } from "./reminders.js";
8
8
  import { decodeToken } from "./jwt.js";
9
9
  import { ADDITIONAL_REASONS_SETTING, CLOSED_REQUEST_STATUSES, formatHours, LEAVE_REQUEST_TYPES, leaveReasons, sameLeaveTypeId, toLeaveRequest, } from "./leave.js";
10
10
  import { reloginReminderAt, todayIn } from "./workingTime.js";
@@ -100,7 +100,7 @@ export function createItfinServer(deps) {
100
100
  await tokenStore.save(token, { ...stored, rejected: true });
101
101
  }
102
102
  const itfin = new ItfinClient(config.workspaceUrl, validToken, deps.retryDelayMs ?? 500, markRejected);
103
- const reminders = deps.reminders ?? new LaunchdReminderScheduler();
103
+ const reminders = deps.reminders ?? createReminderScheduler();
104
104
  const startLogin = deps.startLogin ?? ((maxWaitMs) => startBrowserLogin({ workspaceUrl: config.workspaceUrl, browser: config.browser, maxWaitMs }));
105
105
  let loginInProgress;
106
106
  /** Stores a freshly issued ITFin token and replaces the relogin reminder. */
@@ -109,9 +109,17 @@ export function createItfinServer(deps) {
109
109
  const expiresAt = new Date(claims.exp * 1000);
110
110
  await tokenStore.save(token);
111
111
  const reminder = await nextReminder(expiresAt, clock.now());
112
- if (reminder)
113
- await reminders.schedule(reminder, config.workspaceUrl);
114
- return { email: claims.Email, expiresAt: expiresAt.toISOString(), nextReminderAt: reminder?.toISOString() };
112
+ // The token is saved, so a scheduler failure (e.g. no systemd user bus) must not fail the login.
113
+ let scheduled = reminder;
114
+ if (reminder) {
115
+ try {
116
+ await reminders.schedule(reminder, config.workspaceUrl);
117
+ }
118
+ catch {
119
+ scheduled = undefined;
120
+ }
121
+ }
122
+ return { email: claims.Email, expiresAt: expiresAt.toISOString(), nextReminderAt: scheduled?.toISOString() };
115
123
  }
116
124
  async function getDays(from, to) {
117
125
  const res = await itfin.get("/v1/tracking", { includes: "log", "filter[from]": from, "filter[to]": to });
@@ -35,6 +35,46 @@ export class KeychainTokenStore {
35
35
  await run("security", ["delete-generic-password", "-s", this.service, "-a", this.account]).catch(() => undefined);
36
36
  }
37
37
  }
38
+ /** A locked keyring makes secret-tool wait for an unlock prompt; give up instead of blocking every tool call. */
39
+ const SECRET_TOOL_TIMEOUT_MS = 15_000;
40
+ /** Keeps the ITFin token in the Linux Secret Service (GNOME Keyring, KWallet) through `secret-tool`. */
41
+ export class SecretToolTokenStore {
42
+ service;
43
+ account;
44
+ constructor(service, account) {
45
+ this.service = service;
46
+ this.account = account;
47
+ }
48
+ async load() {
49
+ try {
50
+ const { stdout } = await run("secret-tool", ["lookup", "service", this.service, "account", this.account], { timeout: SECRET_TOOL_TIMEOUT_MS });
51
+ return JSON.parse(stdout.trim());
52
+ }
53
+ catch {
54
+ return undefined;
55
+ }
56
+ }
57
+ async save(token, extra = {}) {
58
+ const value = JSON.stringify({ token, ...extra });
59
+ // secret-tool reads the secret from stdin, so it never shows up in the process list.
60
+ await new Promise((resolve, reject) => {
61
+ const child = spawn("secret-tool", ["store", "--label", `ITFin (${this.account})`, "service", this.service, "account", this.account], { stdio: ["pipe", "ignore", "pipe"], timeout: SECRET_TOOL_TIMEOUT_MS });
62
+ let stderr = "";
63
+ child.stderr.on("data", (d) => (stderr += d));
64
+ child.on("error", reject);
65
+ child.stdin.on("error", reject); // secret-tool may exit before it reads the secret
66
+ child.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`Keyring write failed: ${stderr.trim() || code}`))));
67
+ child.stdin.end(value);
68
+ });
69
+ }
70
+ async clear() {
71
+ await run("secret-tool", ["clear", "service", this.service, "account", this.account], { timeout: SECRET_TOOL_TIMEOUT_MS }).catch(() => undefined);
72
+ }
73
+ }
74
+ /** The OS keychain: macOS Keychain, or the Secret Service on Linux. */
75
+ export function createTokenStore(service, account) {
76
+ return process.platform === "darwin" ? new KeychainTokenStore(service, account) : new SecretToolTokenStore(service, account);
77
+ }
38
78
  function quote(s) {
39
79
  return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
40
80
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "itfin-mcp",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "ITFin for AI agents, as a local MCP server or a CLI: projects, time entries, leave and reopen requests",
5
5
  "keywords": [
6
6
  "mcp",
@@ -26,7 +26,8 @@
26
26
  "scripts"
27
27
  ],
28
28
  "os": [
29
- "darwin"
29
+ "darwin",
30
+ "linux"
30
31
  ],
31
32
  "scripts": {
32
33
  "build": "tsc -p tsconfig.build.json",
@@ -13,7 +13,9 @@ DIR="${ITFIN_MCP_DIR:-$HOME/.itfin-mcp}"
13
13
  # What npm installs. Override it to try a local build: ITFIN_MCP_PACKAGE=./itfin-mcp-0.1.0.tgz
14
14
  PACKAGE="${ITFIN_MCP_PACKAGE:-itfin-mcp@latest}"
15
15
  CODEX_CONFIG="${CODEX_HOME:-$HOME/.codex}/config.toml"
16
- DESKTOP_CONFIG="$HOME/Library/Application Support/Claude/claude_desktop_config.json"
16
+ # Claude Desktop only exists for macOS (and Windows), so Linux has no Desktop config.
17
+ DESKTOP_CONFIG=""
18
+ [[ "$(uname)" == "Darwin" ]] && DESKTOP_CONFIG="$HOME/Library/Application Support/Claude/claude_desktop_config.json"
17
19
  CLAUDE_SETTINGS="$HOME/.claude/settings.json"
18
20
  CLAUDE_SKILL="$HOME/.claude/skills/itfin"
19
21
  CODEX_SKILL="$HOME/.agents/skills/itfin"
@@ -41,7 +43,14 @@ fail() { echo "✗ $*" >&2; exit 1; }
41
43
  # Read answers from the terminal even when the script itself comes from a pipe.
42
44
  ask() { local prompt="$1" default="${2:-}" answer; read -r -p "$prompt${default:+ [$default]}: " answer </dev/tty || true; echo "${answer:-$default}"; }
43
45
 
44
- [[ "$(uname)" == "Darwin" ]] || fail "itfin-mcp needs macOS (Keychain, launchd and notifications)."
46
+ case "$(uname)" in
47
+ Darwin) ;;
48
+ Linux)
49
+ command -v secret-tool >/dev/null || fail "On Linux itfin-mcp stores the token with secret-tool. Install it (Debian/Ubuntu: sudo apt install libsecret-tools; Fedora: sudo dnf install libsecret) and a keyring such as GNOME Keyring."
50
+ command -v systemd-run >/dev/null || echo "! systemd-run was not found, so the relogin reminder is off."
51
+ ;;
52
+ *) fail "itfin-mcp needs macOS or Linux." ;;
53
+ esac
45
54
  command -v node >/dev/null || fail "Node.js 22+ is required: https://nodejs.org"
46
55
  [[ "$(node -p 'process.versions.node.split(".")[0]')" -ge 22 ]] || fail "Node.js 22+ is required (found $(node -v))."
47
56
  command -v npm >/dev/null || fail "npm is required. It comes with Node.js: https://nodejs.org"
@@ -129,6 +138,7 @@ APPS="$(echo $APPS)"
129
138
  for pair in $APPS; do
130
139
  app="${pair%%=*}" mode="${pair#*=}"
131
140
  [[ "$app" =~ ^(claude|codex|desktop)$ ]] || fail "Unknown app: $app. Use claude, codex or desktop."
141
+ [[ "$app" != desktop || "$(uname)" == "Darwin" ]] || fail "Claude Desktop is not available on Linux."
132
142
  [[ "$mode" =~ ^(mcp|cli)$ ]] || fail "Unknown mode for $app: $mode. Use mcp or cli."
133
143
  [[ "$pair" != "desktop=cli" ]] || fail "Claude Desktop has no shell, so it can only use MCP mode."
134
144
  done
@@ -247,7 +257,7 @@ if [[ -n "$(mode_of codex)" ]]; then
247
257
  install_skill "$CODEX_SKILL"
248
258
  remove_rules
249
259
  if [[ ${#READ_ONLY[@]} -gt 0 ]]; then
250
- # Allowed commands run outside the Codex sandbox, which blocks the network and the Keychain.
260
+ # Allowed commands run outside the Codex sandbox, which blocks the network and the keychain.
251
261
  mkdir -p "$(dirname "$CODEX_RULES")"
252
262
  { echo "$RULES_MARK: read-only itfin commands. Rewritten on every update."
253
263
  for command in "${READ_ONLY[@]}"; do echo "prefix_rule(pattern=[\"itfin\", \"$command\"], decision=\"allow\")"; done
@@ -260,9 +270,19 @@ if [[ -n "$(mode_of codex)" ]]; then
260
270
  remove_rules
261
271
  codex mcp add itfin "${ENV_ARGS[@]/#-e/--env}" -- "$NODE" "$ENTRY"
262
272
  # itfin_login waits up to 3 minutes; Codex gives up on tool calls after 60 seconds by default.
263
- sed -i '' '/^\[mcp_servers\.itfin\]$/a\
273
+ # -i.bak works with both BSD (macOS) and GNU (Linux) sed.
274
+ sed -i.bak '/^\[mcp_servers\.itfin\]$/a\
264
275
  tool_timeout_sec = 240
265
276
  ' "$CODEX_CONFIG"
277
+ rm -f "$CODEX_CONFIG.bak"
278
+ if [[ "$(uname)" == Linux ]]; then
279
+ # Codex passes MCP servers only a short list of variables. The browser, secret-tool and
280
+ # systemctl need the desktop session ones.
281
+ sed -i.bak '/^\[mcp_servers\.itfin\]$/a\
282
+ env_vars = ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR", "XDG_DATA_HOME", "DBUS_SESSION_BUS_ADDRESS"]
283
+ ' "$CODEX_CONFIG"
284
+ rm -f "$CODEX_CONFIG.bak"
285
+ fi
266
286
  fi
267
287
  fi
268
288
 
@@ -295,7 +315,7 @@ for pair in $ALL_APPS; do
295
315
  SUMMARY+="${SUMMARY:+, }$name: $(echo "${pair#*=}" | tr '[:lower:]' '[:upper:]')"
296
316
  done
297
317
  echo "✓ itfin-mcp $NEW_VERSION is installed in $DIR ($SUMMARY)"
298
- [[ -n "$PATH_HINT" ]] && echo "! $(dirname "$BIN") is not on your PATH. Add it: echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.zshrc"
318
+ [[ -n "$PATH_HINT" ]] && echo "! $(dirname "$BIN") is not on your PATH. Add it: echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.$([[ "$(uname)" == Darwin ]] && echo zshrc || echo bashrc)"
299
319
  # Claude Desktop reads its config only at startup. Not quit here: it may be running this script.
300
320
  [[ -n "$DESKTOP" ]] && echo "Restart Claude Desktop (Cmd+Q, then open it) to load the server."
301
321
  [[ -n "${STALE:-}" ]] && echo "Open sessions lost the ITFin tools: start a new session, or reconnect itfin with /mcp in Claude Code."