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 +4 -2
- package/dist/index.js +4 -4
- package/dist/login.js +27 -5
- package/dist/reminders.js +26 -0
- package/dist/server.js +13 -5
- package/dist/tokenStore.js +40 -0
- package/package.json +3 -2
- package/scripts/install.sh +25 -5
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
|
|
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
|
|
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 {
|
|
12
|
+
import { createReminderScheduler } from "./reminders.js";
|
|
13
13
|
import { createItfinServer } from "./server.js";
|
|
14
|
-
import {
|
|
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 =
|
|
23
|
+
const reminders = createReminderScheduler();
|
|
24
24
|
const server = createItfinServer({
|
|
25
25
|
config,
|
|
26
26
|
clock: systemClock,
|
|
27
|
-
tokenStore:
|
|
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
|
|
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 =
|
|
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 ??
|
|
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 {
|
|
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 ??
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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 });
|
package/dist/tokenStore.js
CHANGED
|
@@ -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.
|
|
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",
|
package/scripts/install.sh
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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\"' >>
|
|
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."
|