@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 +209 -0
- package/build/api.d.ts +48 -0
- package/build/api.js +263 -0
- package/build/auth.d.ts +15 -0
- package/build/auth.js +154 -0
- package/build/diagnostics.d.ts +26 -0
- package/build/diagnostics.js +197 -0
- package/build/index.d.ts +2 -0
- package/build/index.js +375 -0
- package/package.json +43 -0
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§ion=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
|
+
[](https://nodejs.org)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](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§ion=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
|
+
}
|
package/build/auth.d.ts
ADDED
|
@@ -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
|
+
}
|
package/build/index.d.ts
ADDED
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
|
+
}
|