itfin-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "itfin-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Local MCP server for ITFin: projects, time entries and ITFin token lifecycle",
5
+ "keywords": [
6
+ "mcp",
7
+ "itfin",
8
+ "claude",
9
+ "codex",
10
+ "timesheet"
11
+ ],
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/steven-tailor/itfin-mcp.git"
15
+ },
16
+ "homepage": "https://github.com/steven-tailor/itfin-mcp#readme",
17
+ "bugs": "https://github.com/steven-tailor/itfin-mcp/issues",
18
+ "type": "module",
19
+ "bin": {
20
+ "itfin-mcp": "dist/index.js"
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "scripts"
25
+ ],
26
+ "os": [
27
+ "darwin"
28
+ ],
29
+ "scripts": {
30
+ "build": "tsc -p tsconfig.build.json",
31
+ "typecheck": "tsc --noEmit",
32
+ "test": "vitest run",
33
+ "start": "node dist/index.js",
34
+ "prepack": "npm run build"
35
+ },
36
+ "engines": {
37
+ "node": ">=22"
38
+ },
39
+ "dependencies": {
40
+ "@inquirer/checkbox": "^5.2.5",
41
+ "@modelcontextprotocol/sdk": "^1.30.1",
42
+ "luxon": "^3.7.2",
43
+ "zod": "^4.6.5"
44
+ },
45
+ "devDependencies": {
46
+ "@types/luxon": "^3.7.5",
47
+ "@types/node": "^26.6.2",
48
+ "typescript": "^7.0.2",
49
+ "vitest": "^5.0.1"
50
+ }
51
+ }
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env bash
2
+ # Quick install for itfin-mcp: installs or updates the npm package in ~/.itfin-mcp and registers the
3
+ # MCP server with Claude Code, Codex and Claude Desktop. Usage:
4
+ # npx itfin-mcp install [--company acme] [--clients claude,codex,desktop] [--browser chrome] [--work-start 10:00] [--timezone Europe/Kyiv]
5
+ # curl -fsSL https://raw.githubusercontent.com/steven-tailor/itfin-mcp/main/scripts/install.sh | bash -s -- [same flags]
6
+ # The first install asks which apps to install for and the company name. Updates reuse the apps and
7
+ # settings of the previous install.
8
+ set -euo pipefail
9
+
10
+ DIR="${ITFIN_MCP_DIR:-$HOME/.itfin-mcp}"
11
+ # What npm installs. Override it to try a local build: ITFIN_MCP_PACKAGE=./itfin-mcp-0.1.0.tgz
12
+ PACKAGE="${ITFIN_MCP_PACKAGE:-itfin-mcp@latest}"
13
+ CODEX_CONFIG="${CODEX_HOME:-$HOME/.codex}/config.toml"
14
+ DESKTOP_CONFIG="$HOME/Library/Application Support/Claude/claude_desktop_config.json"
15
+ URL="" BROWSER="" WORK_START="" TIMEZONE="" CLIENTS=""
16
+
17
+ while [[ $# -gt 0 ]]; do
18
+ case "$1" in
19
+ --company) URL="https://$2.itfin.io"; shift 2 ;;
20
+ --url) URL="$2"; shift 2 ;;
21
+ --clients) CLIENTS="${2//,/ }"; shift 2 ;;
22
+ --browser) BROWSER="$2"; shift 2 ;;
23
+ --work-start) WORK_START="$2"; shift 2 ;;
24
+ --timezone) TIMEZONE="$2"; shift 2 ;;
25
+ --dir) DIR="$2"; shift 2 ;;
26
+ *) echo "Unknown option: $1" >&2; exit 1 ;;
27
+ esac
28
+ done
29
+
30
+ fail() { echo "✗ $*" >&2; exit 1; }
31
+ # Read answers from the terminal even when the script itself comes from a pipe.
32
+ ask() { local prompt="$1" default="${2:-}" answer; read -r -p "$prompt${default:+ [$default]}: " answer </dev/tty || true; echo "${answer:-$default}"; }
33
+
34
+ [[ "$(uname)" == "Darwin" ]] || fail "itfin-mcp needs macOS (Keychain, launchd and notifications)."
35
+ command -v node >/dev/null || fail "Node.js 22+ is required: https://nodejs.org"
36
+ [[ "$(node -p 'process.versions.node.split(".")[0]')" -ge 22 ]] || fail "Node.js 22+ is required (found $(node -v))."
37
+ command -v npm >/dev/null || fail "npm is required. It comes with Node.js: https://nodejs.org"
38
+
39
+ PKG="$DIR/node_modules/itfin-mcp"
40
+ version() { node -p 'require(process.argv[1]).version' "$PKG/package.json" 2>/dev/null || true; }
41
+ OLD_VERSION=""
42
+ if [[ -d "$DIR/.git" ]]; then
43
+ # Installs before the npm package were a git clone of the repo, which npm can't install into.
44
+ [[ "$(git -C "$DIR" remote get-url origin 2>/dev/null)" == *itfin-mcp* ]] || fail "$DIR is a git repo that isn't itfin-mcp. Pick another folder with --dir."
45
+ echo "→ Replacing the git clone in $DIR with the npm package"
46
+ OLD_VERSION="git"
47
+ rm -rf "$DIR"
48
+ else
49
+ OLD_VERSION="$(version)"
50
+ fi
51
+
52
+ echo "→ Installing $PACKAGE from npm"
53
+ mkdir -p "$DIR"
54
+ npm install --prefix "$DIR" --silent --no-audit --no-fund --omit=dev "$PACKAGE"
55
+ NEW_VERSION="$(version)"
56
+ [[ -n "$NEW_VERSION" ]] || fail "npm did not install itfin-mcp into $DIR."
57
+
58
+ # Servers that apps started before this update keep running the old code until they exit. Stop them,
59
+ # matching only the node processes (not the wrappers apps launch them with), so no app keeps using
60
+ # stale tools. Each app starts a fresh server for its next session, or after a restart.
61
+ # The pattern also matches the dist/index.js of a replaced git clone.
62
+ STALE=""
63
+ [[ -n "$OLD_VERSION" && "$OLD_VERSION" != "$NEW_VERSION" ]] && STALE="$(pgrep -f "^[^ ]*node $DIR/(node_modules/itfin-mcp/)?dist/index\.js\$" || true)"
64
+ if [[ -n "$STALE" ]]; then
65
+ echo "→ Stopping $(echo "$STALE" | wc -l | tr -d ' ') running itfin-mcp server(s) with the old code"
66
+ kill $STALE 2>/dev/null || true
67
+ fi
68
+
69
+ # Apps to register with: claude (Claude Code), codex, desktop (Claude Desktop).
70
+ # Updates keep the apps of the previous install. A first install asks, with every app found checked.
71
+ if [[ -z "$CLIENTS" ]]; then
72
+ command -v claude >/dev/null && claude mcp get itfin >/dev/null 2>&1 && CLIENTS+="claude "
73
+ [[ -f "$CODEX_CONFIG" ]] && grep -q '^\[mcp_servers\.itfin\]$' "$CODEX_CONFIG" && CLIENTS+="codex "
74
+ [[ -f "$DESKTOP_CONFIG" ]] && grep -q '"itfin"' "$DESKTOP_CONFIG" && CLIENTS+="desktop "
75
+ fi
76
+ if [[ -z "$CLIENTS" ]]; then
77
+ FOUND=()
78
+ command -v claude >/dev/null && FOUND+=("claude=Claude Code")
79
+ command -v codex >/dev/null && FOUND+=("codex=Codex")
80
+ [[ -d /Applications/Claude.app || -f "$DESKTOP_CONFIG" ]] && FOUND+=("desktop=Claude Desktop")
81
+ [[ ${#FOUND[@]} -gt 0 ]] || fail "Install Claude Code, Codex or Claude Desktop first."
82
+ if (: </dev/tty) 2>/dev/null; then
83
+ CLIENTS="$(node "$PKG/scripts/pick-clients.mjs" "${FOUND[@]}")" || fail "Cancelled."
84
+ else
85
+ for found in "${FOUND[@]}"; do CLIENTS+="${found%%=*} "; done
86
+ fi
87
+ fi
88
+ CLIENTS=" $(echo $CLIENTS) "
89
+ for client in $CLIENTS; do
90
+ [[ "$client" =~ ^(claude|codex|desktop)$ ]] || fail "Unknown app: $client. Use claude, codex or desktop."
91
+ done
92
+ wants() { [[ "$CLIENTS" == *" $1 "* ]]; }
93
+
94
+ # Settings of the previous install, from the Claude Code registration, the Codex config or the
95
+ # Claude Desktop config.
96
+ PREV=""
97
+ if command -v claude >/dev/null; then PREV+="$(claude mcp get itfin 2>/dev/null || true)"$'\n'; fi
98
+ if [[ -f "$CODEX_CONFIG" ]]; then PREV+="$(cat "$CODEX_CONFIG")"$'\n'; fi
99
+ if [[ -f "$DESKTOP_CONFIG" ]]; then
100
+ PREV+="$(node -e 'const env = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).mcpServers?.itfin?.env ?? {};
101
+ for (const [k, v] of Object.entries(env)) console.log(`${k}=${v}`)' "$DESKTOP_CONFIG" 2>/dev/null || true)"
102
+ fi
103
+ # Prints the value of KEY from lines like `KEY=value` or `KEY = "value"`.
104
+ prev() {
105
+ printf '%s\n' "$PREV" | awk -v k="$1" '{
106
+ sub(/^[ \t]+/, "")
107
+ if (index($0, k) != 1) next
108
+ rest = substr($0, length(k) + 1)
109
+ if (rest !~ /^[ \t]*=/) next
110
+ sub(/^[ \t]*=[ \t]*/, "", rest); gsub(/"/, "", rest); sub(/[ \t]+$/, "", rest)
111
+ print rest; exit
112
+ }'
113
+ }
114
+
115
+ [[ -n "$URL" ]] || URL="$(prev ITFIN_URL)"
116
+ [[ -n "$BROWSER" ]] || BROWSER="$(prev ITFIN_BROWSER)"
117
+ [[ -n "$WORK_START" ]] || WORK_START="$(prev ITFIN_WORK_START)"
118
+ [[ -n "$TIMEZONE" ]] || TIMEZONE="$(prev ITFIN_TIMEZONE)"
119
+
120
+ if [[ -z "$URL" ]]; then
121
+ COMPANY="$(ask "Company name in ITFin (acme for https://acme.itfin.io)")"
122
+ COMPANY="$(printf '%s' "$COMPANY" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
123
+ # Also accept a pasted address like https://acme.itfin.io.
124
+ COMPANY="${COMPANY#*://}"; COMPANY="${COMPANY%%.itfin.io*}"
125
+ [[ "$COMPANY" =~ ^[a-z0-9-]+$ ]] || fail "Company name must be letters, digits or dashes, like acme."
126
+ URL="https://$COMPANY.itfin.io"
127
+ fi
128
+ URL="${URL%/}"
129
+ [[ "$URL" =~ ^https?://[^/]+$ ]] || fail "Workspace address must look like https://acme.itfin.io"
130
+ echo "→ Workspace: $URL"
131
+
132
+ ENTRY="$PKG/dist/index.js"
133
+ # Absolute path, because Claude Desktop does not see version-manager shims on PATH.
134
+ NODE="$(command -v node)"
135
+ # Settings left empty are not passed, so the server uses its defaults (chrome, 10:00, system timezone).
136
+ ENV_PAIRS=()
137
+ for pair in "ITFIN_URL=$URL" "ITFIN_BROWSER=$BROWSER" "ITFIN_WORK_START=$WORK_START" "ITFIN_TIMEZONE=$TIMEZONE"; do
138
+ [[ -n "${pair#*=}" ]] && ENV_PAIRS+=("$pair")
139
+ done
140
+ ENV_ARGS=()
141
+ for pair in "${ENV_PAIRS[@]}"; do ENV_ARGS+=(-e "$pair"); done
142
+
143
+ if wants claude; then
144
+ command -v claude >/dev/null || fail "The claude CLI was not found."
145
+ echo "→ Registering the MCP server with Claude Code (user scope)"
146
+ claude mcp remove itfin --scope user >/dev/null 2>&1 || true
147
+ claude mcp add itfin --scope user "${ENV_ARGS[@]}" -- "$NODE" "$ENTRY"
148
+ fi
149
+
150
+ if wants codex; then
151
+ command -v codex >/dev/null || fail "The codex CLI was not found."
152
+ echo "→ Registering the MCP server with Codex"
153
+ codex mcp remove itfin >/dev/null 2>&1 || true
154
+ codex mcp add itfin "${ENV_ARGS[@]/#-e/--env}" -- "$NODE" "$ENTRY"
155
+ # itfin_login waits up to 3 minutes; Codex gives up on tool calls after 60 seconds by default.
156
+ sed -i '' '/^\[mcp_servers\.itfin\]$/a\
157
+ tool_timeout_sec = 240
158
+ ' "$CODEX_CONFIG"
159
+ fi
160
+
161
+ DESKTOP=""
162
+ if wants desktop; then
163
+ echo "→ Registering the MCP server with Claude Desktop"
164
+ # Replaces only mcpServers.itfin and keeps the rest of the file. The old file is kept as .bak.
165
+ if node -e '
166
+ const fs = require("fs");
167
+ const [file, command, entry, ...pairs] = process.argv.slice(1);
168
+ const text = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
169
+ const config = text.trim() ? JSON.parse(text) : {};
170
+ if (text) fs.writeFileSync(file + ".bak", text);
171
+ const env = Object.fromEntries(pairs.map((p) => [p.slice(0, p.indexOf("=")), p.slice(p.indexOf("=") + 1)]));
172
+ config.mcpServers = { ...config.mcpServers, itfin: { command, args: [entry], env } };
173
+ fs.mkdirSync(require("path").dirname(file), { recursive: true });
174
+ fs.writeFileSync(file, JSON.stringify(config, null, 2) + "\n");
175
+ ' "$DESKTOP_CONFIG" "$NODE" "$ENTRY" "${ENV_PAIRS[@]}"; then
176
+ DESKTOP=1
177
+ else
178
+ echo "! Could not update $DESKTOP_CONFIG (is it valid JSON?), so Claude Desktop was not registered."
179
+ fi
180
+ fi
181
+
182
+ echo
183
+ echo "✓ itfin-mcp $NEW_VERSION is installed in $DIR"
184
+ # Claude Desktop reads its config only at startup. Not quit here: it may be running this script.
185
+ [[ -n "$DESKTOP" ]] && echo "Restart Claude Desktop (Cmd+Q, then open it) to load the server."
186
+ [[ -n "${STALE:-}" ]] && echo "Open sessions lost the ITFin tools: start a new session, or reconnect itfin with /mcp in Claude Code."
187
+ echo "Next: start a new session and ask it to \"log in to ITFin\"."
188
+ echo "Run npx itfin-mcp@latest install at any time to update."
@@ -0,0 +1,30 @@
1
+ // Checkbox list for install.sh: which apps to register the MCP server with.
2
+ // Usage: node pick-clients.mjs id=Label ... Prints the chosen ids, one per line.
3
+ // Talks to /dev/tty, because install.sh itself usually comes from a pipe (curl | bash).
4
+ import { openSync } from "node:fs";
5
+ import { ReadStream, WriteStream } from "node:tty";
6
+ import checkbox from "@inquirer/checkbox";
7
+
8
+ const input = new ReadStream(openSync("/dev/tty", "r"));
9
+ const output = new WriteStream(openSync("/dev/tty", "w"));
10
+
11
+ try {
12
+ const chosen = await checkbox(
13
+ {
14
+ message: "Install for",
15
+ choices: process.argv.slice(2).map((arg) => {
16
+ const [value, name] = arg.split("=");
17
+ return { value, name, checked: true };
18
+ }),
19
+ required: true,
20
+ },
21
+ { input, output },
22
+ );
23
+ console.log(chosen.join("\n"));
24
+ } catch {
25
+ // Ctrl+C: print nothing, install.sh stops.
26
+ process.exitCode = 1;
27
+ } finally {
28
+ input.destroy();
29
+ output.destroy();
30
+ }