@vairix/admin-mcp 1.0.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 ADDED
@@ -0,0 +1,209 @@
1
+ <div align="center">
2
+
3
+ <img src="https://capsule-render.vercel.app/api?type=waving&color=0:1a1b27,100:6366f1&height=180&section=header&text=vairix-admin-mcp&fontSize=36&fontColor=ffffff&fontAlignY=35&desc=Log%20your%20hours%20with%20Claude%2C%20not%20clicks&descSize=16&descColor=a5b4fc&descAlignY=55" width="100%" />
4
+
5
+ [![Node](https://img.shields.io/badge/node-%3E%3D18-43853d?style=flat-square&logo=node.js&logoColor=white)](https://nodejs.org)
6
+ [![License](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](LICENSE)
7
+ [![MCP](https://img.shields.io/badge/MCP-compatible-6366f1?style=flat-square&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjIiPjxwYXRoIGQ9Ik0xMiAyTDIgN2wxMCA1IDEwLTUtMTAtNXoiLz48cGF0aCBkPSJNMiAxN2wxMCA1IDEwLTUiLz48cGF0aCBkPSJNMiAxMmwxMCA1IDEwLTUiLz48L3N2Zz4=)](https://modelcontextprotocol.io)
8
+
9
+ </div>
10
+
11
+ <div align="center">
12
+
13
+ ### Talk to Claude. Your hours get logged.
14
+
15
+ An [MCP server](https://modelcontextprotocol.io) that gives Claude direct access to [Vairix Admin](https://admin.vairix.com).<br>
16
+ No more clicking through forms -- just describe what you need in plain language.
17
+
18
+ </div>
19
+
20
+ ---
21
+
22
+ <img src="assets/demo.gif" width="100%" alt="Demo" />
23
+
24
+ ---
25
+
26
+ ## Install
27
+
28
+ One command. That's it.
29
+
30
+ ```bash
31
+ claude mcp add vairix-admin -s user -- npx @vairix/admin-mcp
32
+ ```
33
+
34
+ > `-s user` makes it available across **all** your projects, not just the current one.
35
+
36
+ <details>
37
+ <summary>Install from GitHub instead</summary>
38
+
39
+ ```bash
40
+ claude mcp add vairix-admin -s user -- npx --yes github:Barralex/vairix-admin-mcp
41
+ ```
42
+
43
+ </details>
44
+
45
+ <details>
46
+ <summary>Clone manually instead</summary>
47
+
48
+ ```bash
49
+ git clone git@github.com:Barralex/vairix-admin-mcp.git
50
+ cd vairix-admin-mcp
51
+ npm install
52
+ claude mcp add vairix-admin -s user -- node $(pwd)/build/index.js
53
+ ```
54
+
55
+ </details>
56
+
57
+ <details>
58
+ <summary>Uninstall</summary>
59
+
60
+ ```bash
61
+ claude mcp remove vairix-admin -s user
62
+ ```
63
+
64
+ </details>
65
+
66
+ ## How it works
67
+
68
+ ```
69
+ You: "Log 8 hours on Seekr for today: API refactor"
70
+ |
71
+ Claude Code (MCP)
72
+ |
73
+ admin.vairix.com (HTTP)
74
+ |
75
+ Done.
76
+ ```
77
+
78
+ **First time?** Claude will open your browser (Chrome, Edge, or Brave) so you can login normally. Your session cookies are stored in the OS keychain -- passwords are **never** saved. After that, everything runs via direct HTTP requests. Sub-second. No browser needed.
79
+
80
+ Session expired? Just say _"authenticate"_ again.
81
+
82
+ ## What you can say
83
+
84
+ ```
85
+ "Authenticate with Vairix Admin" -- login (once per session)
86
+
87
+ "What days am I missing this month?" -- find gaps
88
+ "Log 8h on Seekr for Monday through Friday" -- bulk log
89
+ "Log 4 hours on Cordage for today: Bug fixes" -- single entry
90
+
91
+ "How many hours did I log on Seekr?" -- totals
92
+ "Show me a breakdown by category" -- summary
93
+ "Show my hours for this month" -- list entries
94
+
95
+ "Delete the hour entry from today" -- remove entry
96
+ ```
97
+
98
+ No special syntax. No commands to memorize. Just describe what you want.
99
+
100
+ ## Tools under the hood
101
+
102
+ Claude picks the right tool automatically. You don't need to call them directly.
103
+
104
+ | Tool | What it does |
105
+ |:-----|:-------------|
106
+ | **`auth`** | Opens your browser for login. Session saved to OS keychain. |
107
+ | **`auth_status`** | Checks if your session is still valid. |
108
+ | **`logout`** | Clears saved session. |
109
+ | **`set_main_project`** | Sets your default project so you don't have to specify it every time. |
110
+ | **`get_pending_days`** | Finds workdays where you haven't logged hours yet. |
111
+ | **`get_hours`** | Lists your entries. Filter by project, date range, or scope. |
112
+ | **`get_hours_summary`** | Totals and breakdowns by project, category, or date. |
113
+ | **`get_projects`** | Shows which projects you can log to. |
114
+ | **`create_hours`** | Logs hours for one or more dates at once. |
115
+ | **`delete_hours`** | Removes an entry by ID. |
116
+
117
+ ## Security
118
+
119
+ Your credentials are handled carefully:
120
+
121
+ - **Passwords** are never stored. You login through your real browser.
122
+ - **Session cookies** live in your OS keychain (macOS Keychain / Linux libsecret / Windows Credential Vault).
123
+ - **No bundled browser**. Uses your existing Chrome, Edge, or Brave.
124
+
125
+ ## WSL Setup
126
+
127
+ Running inside WSL requires extra steps since there's no default browser or keychain.
128
+
129
+ **1. Install a browser inside WSL:**
130
+
131
+ ```bash
132
+ # Option A: Chromium
133
+ sudo apt install -y chromium-browser
134
+
135
+ # Option B: Google Chrome
136
+ wget -q https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
137
+ sudo dpkg -i google-chrome-stable_current_amd64.deb
138
+ sudo apt -f install -y
139
+ ```
140
+
141
+ **2. Install keychain dependencies:**
142
+
143
+ ```bash
144
+ sudo apt install -y libsecret-1-dev gnome-keyring
145
+ ```
146
+
147
+ Start the keyring daemon (add to your `.bashrc`):
148
+
149
+ ```bash
150
+ eval $(gnome-keyring-daemon --start --components=secrets 2>/dev/null)
151
+ export GNOME_KEYRING_CONTROL
152
+ ```
153
+
154
+ **3. Verify setup:**
155
+
156
+ ```bash
157
+ npx --yes github:Barralex/vairix-admin-mcp --health-check
158
+ ```
159
+
160
+ > **Note:** Browser auth requires a display server. WSL2 with WSLg provides this automatically. If using WSL1 or WSLg isn't working, you'll need an X11 server (e.g., VcXsrv) with `export DISPLAY=:0`.
161
+
162
+ ## Troubleshooting
163
+
164
+ | Problem | Solution |
165
+ |:--------|:---------|
166
+ | "No Chromium-based browser found" | Install Chrome, Edge, or Brave. |
167
+ | "No Chromium-based browser found inside WSL" | Install a browser _inside_ WSL, not on Windows. See [WSL Setup](#wsl-setup). |
168
+ | "Not authenticated" | Say _"authenticate with Vairix"_. |
169
+ | "Session expired" | Same -- just authenticate again. |
170
+ | Hours creation fails | Check the error. Admin validates dates (no future dates, etc). |
171
+ | Keychain errors on WSL/Linux | Install libsecret and gnome-keyring. See [WSL Setup](#wsl-setup). |
172
+ | Server won't start | Run `--health-check` to diagnose: `npx --yes github:Barralex/vairix-admin-mcp --health-check` |
173
+
174
+ ## Releasing
175
+
176
+ Every push to `main` automatically publishes to npm. The version is bumped based on the commit message prefix:
177
+
178
+ | Commit prefix | Bump | Example |
179
+ |:--------------|:-----|:--------|
180
+ | `feat!:` or `BREAKING CHANGE` | **major** | `1.0.0` → `2.0.0` |
181
+ | `feat:` | **minor** | `1.0.0` → `1.1.0` |
182
+ | `fix:`, `refactor:`, `chore:`, etc. | **patch** | `1.0.0` → `1.0.1` |
183
+
184
+ The CI pipeline bumps `package.json`, publishes the package, and commits the new version back to `main`. No manual version management needed.
185
+
186
+ ## Development
187
+
188
+ ```bash
189
+ npm run dev # watch mode
190
+ npm run build # compile
191
+ npm test # run tests
192
+ npm start # run server
193
+ ```
194
+
195
+ ## Requirements
196
+
197
+ - Node.js >= 18
198
+ - Chrome, Edge, or Brave
199
+ - [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI
200
+
201
+ <div align="center">
202
+
203
+ ---
204
+
205
+ **[Vairix](https://vairix.com)** | MIT License
206
+
207
+ <img src="https://capsule-render.vercel.app/api?type=waving&color=0:1a1b27,100:6366f1&height=80&section=footer" width="100%" />
208
+
209
+ </div>
package/build/api.d.ts ADDED
@@ -0,0 +1,48 @@
1
+ export interface ProjectOption {
2
+ id: string;
3
+ name: string;
4
+ }
5
+ export interface HourEntry {
6
+ id: number;
7
+ initial_date: string;
8
+ hours: number;
9
+ category: number;
10
+ description: string;
11
+ project_id: number;
12
+ staff_id: number;
13
+ extra_allocation: boolean;
14
+ in_home: boolean;
15
+ confirm: boolean;
16
+ billable: boolean | null;
17
+ }
18
+ export declare function categoryName(cat: number): string;
19
+ export declare function categoryId(name: string): string;
20
+ export interface GetHoursFilter {
21
+ scope?: string;
22
+ date_from?: string;
23
+ date_to?: string;
24
+ project_id?: string;
25
+ }
26
+ export declare function getHours(filter?: GetHoursFilter): Promise<HourEntry[]>;
27
+ export declare function getPendingDays(): Promise<string[]>;
28
+ export declare function getProjects(): Promise<ProjectOption[]>;
29
+ export declare function createHour(params: {
30
+ date: string;
31
+ project_id: string;
32
+ hours: string;
33
+ category: string;
34
+ description: string;
35
+ extra_allocation?: boolean;
36
+ in_home?: boolean;
37
+ }): Promise<{
38
+ success: boolean;
39
+ message: string;
40
+ }>;
41
+ export declare function deleteHour(id: string): Promise<{
42
+ success: boolean;
43
+ message: string;
44
+ }>;
45
+ export declare function batchDeleteHours(ids: string[]): Promise<{
46
+ success: boolean;
47
+ message: string;
48
+ }>;
package/build/api.js ADDED
@@ -0,0 +1,263 @@
1
+ import { loadSession, isSessionValid } from "./auth.js";
2
+ const BASE_URL = "https://admin.vairix.com";
3
+ // Session cache with TTL — avoids re-validating on every tool call
4
+ let cachedSession = null;
5
+ let sessionValidatedAt = 0;
6
+ const SESSION_TTL = 60_000;
7
+ async function session() {
8
+ if (cachedSession && Date.now() - sessionValidatedAt < SESSION_TTL) {
9
+ return cachedSession;
10
+ }
11
+ if (cachedSession) {
12
+ const valid = await isSessionValid(cachedSession);
13
+ if (valid) {
14
+ sessionValidatedAt = Date.now();
15
+ return cachedSession;
16
+ }
17
+ }
18
+ const saved = await loadSession();
19
+ if (!saved)
20
+ throw new Error("Not authenticated. Use the `auth` tool first.");
21
+ const valid = await isSessionValid(saved);
22
+ if (!valid)
23
+ throw new Error("Session expired. Use the `auth` tool to login again.");
24
+ if (!cachedSession || cachedSession.cookies !== saved.cookies) {
25
+ formDataCache = null;
26
+ }
27
+ cachedSession = saved;
28
+ sessionValidatedAt = Date.now();
29
+ return saved;
30
+ }
31
+ let formDataCache = null;
32
+ const FORM_DATA_TTL = 300_000;
33
+ async function fetchFormData() {
34
+ if (formDataCache && Date.now() - formDataCache.fetchedAt < FORM_DATA_TTL) {
35
+ return formDataCache;
36
+ }
37
+ const s = await session();
38
+ const res = await fetch(`${BASE_URL}/admin/daily_hours/new`, {
39
+ headers: { Cookie: s.cookies },
40
+ redirect: "follow",
41
+ });
42
+ if (res.status !== 200)
43
+ throw new Error(`Failed to load form: ${res.status}`);
44
+ const html = await res.text();
45
+ const formTokenMatch = html.match(/name="authenticity_token"[^>]*value="([^"]+)"/);
46
+ const csrfFormToken = formTokenMatch ? formTokenMatch[1] : s.csrfToken;
47
+ const metaTokenMatch = html.match(/meta name="csrf-token" content="([^"]+)"/);
48
+ const csrfMetaToken = metaTokenMatch ? metaTokenMatch[1] : s.csrfToken;
49
+ let staffId = "";
50
+ const staffMatch = html.match(/name="daily_hour\[staff_id\]"[\s\S]*?option[^>]*value="(\d+)"[^>]*selected/);
51
+ if (staffMatch) {
52
+ staffId = staffMatch[1];
53
+ }
54
+ else {
55
+ const altMatch = html.match(/name="daily_hour\[staff_id\]"[\s\S]*?selected[^>]*value="(\d+)"/);
56
+ if (altMatch)
57
+ staffId = altMatch[1];
58
+ else
59
+ throw new Error("Could not find staff_id");
60
+ }
61
+ const projects = [];
62
+ const projectSelectMatch = html.match(/name="daily_hour\[project_id\]"[^>]*>([\s\S]*?)<\/select>/);
63
+ if (projectSelectMatch) {
64
+ const regex = /<option value="(\d+)">([^<]+)<\/option>/g;
65
+ let m;
66
+ while ((m = regex.exec(projectSelectMatch[1])) !== null) {
67
+ projects.push({ id: m[1], name: m[2] });
68
+ }
69
+ }
70
+ formDataCache = { csrfFormToken, csrfMetaToken, staffId, projects, fetchedAt: Date.now() };
71
+ return formDataCache;
72
+ }
73
+ async function apiGet(path) {
74
+ const s = await session();
75
+ return fetch(`${BASE_URL}${path}`, {
76
+ headers: { Cookie: s.cookies },
77
+ redirect: "follow",
78
+ });
79
+ }
80
+ async function apiPost(path, body) {
81
+ const s = await session();
82
+ for (let attempt = 0; attempt < 2; attempt++) {
83
+ const form = await fetchFormData();
84
+ const params = new URLSearchParams({
85
+ authenticity_token: form.csrfFormToken,
86
+ ...body,
87
+ });
88
+ const res = await fetch(`${BASE_URL}${path}`, {
89
+ method: "POST",
90
+ headers: {
91
+ "Content-Type": "application/x-www-form-urlencoded",
92
+ Cookie: s.cookies,
93
+ },
94
+ body: params.toString(),
95
+ redirect: "manual",
96
+ });
97
+ if (res.status === 302 || res.status === 303 || res.status === 200) {
98
+ return res;
99
+ }
100
+ if (attempt === 0) {
101
+ formDataCache = null;
102
+ continue;
103
+ }
104
+ return res;
105
+ }
106
+ throw new Error("POST failed after retry");
107
+ }
108
+ async function apiDelete(path) {
109
+ const s = await session();
110
+ for (let attempt = 0; attempt < 2; attempt++) {
111
+ const form = await fetchFormData();
112
+ const res = await fetch(`${BASE_URL}${path}`, {
113
+ method: "DELETE",
114
+ headers: {
115
+ Cookie: s.cookies,
116
+ "X-CSRF-Token": form.csrfMetaToken,
117
+ },
118
+ redirect: "manual",
119
+ });
120
+ if (res.status === 200 || res.status === 302 || res.status === 303) {
121
+ return res;
122
+ }
123
+ if (attempt === 0) {
124
+ formDataCache = null;
125
+ continue;
126
+ }
127
+ return res;
128
+ }
129
+ throw new Error("DELETE failed after retry");
130
+ }
131
+ const CATEGORY_MAP = {
132
+ 1: "Desarrollador",
133
+ 2: "Gerente de proyecto",
134
+ 3: "Testing",
135
+ 4: "Arquitecto",
136
+ 5: "Otro",
137
+ };
138
+ const CATEGORY_REVERSE = {
139
+ desarrollador: "1",
140
+ dev: "1",
141
+ developer: "1",
142
+ "gerente de proyecto": "2",
143
+ pm: "2",
144
+ testing: "3",
145
+ qa: "3",
146
+ arquitecto: "4",
147
+ architect: "4",
148
+ otro: "5",
149
+ other: "5",
150
+ };
151
+ export function categoryName(cat) {
152
+ return CATEGORY_MAP[cat] ?? `Unknown(${cat})`;
153
+ }
154
+ export function categoryId(name) {
155
+ return CATEGORY_REVERSE[name.toLowerCase()] ?? "1";
156
+ }
157
+ const MAX_PAGES = 10;
158
+ const PER_PAGE = 100;
159
+ export async function getHours(filter = {}) {
160
+ const { scope = "current_month", date_from, date_to, project_id } = filter;
161
+ const all = [];
162
+ for (let page = 1; page <= MAX_PAGES; page++) {
163
+ const params = new URLSearchParams({ scope, page: String(page), per_page: String(PER_PAGE) });
164
+ if (date_from)
165
+ params.set("q[initial_date_gteq]", date_from);
166
+ if (date_to)
167
+ params.set("q[initial_date_lteq]", date_to);
168
+ if (project_id)
169
+ params.set("q[project_id_eq]", project_id);
170
+ const res = await apiGet(`/admin/daily_hours.json?${params}`);
171
+ if (res.status !== 200)
172
+ throw new Error(`Failed to get hours: ${res.status}`);
173
+ const entries = await res.json();
174
+ all.push(...entries);
175
+ if (entries.length < PER_PAGE)
176
+ break;
177
+ }
178
+ return all;
179
+ }
180
+ export async function getPendingDays() {
181
+ const now = new Date();
182
+ const year = now.getFullYear();
183
+ const month = now.getMonth();
184
+ const today = now.getDate();
185
+ const monthPrefix = `${year}-${String(month + 1).padStart(2, "0")}`;
186
+ const monthStart = `${monthPrefix}-01`;
187
+ const todayStr = `${monthPrefix}-${String(today).padStart(2, "0")}`;
188
+ let hours = await getHours({ scope: "current_month" });
189
+ // At month boundaries, server (UTC) may be in a different month than the client.
190
+ // Use date range filters instead of fetching all hours.
191
+ if (!hours.some((h) => h.initial_date.startsWith(monthPrefix))) {
192
+ hours = await getHours({ scope: "all", date_from: monthStart, date_to: todayStr });
193
+ }
194
+ const loggedDates = new Set(hours.map((h) => h.initial_date));
195
+ const pending = [];
196
+ for (let day = 1; day <= today; day++) {
197
+ const d = new Date(year, month, day);
198
+ const dow = d.getDay();
199
+ if (dow === 0 || dow === 6)
200
+ continue;
201
+ const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
202
+ if (!loggedDates.has(dateStr))
203
+ pending.push(dateStr);
204
+ }
205
+ return pending;
206
+ }
207
+ export async function getProjects() {
208
+ return (await fetchFormData()).projects;
209
+ }
210
+ export async function createHour(params) {
211
+ const { staffId } = await fetchFormData();
212
+ const body = {
213
+ "daily_hour[initial_date]": params.date,
214
+ "daily_hour[project_id]": params.project_id,
215
+ "daily_hour[staff_id]": staffId,
216
+ "daily_hour[hours]": params.hours,
217
+ "daily_hour[category]": categoryId(params.category),
218
+ "daily_hour[description]": params.description,
219
+ "daily_hour[in_home]": params.in_home ? "1" : "0",
220
+ "daily_hour[extra_allocation]": params.extra_allocation ? "1" : "0",
221
+ commit: "Guardar Horas Proyectos",
222
+ };
223
+ const res = await apiPost("/admin/daily_hours", body);
224
+ if (res.status === 302 || res.status === 303) {
225
+ return { success: true, message: `Hours created for ${params.date}` };
226
+ }
227
+ const responseBody = await res.text();
228
+ const errorMatch = responseBody.match(/<li>([^<]+)<\/li>/g);
229
+ const errors = errorMatch
230
+ ? errorMatch.map((e) => e.replace(/<\/?li>/g, "")).join(", ")
231
+ : `status ${res.status}`;
232
+ return {
233
+ success: false,
234
+ message: `Failed to create hours for ${params.date}: ${errors}`,
235
+ };
236
+ }
237
+ export async function deleteHour(id) {
238
+ await apiDelete(`/admin/daily_hours/${id}`);
239
+ return { success: true, message: `Hour entry ${id} deleted` };
240
+ }
241
+ export async function batchDeleteHours(ids) {
242
+ const s = await session();
243
+ const form = await fetchFormData();
244
+ const params = new URLSearchParams();
245
+ params.set("authenticity_token", form.csrfFormToken);
246
+ params.set("batch_action", "destroy");
247
+ for (const id of ids) {
248
+ params.append("collection_selection[]", id);
249
+ }
250
+ const res = await fetch(`${BASE_URL}/admin/daily_hours/batch_action`, {
251
+ method: "POST",
252
+ headers: {
253
+ "Content-Type": "application/x-www-form-urlencoded",
254
+ Cookie: s.cookies,
255
+ },
256
+ body: params.toString(),
257
+ redirect: "manual",
258
+ });
259
+ if (res.status === 302 || res.status === 303) {
260
+ return { success: true, message: `${ids.length} entries deleted` };
261
+ }
262
+ return { success: false, message: `Batch delete failed (status ${res.status})` };
263
+ }
@@ -0,0 +1,15 @@
1
+ export interface SessionData {
2
+ cookies: string;
3
+ csrfToken: string;
4
+ email: string;
5
+ savedAt: string;
6
+ }
7
+ export declare function loadSession(): Promise<SessionData | null>;
8
+ export declare function clearSession(): Promise<void>;
9
+ export declare function saveMainProject(projectId: string, projectName: string): Promise<void>;
10
+ export declare function loadMainProject(): Promise<{
11
+ id: string;
12
+ name: string;
13
+ } | null>;
14
+ export declare function isSessionValid(session: SessionData): Promise<boolean>;
15
+ export declare function authenticate(): Promise<SessionData>;
package/build/auth.js ADDED
@@ -0,0 +1,154 @@
1
+ import { chromium } from "playwright-core";
2
+ import { existsSync } from "fs";
3
+ import { execSync } from "child_process";
4
+ import keytar from "keytar";
5
+ import { getBrowserPaths, detectEnvironment, log } from "./diagnostics.js";
6
+ const SERVICE = "vairix-admin-mcp";
7
+ const BASE_URL = "https://admin.vairix.com";
8
+ const LOGIN_URL = `${BASE_URL}/admin/login`;
9
+ async function saveSession(session) {
10
+ await keytar.setPassword(SERVICE, "session", JSON.stringify(session));
11
+ }
12
+ export async function loadSession() {
13
+ try {
14
+ const data = await keytar.getPassword(SERVICE, "session");
15
+ if (!data)
16
+ return null;
17
+ return JSON.parse(data);
18
+ }
19
+ catch (e) {
20
+ log("error", `Failed to load session: ${e instanceof Error ? e.message : e}`);
21
+ return null;
22
+ }
23
+ }
24
+ export async function clearSession() {
25
+ await keytar.deletePassword(SERVICE, "session");
26
+ await keytar.deletePassword(SERVICE, "main_project").catch(() => { });
27
+ }
28
+ export async function saveMainProject(projectId, projectName) {
29
+ await keytar.setPassword(SERVICE, "main_project", JSON.stringify({ id: projectId, name: projectName }));
30
+ }
31
+ export async function loadMainProject() {
32
+ try {
33
+ const data = await keytar.getPassword(SERVICE, "main_project");
34
+ if (!data)
35
+ return null;
36
+ return JSON.parse(data);
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ export async function isSessionValid(session) {
43
+ try {
44
+ const res = await fetch(`${BASE_URL}/admin/daily_hours.json?scope=today`, {
45
+ headers: { Cookie: session.cookies },
46
+ redirect: "manual",
47
+ });
48
+ return res.status === 200;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ function getScreenSize() {
55
+ try {
56
+ if (process.platform === "win32") {
57
+ const output = execSync('powershell -Command "Add-Type -AssemblyName System.Windows.Forms; $s = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea; Write-Output \\"$($s.Width),$($s.Height)\\""', { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] }).trim();
58
+ const [w, h] = output.split(",").map(Number);
59
+ if (w && h)
60
+ return { width: w, height: h };
61
+ }
62
+ else if (process.platform === "darwin") {
63
+ const output = execSync("system_profiler SPDisplaysDataType 2>/dev/null | grep Resolution", { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] }).trim();
64
+ const match = output.match(/(\d+)\s*x\s*(\d+)/);
65
+ if (match)
66
+ return { width: Number(match[1]), height: Number(match[2]) };
67
+ }
68
+ }
69
+ catch { }
70
+ return { width: 1920, height: 1080 };
71
+ }
72
+ function findChromiumBrowser() {
73
+ const paths = getBrowserPaths();
74
+ for (const p of paths) {
75
+ if (existsSync(p))
76
+ return p;
77
+ }
78
+ const env = detectEnvironment();
79
+ if (env.isWSL) {
80
+ throw new Error("No Chromium-based browser found inside WSL. You need a browser installed in WSL itself, not just on Windows. Run with --health-check for setup instructions.");
81
+ }
82
+ throw new Error("No Chromium-based browser found. Install Chrome, Edge, or Brave and try again.");
83
+ }
84
+ export async function authenticate() {
85
+ log("info", "Authentication started");
86
+ const chromePath = findChromiumBrowser();
87
+ log("info", `Browser found: ${chromePath}`);
88
+ const windowWidth = 900;
89
+ const windowHeight = 700;
90
+ const screen = getScreenSize();
91
+ const left = Math.round((screen.width - windowWidth) / 2);
92
+ const top = Math.round((screen.height - windowHeight) / 2);
93
+ const browser = await chromium.launch({
94
+ headless: false,
95
+ executablePath: chromePath,
96
+ args: [
97
+ "--disable-blink-features=AutomationControlled",
98
+ `--window-size=${windowWidth},${windowHeight}`,
99
+ `--window-position=${left},${top}`,
100
+ ],
101
+ });
102
+ const context = await browser.newContext({
103
+ viewport: { width: windowWidth, height: windowHeight - 80 },
104
+ });
105
+ const page = await context.newPage();
106
+ await page.goto(LOGIN_URL);
107
+ await page.waitForLoadState("domcontentloaded");
108
+ if (process.platform === "win32") {
109
+ try {
110
+ execSync(`powershell -Command "(New-Object -ComObject WScript.Shell).AppActivate('admin.vairix.com')"`, { stdio: "ignore" });
111
+ }
112
+ catch { }
113
+ }
114
+ else {
115
+ await page.bringToFront();
116
+ }
117
+ try {
118
+ await page.waitForURL((url) => !url.toString().includes("/login"), {
119
+ timeout: 120_000,
120
+ });
121
+ }
122
+ catch {
123
+ await browser.close();
124
+ log("warn", "Authentication timed out");
125
+ throw new Error("Login timed out. Chrome was closed or login was not completed in time. Try again with `auth`.");
126
+ }
127
+ await page.waitForLoadState("networkidle");
128
+ // Capture cookies
129
+ const browserCookies = await context.cookies();
130
+ const cookieHeader = browserCookies
131
+ .map((c) => `${c.name}=${c.value}`)
132
+ .join("; ");
133
+ // Get CSRF token
134
+ const csrfToken = await page.evaluate(() => {
135
+ const meta = document.querySelector('meta[name="csrf-token"]');
136
+ return meta?.getAttribute("content") ?? "";
137
+ });
138
+ // Get user email from the page
139
+ const email = await page.evaluate(() => {
140
+ const links = Array.from(document.querySelectorAll("a"));
141
+ const emailLink = links.find((a) => a.textContent?.includes("@vairix.com"));
142
+ return emailLink?.textContent?.trim() ?? "unknown";
143
+ });
144
+ await browser.close();
145
+ const session = {
146
+ cookies: cookieHeader,
147
+ csrfToken,
148
+ email,
149
+ savedAt: new Date().toISOString(),
150
+ };
151
+ await saveSession(session);
152
+ log("info", `Authentication successful: ${email}`);
153
+ return session;
154
+ }
@@ -0,0 +1,26 @@
1
+ export declare function isWSL(): boolean;
2
+ export interface Environment {
3
+ isWSL: boolean;
4
+ platform: string;
5
+ nodeVersion: string;
6
+ nodeMajor: number;
7
+ }
8
+ export declare function detectEnvironment(): Environment;
9
+ export declare function getBrowserPaths(): string[];
10
+ export interface CheckResult {
11
+ name: string;
12
+ status: "ok" | "warn" | "fail";
13
+ message: string;
14
+ fix?: string;
15
+ }
16
+ export declare function checkNodeVersion(): CheckResult;
17
+ export declare function checkBrowser(): CheckResult;
18
+ export declare function checkKeychain(): Promise<CheckResult>;
19
+ export declare function runAllChecks(): Promise<CheckResult[]>;
20
+ export declare function runStartupChecks(): {
21
+ fatal?: string;
22
+ warnings: string[];
23
+ };
24
+ export declare function formatCheckResults(checks: CheckResult[]): string;
25
+ export declare function enhanceError(error: unknown): string;
26
+ export declare function log(level: "info" | "warn" | "error", message: string): void;
@@ -0,0 +1,197 @@
1
+ import { existsSync, readFileSync, renameSync, mkdirSync, appendFileSync, statSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+ // ---- Environment detection ----
5
+ export function isWSL() {
6
+ if (process.env.WSL_DISTRO_NAME)
7
+ return true;
8
+ try {
9
+ const version = readFileSync("/proc/version", "utf-8");
10
+ return /microsoft/i.test(version);
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ export function detectEnvironment() {
17
+ const ver = process.version.replace(/^v/, "");
18
+ return {
19
+ isWSL: isWSL(),
20
+ platform: process.platform,
21
+ nodeVersion: ver,
22
+ nodeMajor: parseInt(ver.split(".")[0], 10),
23
+ };
24
+ }
25
+ // ---- Browser paths (shared with auth.ts) ----
26
+ export function getBrowserPaths() {
27
+ return [
28
+ // macOS - Chrome
29
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
30
+ "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
31
+ // macOS - Edge
32
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
33
+ // macOS - Brave
34
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
35
+ // macOS - Chromium
36
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
37
+ // Linux - Chrome
38
+ "/usr/bin/google-chrome",
39
+ "/usr/bin/google-chrome-stable",
40
+ // Linux - Edge
41
+ "/usr/bin/microsoft-edge",
42
+ "/usr/bin/microsoft-edge-stable",
43
+ // Linux - Brave
44
+ "/usr/bin/brave-browser",
45
+ "/usr/bin/brave-browser-stable",
46
+ // Linux - Chromium
47
+ "/usr/bin/chromium-browser",
48
+ "/usr/bin/chromium",
49
+ // Windows - Chrome
50
+ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
51
+ "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
52
+ // Windows - Edge
53
+ "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
54
+ "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
55
+ // Windows - Brave
56
+ "C:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe",
57
+ "C:\\Program Files (x86)\\BraveSoftware\\Brave-Browser\\Application\\brave.exe",
58
+ ];
59
+ }
60
+ export function checkNodeVersion() {
61
+ const env = detectEnvironment();
62
+ if (env.nodeMajor >= 18) {
63
+ return { name: "Node.js", status: "ok", message: `v${env.nodeVersion}` };
64
+ }
65
+ return {
66
+ name: "Node.js",
67
+ status: "fail",
68
+ message: `v${env.nodeVersion} (requires >= 18)`,
69
+ fix: "Install Node.js 18 or later: https://nodejs.org",
70
+ };
71
+ }
72
+ export function checkBrowser() {
73
+ const env = detectEnvironment();
74
+ const paths = getBrowserPaths();
75
+ const found = paths.find((p) => existsSync(p));
76
+ if (found) {
77
+ const name = found.split("/").pop() ?? found;
78
+ return { name: "Browser", status: "ok", message: name };
79
+ }
80
+ if (env.isWSL) {
81
+ return {
82
+ name: "Browser",
83
+ status: "fail",
84
+ message: "No Chromium browser found inside WSL",
85
+ fix: "Install Chrome in WSL: sudo apt install -y chromium-browser\nOr: wget -q https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb && sudo dpkg -i google-chrome-stable_current_amd64.deb && sudo apt -f install -y",
86
+ };
87
+ }
88
+ return {
89
+ name: "Browser",
90
+ status: "fail",
91
+ message: "No Chromium-based browser found",
92
+ fix: "Install Chrome, Edge, or Brave.",
93
+ };
94
+ }
95
+ export async function checkKeychain() {
96
+ const env = detectEnvironment();
97
+ try {
98
+ const keytar = await import("keytar");
99
+ const testKey = "vairix-admin-mcp-healthcheck";
100
+ await keytar.default.setPassword(testKey, "test", "ok");
101
+ const val = await keytar.default.getPassword(testKey, "test");
102
+ await keytar.default.deletePassword(testKey, "test");
103
+ if (val === "ok") {
104
+ return { name: "Keychain", status: "ok", message: "Working" };
105
+ }
106
+ return { name: "Keychain", status: "fail", message: "Read/write test failed" };
107
+ }
108
+ catch (e) {
109
+ const msg = e instanceof Error ? e.message : String(e);
110
+ if (env.isWSL) {
111
+ return {
112
+ name: "Keychain",
113
+ status: "fail",
114
+ message: `Keychain unavailable in WSL: ${msg}`,
115
+ fix: "Install libsecret and gnome-keyring:\n sudo apt install -y libsecret-1-dev gnome-keyring\n eval $(gnome-keyring-daemon --start --components=secrets 2>/dev/null)",
116
+ };
117
+ }
118
+ return {
119
+ name: "Keychain",
120
+ status: "fail",
121
+ message: `Keychain error: ${msg}`,
122
+ fix: env.platform === "linux"
123
+ ? "Install libsecret: sudo apt install -y libsecret-1-dev"
124
+ : undefined,
125
+ };
126
+ }
127
+ }
128
+ export async function runAllChecks() {
129
+ return [
130
+ checkNodeVersion(),
131
+ checkBrowser(),
132
+ await checkKeychain(),
133
+ ];
134
+ }
135
+ export function runStartupChecks() {
136
+ const warnings = [];
137
+ const env = detectEnvironment();
138
+ if (env.nodeMajor < 18) {
139
+ return {
140
+ fatal: `Node.js ${env.nodeVersion} is not supported. Please upgrade to Node.js 18 or later.`,
141
+ warnings,
142
+ };
143
+ }
144
+ if (env.isWSL) {
145
+ warnings.push("Running inside WSL. Browser auth and keychain require extra setup. Run with --health-check for details.");
146
+ }
147
+ return { warnings };
148
+ }
149
+ export function formatCheckResults(checks) {
150
+ const lines = checks.map((c) => {
151
+ const icon = c.status === "ok" ? "[OK]" : c.status === "warn" ? "[WARN]" : "[FAIL]";
152
+ let line = `${icon} ${c.name}: ${c.message}`;
153
+ if (c.fix) {
154
+ line += `\n Fix: ${c.fix}`;
155
+ }
156
+ return line;
157
+ });
158
+ const hasFailure = checks.some((c) => c.status === "fail");
159
+ const summary = hasFailure
160
+ ? "\nSome checks failed. Fix the issues above before using the server."
161
+ : "\nAll checks passed.";
162
+ return lines.join("\n") + summary;
163
+ }
164
+ // ---- Error enhancement ----
165
+ export function enhanceError(error) {
166
+ const msg = error instanceof Error ? error.message : String(error);
167
+ const env = detectEnvironment();
168
+ if (!env.isWSL)
169
+ return msg;
170
+ if (/no chromium|browser.*not found|executable.*not found|launch/i.test(msg)) {
171
+ return `${msg}\n\nWSL detected: You need a browser installed inside WSL, not just on Windows.\nInstall Chrome: sudo apt install -y chromium-browser\nOr run --health-check for full setup instructions.`;
172
+ }
173
+ if (/keytar|keychain|secret|dbus/i.test(msg)) {
174
+ return `${msg}\n\nWSL detected: The OS keychain requires libsecret and gnome-keyring.\nInstall: sudo apt install -y libsecret-1-dev gnome-keyring\nThen: eval $(gnome-keyring-daemon --start --components=secrets 2>/dev/null)`;
175
+ }
176
+ return msg;
177
+ }
178
+ // ---- Logging ----
179
+ const LOG_DIR = join(homedir(), ".vairix-admin-mcp", "logs");
180
+ const LOG_FILE = join(LOG_DIR, "vairix-admin-mcp.log");
181
+ const MAX_LOG_SIZE = 1_048_576; // 1MB
182
+ export function log(level, message) {
183
+ try {
184
+ mkdirSync(LOG_DIR, { recursive: true });
185
+ try {
186
+ const stat = statSync(LOG_FILE);
187
+ if (stat.size > MAX_LOG_SIZE) {
188
+ renameSync(LOG_FILE, `${LOG_FILE}.1`);
189
+ }
190
+ }
191
+ catch { }
192
+ const timestamp = new Date().toISOString();
193
+ const line = `${timestamp} [${level.toUpperCase()}] ${message}\n`;
194
+ appendFileSync(LOG_FILE, line);
195
+ }
196
+ catch { }
197
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/build/index.js ADDED
@@ -0,0 +1,375 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ import { authenticate, loadSession, isSessionValid, clearSession, saveMainProject, loadMainProject } from "./auth.js";
6
+ import { getHours, getPendingDays, getProjects, createHour, deleteHour, categoryName, } from "./api.js";
7
+ import { detectEnvironment, runAllChecks, runStartupChecks, formatCheckResults, enhanceError, log, } from "./diagnostics.js";
8
+ const server = new McpServer({
9
+ name: "vairix-admin",
10
+ version: "1.0.0",
11
+ });
12
+ function formatHours(hours) {
13
+ if (hours.length === 0)
14
+ return "No hours found.";
15
+ return hours
16
+ .map((h) => `[${h.id}] ${h.initial_date} | ${h.hours}h | project:${h.project_id} | ${categoryName(h.category)} | ${h.description}${h.extra_allocation ? " (Extra)" : ""}`)
17
+ .join("\n");
18
+ }
19
+ // ---- Tools ----
20
+ server.tool("auth", "Opens Chrome so the user can login to Vairix Admin manually. Required before using any other tool. Captures session cookies and stores them in the OS keychain. The user will see a Chrome window — do not call this without telling them first.", {}, { idempotentHint: true, openWorldHint: true }, async () => {
21
+ try {
22
+ const session = await authenticate();
23
+ const projects = await getProjects();
24
+ const mainProject = await loadMainProject();
25
+ const projectList = projects.map((p) => `- [${p.id}] ${p.name}`).join("\n");
26
+ const mainInfo = mainProject
27
+ ? `Current main project: [${mainProject.id}] ${mainProject.name}`
28
+ : "No main project set. Use `set_main_project` to set one.";
29
+ return {
30
+ content: [{
31
+ type: "text",
32
+ text: `Authenticated as ${session.email}. Session saved.\n\nAvailable projects:\n${projectList}\n\n${mainInfo}\n\nIMPORTANT: If no main project is set, you MUST ask the user to pick their main project from the list above using a selection UI (e.g. AskUserQuestion with the project names as options). Then call \`set_main_project\` with the chosen project_id and project_name.`,
33
+ }],
34
+ };
35
+ }
36
+ catch (e) {
37
+ log("error", `Auth failed: ${e instanceof Error ? e.message : e}`);
38
+ return {
39
+ content: [{
40
+ type: "text",
41
+ text: `Auth failed: ${enhanceError(e)}`,
42
+ }],
43
+ isError: true,
44
+ };
45
+ }
46
+ });
47
+ server.tool("auth_status", "Check if the user has a valid session. Call this before other tools if unsure whether the user is authenticated. Returns the user's email and session age if valid.", {}, { readOnlyHint: true, openWorldHint: true }, async () => {
48
+ const session = await loadSession();
49
+ if (!session) {
50
+ return {
51
+ content: [{
52
+ type: "text",
53
+ text: "Not authenticated. Use `auth` tool to open Chrome and login.",
54
+ }],
55
+ };
56
+ }
57
+ const valid = await isSessionValid(session);
58
+ if (!valid) {
59
+ return {
60
+ content: [{
61
+ type: "text",
62
+ text: `Session expired (was ${session.email}, saved ${session.savedAt}). Use \`auth\` to login again.`,
63
+ }],
64
+ };
65
+ }
66
+ return {
67
+ content: [{
68
+ type: "text",
69
+ text: `Authenticated as ${session.email}. Session from ${session.savedAt}.`,
70
+ }],
71
+ };
72
+ });
73
+ server.tool("logout", "Clear the saved session from the OS keychain. The user will need to call `auth` again to re-authenticate.", {}, { destructiveHint: true, idempotentHint: true }, async () => {
74
+ try {
75
+ await clearSession();
76
+ }
77
+ catch { }
78
+ return {
79
+ content: [{ type: "text", text: "Session cleared. Use `auth` to login again." }],
80
+ };
81
+ });
82
+ server.tool("set_main_project", "Set the user's main project for logging hours. Call `get_projects` first to see available projects. The main project is saved in the OS keychain and used as default for `create_hours`. IMPORTANT: When the user mentions a project by name (e.g. 'cargame horas en Seekr', 'log hours to ProjectX'), call `get_projects` to find the matching project_id, then call this tool to switch before creating hours.", {
83
+ project_id: z.string().describe("The project ID from `get_projects`"),
84
+ project_name: z.string().describe("The project name (for display)"),
85
+ }, { idempotentHint: true }, async ({ project_id, project_name }) => {
86
+ try {
87
+ await saveMainProject(project_id, project_name);
88
+ return {
89
+ content: [{ type: "text", text: `Main project set to [${project_id}] ${project_name}.` }],
90
+ };
91
+ }
92
+ catch (e) {
93
+ return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : e}` }], isError: true };
94
+ }
95
+ });
96
+ server.tool("get_pending_days", "Get workdays that are missing hour entries for the current month. Use this to find out which days the user still needs to log hours for.", {}, { readOnlyHint: true, openWorldHint: true }, async () => {
97
+ try {
98
+ const days = await getPendingDays();
99
+ if (days.length === 0) {
100
+ return { content: [{ type: "text", text: "All hours are up to date! No pending days." }] };
101
+ }
102
+ return {
103
+ content: [{
104
+ type: "text",
105
+ text: `Pending days (missing hours):\n${days.map((d) => `- ${d}`).join("\n")}`,
106
+ }],
107
+ };
108
+ }
109
+ catch (e) {
110
+ return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : e}` }], isError: true };
111
+ }
112
+ });
113
+ server.tool("get_hours", "Get the user's logged hour entries. Returns ID, date, hours, project_id, category, and description for each entry. Use the ID from results to delete entries with `delete_hours`. For totals and aggregation, use `get_hours_summary` instead.", {
114
+ scope: z
115
+ .enum(["current_month", "all", "today", "yesterday"])
116
+ .default("current_month")
117
+ .describe("Time scope: current_month (default), today, yesterday, or all"),
118
+ project_id: z
119
+ .string()
120
+ .optional()
121
+ .describe("Filter by project ID (from `get_projects`)"),
122
+ date_from: z
123
+ .string()
124
+ .optional()
125
+ .describe("Filter start date (YYYY-MM-DD). Auto-sets scope to 'all'."),
126
+ date_to: z
127
+ .string()
128
+ .optional()
129
+ .describe("Filter end date (YYYY-MM-DD). Auto-sets scope to 'all'."),
130
+ }, { readOnlyHint: true, openWorldHint: true }, async ({ scope, project_id, date_from, date_to }) => {
131
+ try {
132
+ const filter = { scope };
133
+ if (project_id)
134
+ filter.project_id = project_id;
135
+ if (date_from) {
136
+ filter.date_from = date_from;
137
+ filter.scope = "all";
138
+ }
139
+ if (date_to) {
140
+ filter.date_to = date_to;
141
+ filter.scope = "all";
142
+ }
143
+ let hours = await getHours(filter);
144
+ if (filter.scope === "current_month") {
145
+ const now = new Date();
146
+ const monthPrefix = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
147
+ if (!hours.some((h) => h.initial_date.startsWith(monthPrefix))) {
148
+ filter.scope = "all";
149
+ filter.date_from = `${monthPrefix}-01`;
150
+ filter.date_to = `${monthPrefix}-${String(now.getDate()).padStart(2, "0")}`;
151
+ hours = await getHours(filter);
152
+ }
153
+ }
154
+ return {
155
+ content: [{
156
+ type: "text",
157
+ text: `Hours (${filter.scope}, ${hours.length} entries):\n${formatHours(hours)}`,
158
+ }],
159
+ };
160
+ }
161
+ catch (e) {
162
+ return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : e}` }], isError: true };
163
+ }
164
+ });
165
+ server.tool("get_projects", "List projects the user can log hours to. Returns project ID and name. You MUST call this before `create_hours` to get a valid project_id.", {}, { readOnlyHint: true, openWorldHint: true }, async () => {
166
+ try {
167
+ const projects = await getProjects();
168
+ if (projects.length === 0) {
169
+ return { content: [{ type: "text", text: "No projects found." }] };
170
+ }
171
+ return {
172
+ content: [{
173
+ type: "text",
174
+ text: `Available projects:\n${projects.map((p) => `- [${p.id}] ${p.name}`).join("\n")}`,
175
+ }],
176
+ };
177
+ }
178
+ catch (e) {
179
+ return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : e}` }], isError: true };
180
+ }
181
+ });
182
+ server.tool("get_hours_summary", "Get aggregated hour totals with breakdown. Use this for questions like 'how many hours on project X?' or 'hours by category this month'. Defaults to current month.", {
183
+ project_id: z
184
+ .string()
185
+ .optional()
186
+ .describe("Filter by project ID (from `get_projects`)"),
187
+ date_from: z
188
+ .string()
189
+ .optional()
190
+ .describe("Start date (YYYY-MM-DD). Defaults to first day of current month."),
191
+ date_to: z
192
+ .string()
193
+ .optional()
194
+ .describe("End date (YYYY-MM-DD). Defaults to today."),
195
+ group_by: z
196
+ .enum(["project", "category", "date"])
197
+ .default("project")
198
+ .describe("How to group the breakdown: project, category, or date"),
199
+ }, { readOnlyHint: true, openWorldHint: true }, async ({ project_id, date_from, date_to, group_by }) => {
200
+ try {
201
+ const now = new Date();
202
+ const monthPrefix = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
203
+ const effectiveFrom = date_from ?? `${monthPrefix}-01`;
204
+ const effectiveTo = date_to ?? `${monthPrefix}-${String(now.getDate()).padStart(2, "0")}`;
205
+ const filter = {
206
+ scope: "all",
207
+ date_from: effectiveFrom,
208
+ date_to: effectiveTo,
209
+ };
210
+ if (project_id)
211
+ filter.project_id = project_id;
212
+ const hours = await getHours(filter);
213
+ const totalHours = hours.reduce((sum, h) => sum + h.hours, 0);
214
+ const projectMap = new Map();
215
+ if (group_by === "project") {
216
+ const projects = await getProjects();
217
+ for (const p of projects)
218
+ projectMap.set(p.id, p.name);
219
+ }
220
+ const groups = new Map();
221
+ for (const h of hours) {
222
+ let key;
223
+ if (group_by === "project") {
224
+ key = projectMap.get(String(h.project_id)) ?? `Project ${h.project_id}`;
225
+ }
226
+ else if (group_by === "category") {
227
+ key = categoryName(h.category);
228
+ }
229
+ else {
230
+ key = h.initial_date;
231
+ }
232
+ groups.set(key, (groups.get(key) ?? 0) + h.hours);
233
+ }
234
+ const sorted = [...groups.entries()].sort((a, b) => b[1] - a[1]);
235
+ const breakdown = sorted
236
+ .map(([key, hrs]) => {
237
+ const pct = totalHours > 0 ? ((hrs / totalHours) * 100).toFixed(1) : "0.0";
238
+ return `- ${key}: ${hrs}h (${pct}%)`;
239
+ })
240
+ .join("\n");
241
+ return {
242
+ content: [{
243
+ type: "text",
244
+ text: `Summary (${effectiveFrom} to ${effectiveTo}, ${hours.length} entries):\nTotal: ${totalHours}h\n\nBreakdown by ${group_by}:\n${breakdown}`,
245
+ }],
246
+ };
247
+ }
248
+ catch (e) {
249
+ return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : e}` }], isError: true };
250
+ }
251
+ });
252
+ server.tool("create_hours", "Log hours for one or more dates. Uses the main project if project_id is omitted — if the user mentions a different project by name, call `set_main_project` first to switch. Cannot log future dates. To update an existing entry, delete it first with `delete_hours` then recreate.", {
253
+ dates: z.union([
254
+ z.array(z.string()),
255
+ z.string().transform((s) => {
256
+ try {
257
+ return JSON.parse(s);
258
+ }
259
+ catch {
260
+ return [s];
261
+ }
262
+ }),
263
+ ]).pipe(z.array(z.string()).min(1)).describe('One or more dates in YYYY-MM-DD format. Example: ["2026-02-24", "2026-02-25"]'),
264
+ project_id: z
265
+ .string()
266
+ .regex(/^\d+$/, "project_id must be numeric")
267
+ .optional()
268
+ .describe("Project ID — get valid IDs from `get_projects`. If omitted, uses the main project set via `set_main_project`."),
269
+ hours: z
270
+ .string()
271
+ .default("8")
272
+ .refine((v) => { const n = Number(v); return !isNaN(n) && n > 0 && n <= 24; }, "Hours must be a number between 1 and 24")
273
+ .describe("Hours to log per day (default: 8)"),
274
+ category: z
275
+ .string()
276
+ .default("desarrollador")
277
+ .describe("One of: desarrollador, pm, testing, arquitecto, otro (default: desarrollador)"),
278
+ description: z
279
+ .string()
280
+ .describe("Work description for the time entry"),
281
+ extra_allocation: z
282
+ .boolean()
283
+ .default(false)
284
+ .describe("Set true only for secondary/extra project allocations"),
285
+ in_home: z
286
+ .boolean()
287
+ .default(false)
288
+ .describe("Set true if working from home"),
289
+ }, { openWorldHint: true }, async ({ dates, project_id, hours, category, description, extra_allocation, in_home }) => {
290
+ try {
291
+ let pid = project_id;
292
+ if (!pid) {
293
+ const mainProject = await loadMainProject();
294
+ if (!mainProject) {
295
+ return { content: [{ type: "text", text: "No project_id provided and no main project set. Use `set_main_project` first or pass a project_id." }], isError: true };
296
+ }
297
+ pid = mainProject.id;
298
+ }
299
+ const today = new Date().toISOString().slice(0, 10);
300
+ const futureDates = dates.filter((d) => d > today);
301
+ if (futureDates.length > 0) {
302
+ return { content: [{ type: "text", text: `Cannot log future dates: ${futureDates.join(", ")}` }], isError: true };
303
+ }
304
+ const BATCH_SIZE = 3;
305
+ const results = [];
306
+ for (let i = 0; i < dates.length; i += BATCH_SIZE) {
307
+ const batch = dates.slice(i, i + BATCH_SIZE);
308
+ const batchResults = await Promise.all(batch.map((date) => createHour({ date, project_id: pid, hours, category, description, extra_allocation, in_home })));
309
+ for (let j = 0; j < batch.length; j++) {
310
+ const res = batchResults[j];
311
+ results.push(`${batch[j]}: ${res.success ? "OK" : res.message}`);
312
+ }
313
+ }
314
+ return { content: [{ type: "text", text: `Results:\n${results.join("\n")}` }] };
315
+ }
316
+ catch (e) {
317
+ return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : e}` }], isError: true };
318
+ }
319
+ });
320
+ server.tool("delete_hours", "Delete one or more hour entries by ID. Get IDs from `get_hours` results. This action is irreversible — you MUST confirm with the user before calling this tool, listing the entries that will be deleted. Pass a single ID as ['123'] or multiple as ['123','456']. Multiple IDs use a single batch request.", {
321
+ ids: z.union([
322
+ z.array(z.string()),
323
+ z.string().transform((s) => {
324
+ try {
325
+ return JSON.parse(s);
326
+ }
327
+ catch {
328
+ return [s];
329
+ }
330
+ }),
331
+ z.number().transform((n) => [String(n)]),
332
+ ]).pipe(z.array(z.string()).min(1)).describe("Entry IDs to delete. Example: ['182353'] or ['182353','182351','182350']"),
333
+ }, { destructiveHint: true, openWorldHint: true }, async ({ ids }) => {
334
+ try {
335
+ const results = await Promise.all(ids.map((id) => deleteHour(id).then(() => id).catch((e) => `${id}: ${e instanceof Error ? e.message : e}`)));
336
+ const deleted = results.filter((r) => !r.includes(":"));
337
+ const failed = results.filter((r) => r.includes(":"));
338
+ const parts = [];
339
+ if (deleted.length)
340
+ parts.push(`${deleted.length} entries deleted`);
341
+ if (failed.length)
342
+ parts.push(`Failed: ${failed.join(", ")}`);
343
+ return { content: [{ type: "text", text: parts.join(". ") }], ...(failed.length ? { isError: true } : {}) };
344
+ }
345
+ catch (e) {
346
+ return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : e}` }], isError: true };
347
+ }
348
+ });
349
+ async function main() {
350
+ const env = detectEnvironment();
351
+ log("info", `Startup: Node ${env.nodeVersion}, ${env.platform}${env.isWSL ? " (WSL)" : ""}`);
352
+ if (process.argv.includes("--health-check")) {
353
+ const checks = await runAllChecks();
354
+ console.log(formatCheckResults(checks));
355
+ process.exit(checks.some((c) => c.status === "fail") ? 1 : 0);
356
+ }
357
+ const startup = runStartupChecks();
358
+ if (startup.fatal) {
359
+ log("error", `Fatal: ${startup.fatal}`);
360
+ console.error(startup.fatal);
361
+ process.exit(1);
362
+ }
363
+ for (const w of startup.warnings) {
364
+ log("warn", w);
365
+ console.error(`[WARN] ${w}`);
366
+ }
367
+ const transport = new StdioServerTransport();
368
+ await server.connect(transport);
369
+ console.error("vairix-admin MCP running");
370
+ }
371
+ main().catch((e) => {
372
+ log("error", `Fatal: ${e instanceof Error ? e.message : e}`);
373
+ console.error(e);
374
+ process.exit(1);
375
+ });
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@vairix/admin-mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for Vairix Admin - automate time tracking with Claude",
5
+ "type": "module",
6
+ "bin": {
7
+ "vairix-admin-mcp": "./build/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "prepare": "npm run build",
12
+ "dev": "tsc --watch",
13
+ "start": "node build/index.js",
14
+ "test": "node --import tsx --test tests/*.test.ts"
15
+ },
16
+ "files": [
17
+ "build"
18
+ ],
19
+ "keywords": [
20
+ "mcp",
21
+ "vairix",
22
+ "time-tracking",
23
+ "claude",
24
+ "active-admin"
25
+ ],
26
+ "author": "Vairix",
27
+ "license": "MIT",
28
+ "dependencies": {
29
+ "@modelcontextprotocol/sdk": "^1.12.1",
30
+ "keytar": "^7.9.0",
31
+ "playwright-core": "^1.58.2",
32
+ "zod": "^3.24.0"
33
+ },
34
+ "devDependencies": {
35
+ "@types/keytar": "^4.4.0",
36
+ "@types/node": "^22.0.0",
37
+ "tsx": "^4.21.0",
38
+ "typescript": "^5.7.0"
39
+ },
40
+ "engines": {
41
+ "node": ">=18"
42
+ }
43
+ }