itfin-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -0
- package/dist/clock.js +1 -0
- package/dist/config.js +14 -0
- package/dist/errors.js +10 -0
- package/dist/index.js +22 -0
- package/dist/itfinClient.js +79 -0
- package/dist/jwt.js +10 -0
- package/dist/leave.js +56 -0
- package/dist/login.js +122 -0
- package/dist/reminders.js +61 -0
- package/dist/server.js +557 -0
- package/dist/tokenStore.js +40 -0
- package/dist/workingTime.js +28 -0
- package/package.json +51 -0
- package/scripts/install.sh +188 -0
- package/scripts/pick-clients.mjs +30 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { ToolError } from "./errors.js";
|
|
5
|
+
import { ItfinClient } from "./itfinClient.js";
|
|
6
|
+
import { startBrowserLogin } from "./login.js";
|
|
7
|
+
import { LaunchdReminderScheduler } from "./reminders.js";
|
|
8
|
+
import { decodeToken } from "./jwt.js";
|
|
9
|
+
import { ADDITIONAL_REASONS_SETTING, CLOSED_REQUEST_STATUSES, formatHours, LEAVE_REQUEST_TYPES, leaveReasons, sameLeaveTypeId, toLeaveRequest, } from "./leave.js";
|
|
10
|
+
import { reloginReminderAt, todayIn } from "./workingTime.js";
|
|
11
|
+
// dist/ and src/ both sit next to package.json. CI sets the version when it publishes.
|
|
12
|
+
const PACKAGE_VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
13
|
+
/** A token this close to expiry is treated as expired, to absorb clock skew. */
|
|
14
|
+
export const EXPIRY_SKEW_MS = 5 * 60 * 1000;
|
|
15
|
+
/** Browser use is limited to the login flow; everything else goes through the tools. */
|
|
16
|
+
export const BROWSER_RULE = "Never open or drive a browser on your own initiative for ITFin work. The only automatic browser action allowed is the itfin_login sign-in flow. " +
|
|
17
|
+
"For anything else (for example filing leave or time entries through the ITFin web app when a tool fails), use a browser only if the user explicitly asks for it, or after you've proposed it and the user confirmed. " +
|
|
18
|
+
"If a tool fails, report the error and stop. Don't fall back to web UI automation.";
|
|
19
|
+
export const SERVER_INSTRUCTIONS = `Tools for one user's ITFin workspace: projects, time entries, reopen requests and leave requests.\n\n${BROWSER_RULE}`;
|
|
20
|
+
/** How long itfin_login blocks before it keeps waiting in the background. */
|
|
21
|
+
const LOGIN_BLOCK_MS = 3 * 60 * 1000;
|
|
22
|
+
/** How long the login browser stays open waiting for the user. */
|
|
23
|
+
const LOGIN_MAX_WAIT_MS = 15 * 60 * 1000;
|
|
24
|
+
const REOPEN_REASON_MIN = 10;
|
|
25
|
+
const REOPEN_HINT = "The reporting period for these days is closed. If the user asks or agrees, you can file a reopen request (itfin_request_reopen); a manager must approve it.";
|
|
26
|
+
/** How the agent fills in the reason when the user didn't give one. */
|
|
27
|
+
const REOPEN_REASON_HINT = "Reason shown to the manager, at least 10 characters. If the user didn't give one, invent a short, funny but workplace-safe excuse " +
|
|
28
|
+
"(one or two sentences, in the user's language, a fresh one every time, no blaming colleagues or real people). Tell the user which reason you used when you report the result.";
|
|
29
|
+
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD");
|
|
30
|
+
function toReopenRequest(r) {
|
|
31
|
+
return { id: r.Id, from: r.DateFrom.slice(0, 10), to: r.DateTo.slice(0, 10), status: r.Status, comment: r.Comment, createdAt: r.CreatedAt };
|
|
32
|
+
}
|
|
33
|
+
/** Finds a setting by key anywhere in the workspace settings payload. */
|
|
34
|
+
function findSetting(obj, key) {
|
|
35
|
+
if (!obj || typeof obj !== "object")
|
|
36
|
+
return undefined;
|
|
37
|
+
if (key in obj)
|
|
38
|
+
return obj[key];
|
|
39
|
+
for (const v of Object.values(obj)) {
|
|
40
|
+
const found = findSetting(v, key);
|
|
41
|
+
if (found !== undefined)
|
|
42
|
+
return found;
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
/** When an ITFin token stops being usable: its expiry minus the clock-skew margin. */
|
|
47
|
+
function usableUntil(expiresAt) {
|
|
48
|
+
return new Date(expiresAt.getTime() - EXPIRY_SKEW_MS);
|
|
49
|
+
}
|
|
50
|
+
/** ITFin keeps a time entry's duration both as minutes and as decimal hours. */
|
|
51
|
+
function duration(minutes) {
|
|
52
|
+
return { MinutesInt: minutes, InternalTime: String(minutes / 60) };
|
|
53
|
+
}
|
|
54
|
+
function toEntry(e) {
|
|
55
|
+
return {
|
|
56
|
+
id: e.Id,
|
|
57
|
+
date: e.Date,
|
|
58
|
+
projectId: e.ProjectId,
|
|
59
|
+
projectName: e.ProjectName,
|
|
60
|
+
clientAgreementId: e.ClientAgreementId,
|
|
61
|
+
taskId: e.TaskId,
|
|
62
|
+
minutes: e.MinutesInt,
|
|
63
|
+
comment: e.Comment,
|
|
64
|
+
type: e.Type,
|
|
65
|
+
approved: e.Approved,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export function createItfinServer(deps) {
|
|
69
|
+
const { config, clock, tokenStore } = deps;
|
|
70
|
+
const server = new McpServer({ name: "itfin-mcp", version: PACKAGE_VERSION }, { instructions: SERVER_INSTRUCTIONS });
|
|
71
|
+
/** The stored ITFin token and whether it can still be used. */
|
|
72
|
+
async function tokenState() {
|
|
73
|
+
const stored = await tokenStore.load();
|
|
74
|
+
if (!stored)
|
|
75
|
+
return { valid: false, reason: "no_token" };
|
|
76
|
+
let claims;
|
|
77
|
+
try {
|
|
78
|
+
claims = decodeToken(stored.token);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return { valid: false, reason: "malformed" };
|
|
82
|
+
}
|
|
83
|
+
const info = { email: claims.Email, expiresAt: new Date(claims.exp * 1000) };
|
|
84
|
+
if (stored.rejected)
|
|
85
|
+
return { valid: false, reason: "rejected", ...info };
|
|
86
|
+
if (usableUntil(info.expiresAt) <= clock.now())
|
|
87
|
+
return { valid: false, reason: "expired", ...info };
|
|
88
|
+
return { valid: true, token: stored.token, ...info };
|
|
89
|
+
}
|
|
90
|
+
async function validToken() {
|
|
91
|
+
const state = await tokenState();
|
|
92
|
+
if (state.valid)
|
|
93
|
+
return state.token;
|
|
94
|
+
const why = { no_token: "No ITFin token.", malformed: "The stored ITFin token is unreadable.", rejected: "ITFin rejected the ITFin token.", expired: "The ITFin token has expired." }[state.reason];
|
|
95
|
+
throw new ToolError("AUTH_REQUIRED", `${why} Ask the user to log in (itfin_login).`, { expiresAt: state.expiresAt?.toISOString() });
|
|
96
|
+
}
|
|
97
|
+
async function markRejected(token) {
|
|
98
|
+
const stored = await tokenStore.load();
|
|
99
|
+
if (stored?.token === token)
|
|
100
|
+
await tokenStore.save(token, { ...stored, rejected: true });
|
|
101
|
+
}
|
|
102
|
+
const itfin = new ItfinClient(config.workspaceUrl, validToken, deps.retryDelayMs ?? 500, markRejected);
|
|
103
|
+
const reminders = deps.reminders ?? new LaunchdReminderScheduler();
|
|
104
|
+
const startLogin = deps.startLogin ?? ((maxWaitMs) => startBrowserLogin({ workspaceUrl: config.workspaceUrl, browser: config.browser, maxWaitMs }));
|
|
105
|
+
let loginInProgress;
|
|
106
|
+
/** Stores a freshly issued ITFin token and replaces the relogin reminder. */
|
|
107
|
+
async function completeLogin(token) {
|
|
108
|
+
const claims = decodeToken(token);
|
|
109
|
+
const expiresAt = new Date(claims.exp * 1000);
|
|
110
|
+
await tokenStore.save(token);
|
|
111
|
+
const reminder = await nextReminder(expiresAt, clock.now());
|
|
112
|
+
if (reminder)
|
|
113
|
+
await reminders.schedule(reminder, config.workspaceUrl);
|
|
114
|
+
return { email: claims.Email, expiresAt: expiresAt.toISOString(), nextReminderAt: reminder?.toISOString() };
|
|
115
|
+
}
|
|
116
|
+
async function getDays(from, to) {
|
|
117
|
+
const res = await itfin.get("/v1/tracking", { includes: "log", "filter[from]": from, "filter[to]": to });
|
|
118
|
+
return res.data;
|
|
119
|
+
}
|
|
120
|
+
async function getDay(date) {
|
|
121
|
+
const [day] = await getDays(date, date);
|
|
122
|
+
if (!day)
|
|
123
|
+
throw new ToolError("ITFIN_ERROR", `ITFin returned no data for ${date}.`);
|
|
124
|
+
return day;
|
|
125
|
+
}
|
|
126
|
+
/** The public workspace settings payload; look values up with findSetting. */
|
|
127
|
+
async function workspaceSettingsRaw() {
|
|
128
|
+
const host = new URL(config.workspaceUrl).host;
|
|
129
|
+
return itfin.request("GET", `/v1/auth/workspaces/${host}`, { anonymous: true });
|
|
130
|
+
}
|
|
131
|
+
async function workspaceSettings() {
|
|
132
|
+
const raw = await workspaceSettingsRaw();
|
|
133
|
+
const reopen = findSetting(raw, "OpenReportingApprovals");
|
|
134
|
+
return {
|
|
135
|
+
minCommentLength: Number(findSetting(raw, "TrackingMinCommentLength") ?? 0),
|
|
136
|
+
reopenRequestsEnabled: Boolean(reopen?.Enabled),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
async function employeeId() {
|
|
140
|
+
const stored = await tokenStore.load();
|
|
141
|
+
if (stored?.employeeId)
|
|
142
|
+
return stored.employeeId;
|
|
143
|
+
const me = await itfin.get("/v1/auth");
|
|
144
|
+
if (stored)
|
|
145
|
+
await tokenStore.save(stored.token, { ...stored, employeeId: me.Id });
|
|
146
|
+
return me.Id;
|
|
147
|
+
}
|
|
148
|
+
async function reopenRequests() {
|
|
149
|
+
return itfin.get("/v1/requests/my", { "filter[requestType]": "OpenReporting", page: 1, size: 50 });
|
|
150
|
+
}
|
|
151
|
+
async function reopenRequestsCovering(date) {
|
|
152
|
+
return (await reopenRequests()).map(toReopenRequest).filter((r) => r.from <= date && date <= r.to);
|
|
153
|
+
}
|
|
154
|
+
async function closedDayError(date, message = "Reporting is closed for this day.") {
|
|
155
|
+
const covering = await reopenRequestsCovering(date).catch(() => []);
|
|
156
|
+
return new ToolError("DAY_CLOSED", message, { closedDates: [date], reopenRequests: covering, hint: REOPEN_HINT });
|
|
157
|
+
}
|
|
158
|
+
/** Throws unless time entries on `date` can be written; an approved reopen request lets ITFin decide. */
|
|
159
|
+
async function assertReportable(date, day) {
|
|
160
|
+
const status = dayStatus(day ?? (await getDay(date)));
|
|
161
|
+
if (status === "open")
|
|
162
|
+
return;
|
|
163
|
+
if (status === "future")
|
|
164
|
+
throw new ToolError("DAY_IN_FUTURE", `${date} is not reportable yet.`);
|
|
165
|
+
const approved = (await reopenRequestsCovering(date)).some((r) => r.status === "Approved");
|
|
166
|
+
if (!approved)
|
|
167
|
+
throw await closedDayError(date);
|
|
168
|
+
}
|
|
169
|
+
async function assertComment(comment) {
|
|
170
|
+
const { minCommentLength } = await workspaceSettings();
|
|
171
|
+
if (comment.trim().length < minCommentLength) {
|
|
172
|
+
throw new ToolError("VALIDATION", `The comment must be at least ${minCommentLength} characters.`, { minCommentLength });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** Runs a write, turning ITFin's closed-day rejection into a DAY_CLOSED error with details. */
|
|
176
|
+
async function withClosedDayDetails(date, fn) {
|
|
177
|
+
try {
|
|
178
|
+
return await fn();
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
if (err instanceof ToolError && err.code === "DAY_CLOSED" && !err.details.closedDates)
|
|
182
|
+
throw await closedDayError(date, err.message);
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
async function findEntry(id, date) {
|
|
187
|
+
const day = await getDay(date);
|
|
188
|
+
const entry = day.log.find((e) => e.Id === id);
|
|
189
|
+
if (!entry)
|
|
190
|
+
throw new ToolError("NOT_FOUND", `No time entry ${id} on ${date}.`);
|
|
191
|
+
return { day, entry };
|
|
192
|
+
}
|
|
193
|
+
function dayStatus(day) {
|
|
194
|
+
if (day.isEditable)
|
|
195
|
+
return "open";
|
|
196
|
+
return day.Date < todayIn(clock.now(), config.timezone) ? "closed" : "future";
|
|
197
|
+
}
|
|
198
|
+
server.registerTool("itfin_get_entries", {
|
|
199
|
+
title: "Read time entries",
|
|
200
|
+
description: "Time entries for a date range (inclusive), grouped by day. Use one day, a week or a month as the range. Each day has a status: open (reportable), closed (reporting period closed; needs a reopen request) or future (not reportable yet).",
|
|
201
|
+
inputSchema: { from: isoDate, to: isoDate },
|
|
202
|
+
annotations: { readOnlyHint: true },
|
|
203
|
+
}, ({ from, to }) => handle(async () => {
|
|
204
|
+
if (to < from)
|
|
205
|
+
throw new ToolError("VALIDATION", "`to` must not be before `from`.");
|
|
206
|
+
const days = await getDays(from, to);
|
|
207
|
+
return {
|
|
208
|
+
days: days.map((d) => ({
|
|
209
|
+
date: d.Date,
|
|
210
|
+
status: dayStatus(d),
|
|
211
|
+
isHoliday: d.isHoliday,
|
|
212
|
+
isWeekend: d.isWeekend,
|
|
213
|
+
businessHours: d.BusinessHours,
|
|
214
|
+
totalMinutes: d.log.reduce((sum, e) => sum + e.MinutesInt, 0),
|
|
215
|
+
entries: d.log.map(toEntry),
|
|
216
|
+
})),
|
|
217
|
+
};
|
|
218
|
+
}));
|
|
219
|
+
server.registerTool("itfin_list_projects", {
|
|
220
|
+
title: "List reportable projects",
|
|
221
|
+
description: "Projects the user can report time to on a given date, with the clientAgreementId needed to create a time entry and the project's tasks. The list depends on the date.",
|
|
222
|
+
inputSchema: { date: isoDate },
|
|
223
|
+
annotations: { readOnlyHint: true },
|
|
224
|
+
}, ({ date }) => handle(async () => {
|
|
225
|
+
const projects = await itfin.get("/v1/tracking/tasks", { Date: date, name: "" });
|
|
226
|
+
return {
|
|
227
|
+
projects: projects.map((p) => ({
|
|
228
|
+
projectId: p.ProjectId,
|
|
229
|
+
projectName: p.ProjectName,
|
|
230
|
+
clientAgreementId: p.ClientAgreementId,
|
|
231
|
+
billable: p.agreements?.find((a) => a.Id === p.ClientAgreementId)?.Type !== "NotBillable",
|
|
232
|
+
tasks: (p.tasks ?? []).map((t) => ({ id: t.Id, name: t.Name ?? t.Title ?? "" })),
|
|
233
|
+
})),
|
|
234
|
+
};
|
|
235
|
+
}));
|
|
236
|
+
async function fileReopenRequest(request) {
|
|
237
|
+
const body = { RequestType: "OpenReporting", EmployeeId: await employeeId(), DateFrom: request.from, DateTo: request.to, Comment: request.reason };
|
|
238
|
+
const res = await itfin.request("POST", "/v1/requests/open-reporting", { body });
|
|
239
|
+
return { requested: true, id: res?.Id };
|
|
240
|
+
}
|
|
241
|
+
server.registerTool("itfin_create_entry", {
|
|
242
|
+
title: "Create a time entry",
|
|
243
|
+
description: "Creates a time entry for the logged-in user. Get clientAgreementId and taskId from itfin_list_projects for the same date. Fails with DAY_CLOSED or DAY_IN_FUTURE when the day is not reportable, and with VALIDATION when the comment is shorter than the workspace minimum.",
|
|
244
|
+
inputSchema: {
|
|
245
|
+
date: isoDate,
|
|
246
|
+
clientAgreementId: z.number().int(),
|
|
247
|
+
minutes: z.number().int().positive(),
|
|
248
|
+
comment: z.string(),
|
|
249
|
+
taskId: z.number().int().optional(),
|
|
250
|
+
},
|
|
251
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
252
|
+
}, (args) => handle(async () => {
|
|
253
|
+
await assertComment(args.comment);
|
|
254
|
+
await assertReportable(args.date);
|
|
255
|
+
const body = {
|
|
256
|
+
EmployeeId: await employeeId(),
|
|
257
|
+
Date: args.date,
|
|
258
|
+
ClientAgreementId: args.clientAgreementId,
|
|
259
|
+
TaskId: args.taskId ?? null,
|
|
260
|
+
TaskReference: null,
|
|
261
|
+
...duration(args.minutes),
|
|
262
|
+
Comment: args.comment,
|
|
263
|
+
IsNonBillable: false,
|
|
264
|
+
};
|
|
265
|
+
const res = await withClosedDayDetails(args.date, () => itfin.request("POST", "/v1/tracking", { body }));
|
|
266
|
+
return { id: res.Id };
|
|
267
|
+
}));
|
|
268
|
+
server.registerTool("itfin_update_entry", {
|
|
269
|
+
title: "Update a time entry",
|
|
270
|
+
description: "Changes a time entry. Pass its id and current date (from itfin_get_entries) and only the fields to change; use newDate to move it to another day. Both days must be reportable.",
|
|
271
|
+
inputSchema: {
|
|
272
|
+
id: z.number().int(),
|
|
273
|
+
date: isoDate,
|
|
274
|
+
newDate: isoDate.optional(),
|
|
275
|
+
clientAgreementId: z.number().int().optional(),
|
|
276
|
+
taskId: z.number().int().nullable().optional(),
|
|
277
|
+
minutes: z.number().int().positive().optional(),
|
|
278
|
+
comment: z.string().optional(),
|
|
279
|
+
},
|
|
280
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
|
281
|
+
}, (args) => handle(async () => {
|
|
282
|
+
const { day, entry } = await findEntry(args.id, args.date);
|
|
283
|
+
await assertReportable(args.date, day);
|
|
284
|
+
if (args.newDate && args.newDate !== args.date)
|
|
285
|
+
await assertReportable(args.newDate);
|
|
286
|
+
if (args.comment !== undefined)
|
|
287
|
+
await assertComment(args.comment);
|
|
288
|
+
const minutes = args.minutes ?? entry.MinutesInt;
|
|
289
|
+
// Like the web app: send the whole entry minus the integration fields.
|
|
290
|
+
const { ExternalId: _id, ExternalTool: _tool, ...entryFields } = entry;
|
|
291
|
+
const rest = entryFields;
|
|
292
|
+
const agreementChanged = args.clientAgreementId !== undefined && args.clientAgreementId !== entry.ClientAgreementId;
|
|
293
|
+
if (agreementChanged) {
|
|
294
|
+
// Project fields describe the old agreement; ITFin derives them from ClientAgreementId.
|
|
295
|
+
delete rest.ProjectId;
|
|
296
|
+
delete rest.ProjectName;
|
|
297
|
+
}
|
|
298
|
+
const body = {
|
|
299
|
+
...rest,
|
|
300
|
+
Date: args.newDate ?? entry.Date,
|
|
301
|
+
ClientAgreementId: args.clientAgreementId ?? entry.ClientAgreementId,
|
|
302
|
+
TaskId: args.taskId === undefined ? entry.TaskId : args.taskId,
|
|
303
|
+
...duration(minutes),
|
|
304
|
+
Comment: args.comment ?? entry.Comment,
|
|
305
|
+
IsNonBillable: !entry.MinutesExt,
|
|
306
|
+
};
|
|
307
|
+
const res = await withClosedDayDetails(body.Date, () => itfin.request("PUT", `/v1/tracking/${args.id}`, { body }));
|
|
308
|
+
return { id: res.Id };
|
|
309
|
+
}));
|
|
310
|
+
server.registerTool("itfin_delete_entry", {
|
|
311
|
+
title: "Delete a time entry",
|
|
312
|
+
description: "Deletes a time entry by id. Pass its date (from itfin_get_entries); the day must be reportable.",
|
|
313
|
+
inputSchema: { id: z.number().int(), date: isoDate },
|
|
314
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
|
315
|
+
}, (args) => handle(async () => {
|
|
316
|
+
const { day } = await findEntry(args.id, args.date);
|
|
317
|
+
await assertReportable(args.date, day);
|
|
318
|
+
await withClosedDayDetails(args.date, () => itfin.request("DELETE", `/v1/tracking/${args.id}`));
|
|
319
|
+
return { deleted: args.id };
|
|
320
|
+
}));
|
|
321
|
+
server.registerTool("itfin_get_workspace_settings", {
|
|
322
|
+
title: "Workspace settings",
|
|
323
|
+
description: "ITFin workspace rules for time entries: the minimum comment length and whether reopen requests are enabled.",
|
|
324
|
+
annotations: { readOnlyHint: true },
|
|
325
|
+
}, () => handle(async () => {
|
|
326
|
+
const { minCommentLength, reopenRequestsEnabled } = await workspaceSettings();
|
|
327
|
+
return { minCommentLength, reopenRequestsEnabled };
|
|
328
|
+
}));
|
|
329
|
+
server.registerTool("itfin_list_reopen_requests", {
|
|
330
|
+
title: "List reopen requests",
|
|
331
|
+
description: "The user's reopen requests for closed days and their status (e.g. Pending, Approved, Declined).",
|
|
332
|
+
annotations: { readOnlyHint: true },
|
|
333
|
+
}, () => handle(async () => ({ requests: (await reopenRequests()).map(toReopenRequest) })));
|
|
334
|
+
server.registerTool("itfin_request_reopen", {
|
|
335
|
+
title: "Request to reopen closed days",
|
|
336
|
+
description: "Files a reopen request asking a manager to allow reporting on closed days. Call it when the user asks for it or agrees to it in the conversation; that is the go-ahead, with no separate confirmation step. " +
|
|
337
|
+
"If the user didn't say why, make up a funny excuse for the reason yourself instead of asking.",
|
|
338
|
+
inputSchema: { from: isoDate, to: isoDate, reason: z.string().describe(REOPEN_REASON_HINT) },
|
|
339
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
340
|
+
}, (args) => handle(async () => {
|
|
341
|
+
if (args.to < args.from)
|
|
342
|
+
throw new ToolError("VALIDATION", "`to` must not be before `from`.");
|
|
343
|
+
if (args.reason.trim().length < REOPEN_REASON_MIN) {
|
|
344
|
+
throw new ToolError("VALIDATION", `The reason must be at least ${REOPEN_REASON_MIN} characters.`);
|
|
345
|
+
}
|
|
346
|
+
if (!(await workspaceSettings()).reopenRequestsEnabled) {
|
|
347
|
+
throw new ToolError("VALIDATION", "Reopen requests are disabled in this ITFin workspace.");
|
|
348
|
+
}
|
|
349
|
+
return fileReopenRequest({ from: args.from, to: args.to, reason: args.reason });
|
|
350
|
+
}));
|
|
351
|
+
/** Leave types the user can request, as the web app's "Request time off" form offers them. */
|
|
352
|
+
async function leaveTypes() {
|
|
353
|
+
const [types, settings] = await Promise.all([
|
|
354
|
+
itfin.get(`/v3/timeoff-types/available/${todayIn(clock.now(), config.timezone)}/${await employeeId()}`),
|
|
355
|
+
workspaceSettingsRaw(),
|
|
356
|
+
]);
|
|
357
|
+
const reasonOptional = Boolean(findSetting(settings, "TimeoffIsReasonOptional"));
|
|
358
|
+
return (types.timeoffs ?? [])
|
|
359
|
+
// A carry-over (shift day) request moves a working day and needs fields this tool doesn't send.
|
|
360
|
+
.filter((t) => t.type !== "CarryOver" && t.oldSystemTimeoffName !== "CarryOver")
|
|
361
|
+
.map((t) => {
|
|
362
|
+
const requestType = t.oldSystemTimeoffName ?? undefined;
|
|
363
|
+
const setting = requestType ? ADDITIONAL_REASONS_SETTING[requestType] : undefined;
|
|
364
|
+
const reasons = leaveReasons(requestType, setting ? findSetting(settings, setting) : undefined);
|
|
365
|
+
return { id: String(t.id), apiId: t.id, name: t.name, requestType, paid: t.type !== "Unpaid", reasons, reasonRequired: reasons.length > 0 && !reasonOptional };
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
/** The user's leave requests, newest first as ITFin returns them. */
|
|
369
|
+
async function leaveRequests(filter) {
|
|
370
|
+
const requests = await itfin.get("/v1/requests/my", { page: 1, size: 100, ...filter });
|
|
371
|
+
return requests.filter((r) => LEAVE_REQUEST_TYPES.includes(r.RequestType));
|
|
372
|
+
}
|
|
373
|
+
/** The web form's part-day fields: the day, and the time off as "04h 00m" and in hours. */
|
|
374
|
+
function partDayFields(request) {
|
|
375
|
+
if (request.hours === undefined)
|
|
376
|
+
return { date: null, isPartDay: false };
|
|
377
|
+
const minutes = Math.round(request.hours * 60);
|
|
378
|
+
return { date: request.from, isPartDay: true, formattedHours: formatHours(minutes), hours: minutes / 60 };
|
|
379
|
+
}
|
|
380
|
+
async function fileLeaveRequest(request, requestType) {
|
|
381
|
+
const partDay = partDayFields(request);
|
|
382
|
+
const body = {
|
|
383
|
+
EmployeeId: await employeeId(),
|
|
384
|
+
TimeoffTypeId: request.leaveTypeId,
|
|
385
|
+
TimeoffType: requestType ?? null,
|
|
386
|
+
Date: partDay.date,
|
|
387
|
+
DateFrom: request.from,
|
|
388
|
+
DateTo: request.to,
|
|
389
|
+
IsPartDay: partDay.isPartDay,
|
|
390
|
+
...(partDay.isPartDay ? { FormattedHours: partDay.formattedHours, Hours: partDay.hours } : {}),
|
|
391
|
+
Reason: request.reason ?? null,
|
|
392
|
+
Comment: request.comment,
|
|
393
|
+
};
|
|
394
|
+
const res = await itfin.request("POST", "/v2/requests/timeoff", { body });
|
|
395
|
+
return { requested: true, id: res?.Id };
|
|
396
|
+
}
|
|
397
|
+
server.registerTool("itfin_list_leave_types", {
|
|
398
|
+
title: "List leave types",
|
|
399
|
+
description: "Leave types the user can request (vacation / day off, sick leave, paid or unpaid leave, as configured in the workspace), with the id for itfin_request_leave and the reasons ITFin accepts for each.",
|
|
400
|
+
annotations: { readOnlyHint: true },
|
|
401
|
+
}, () => handle(async () => ({ leaveTypes: (await leaveTypes()).map(({ apiId: _, ...t }) => t) })));
|
|
402
|
+
server.registerTool("itfin_list_leave_requests", {
|
|
403
|
+
title: "List leave requests",
|
|
404
|
+
description: "The user's leave requests (vacation, sick leave, paid and unpaid leave) and their status (Pending, Approved, Rejected, Canceled). Optionally limited to a date range.",
|
|
405
|
+
inputSchema: { from: isoDate.optional(), to: isoDate.optional() },
|
|
406
|
+
annotations: { readOnlyHint: true },
|
|
407
|
+
}, ({ from, to }) => handle(async () => ({ requests: (await leaveRequests({ "filter[from]": from, "filter[to]": to })).map(toLeaveRequest) })));
|
|
408
|
+
server.registerTool("itfin_cancel_leave_request", {
|
|
409
|
+
title: "Cancel a leave request",
|
|
410
|
+
description: "Cancels (deletes) one of the user's leave requests by id (from itfin_list_leave_requests). Only call this when the user asked to cancel that request. " +
|
|
411
|
+
"Works for pending requests; whether an approved one can still be cancelled is up to ITFin.",
|
|
412
|
+
inputSchema: { id: z.number().int() },
|
|
413
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
414
|
+
}, ({ id }) => handle(async () => {
|
|
415
|
+
// Look the id up among the user's leave requests, so this tool can't cancel other kinds of requests.
|
|
416
|
+
const request = (await leaveRequests({})).find((r) => r.Id === id);
|
|
417
|
+
if (!request)
|
|
418
|
+
throw new ToolError("NOT_FOUND", `No leave request ${id} among the user's recent leave requests.`);
|
|
419
|
+
if (CLOSED_REQUEST_STATUSES.includes(request.Status)) {
|
|
420
|
+
throw new ToolError("VALIDATION", `Leave request ${id} is already ${request.Status}.`, { request: toLeaveRequest(request) });
|
|
421
|
+
}
|
|
422
|
+
await itfin.request("DELETE", `/v1/requests/${id}`);
|
|
423
|
+
return { cancelled: id, request: toLeaveRequest(request) };
|
|
424
|
+
}));
|
|
425
|
+
server.registerTool("itfin_request_leave", {
|
|
426
|
+
title: "Request leave",
|
|
427
|
+
description: "Asks the user's manager to approve full or part days of leave: a day off / vacation, sick leave, paid or unpaid leave. Get leaveTypeId and the allowed reasons from itfin_list_leave_types. " +
|
|
428
|
+
"For sick leave, use the leave type with requestType Sickness; if the workspace has none, use the type that lists a sick-leave reason (e.g. Vacation with reason \"SickLeave\") and file it that way without asking. " +
|
|
429
|
+
"For part of a day (e.g. 4h off and 4h of work), set hours with from equal to to. " +
|
|
430
|
+
"Call it when the user asks for the leave in the conversation; that is the go-ahead, with no separate confirmation step. " +
|
|
431
|
+
"ITFin first checks the request (balance, allowed dates), then it is filed. The result says how many days (or hours, for part days) it counts.",
|
|
432
|
+
inputSchema: {
|
|
433
|
+
leaveTypeId: z
|
|
434
|
+
.union([z.string().min(1), z.number().int()])
|
|
435
|
+
.describe("The leave type's id exactly as itfin_list_leave_types returns it, e.g. \"YvDrZ\"."),
|
|
436
|
+
from: isoDate.describe("First day of leave"),
|
|
437
|
+
to: isoDate.describe("Last day of leave (inclusive); same as from for one day"),
|
|
438
|
+
hours: z
|
|
439
|
+
.number()
|
|
440
|
+
.positive()
|
|
441
|
+
.max(24)
|
|
442
|
+
.optional()
|
|
443
|
+
.describe("Part-day leave only: hours off on that one day (from must equal to), e.g. 4 for half a day. Omit for full days."),
|
|
444
|
+
reason: z
|
|
445
|
+
.string()
|
|
446
|
+
.optional()
|
|
447
|
+
.describe("One of the leave type's reasons from itfin_list_leave_types, matching what the user said. For sick leave filed under another type, use its sick-leave reason (e.g. \"SickLeave\"). If the user gave no hint, use \"Other\" for a Sickness type and the closest fit otherwise."),
|
|
448
|
+
comment: z.string().describe("Comment for the manager. If the user didn't give one, write a short neutral one in the user's language (e.g. \"Vacation\" or \"Sick, will be back on Monday\")."),
|
|
449
|
+
},
|
|
450
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
451
|
+
}, (args) => handle(async () => {
|
|
452
|
+
if (args.to < args.from)
|
|
453
|
+
throw new ToolError("VALIDATION", "`to` must not be before `from`.");
|
|
454
|
+
if (!args.comment.trim())
|
|
455
|
+
throw new ToolError("VALIDATION", "The comment must not be empty.");
|
|
456
|
+
const partDay = args.hours !== undefined;
|
|
457
|
+
if (partDay && args.from !== args.to) {
|
|
458
|
+
throw new ToolError("VALIDATION", "Part-day leave covers a single day: `from` and `to` must be the same.");
|
|
459
|
+
}
|
|
460
|
+
if (partDay && Math.round(args.hours * 60) === 0)
|
|
461
|
+
throw new ToolError("VALIDATION", "`hours` must be at least one minute.");
|
|
462
|
+
const types = await leaveTypes();
|
|
463
|
+
const type = types.find((t) => sameLeaveTypeId(t.apiId, args.leaveTypeId));
|
|
464
|
+
if (!type) {
|
|
465
|
+
throw new ToolError("VALIDATION", `Leave type ${args.leaveTypeId} is not available to the user.`, {
|
|
466
|
+
leaveTypes: types.map((t) => ({ id: t.id, name: t.name })),
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
const reason = args.reason?.trim() || undefined;
|
|
470
|
+
if (reason === undefined && type.reasonRequired) {
|
|
471
|
+
throw new ToolError("VALIDATION", `A reason is required for ${type.name}.`, { reasons: type.reasons });
|
|
472
|
+
}
|
|
473
|
+
if (reason !== undefined && type.reasons.length > 0 && !type.reasons.includes(reason)) {
|
|
474
|
+
throw new ToolError("VALIDATION", `"${reason}" is not a reason ITFin accepts for ${type.name}.`, { reasons: type.reasons });
|
|
475
|
+
}
|
|
476
|
+
if (partDay) {
|
|
477
|
+
// The web form offers the part-day switch only when the leave type's policy allows it.
|
|
478
|
+
const { stats } = await itfin.request("POST", "/v3/timeoff/stats", {
|
|
479
|
+
body: { employeeId: await employeeId(), timeoffTypeIds: [type.apiId], date: todayIn(clock.now(), config.timezone) },
|
|
480
|
+
});
|
|
481
|
+
if (!stats?.find((s) => sameLeaveTypeId(s.timeoffTypeId, type.apiId))?.timeForRequest?.isAllowedToRequestPartDay) {
|
|
482
|
+
throw new ToolError("VALIDATION", `ITFin does not allow requesting ${type.name} for part of a day.`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
const request = { leaveTypeId: type.apiId, from: args.from, to: args.to, ...(partDay && { hours: args.hours }), reason, comment: args.comment };
|
|
486
|
+
const info = await itfin.request("POST", "/v3/timeoff/request-info", {
|
|
487
|
+
body: { employeeId: await employeeId(), timeoffId: type.apiId, dateFrom: args.from, dateTo: args.to, ...partDayFields(request) },
|
|
488
|
+
});
|
|
489
|
+
// Balance stays in days; a part-day request is counted in hours.
|
|
490
|
+
const days = { requestedDays: info.requestedDays, requestedHours: partDay ? info.requestedHours : undefined, availableDays: info.availableDays, minDays: info.minDays };
|
|
491
|
+
if (!info.isAvailableToRequest) {
|
|
492
|
+
throw new ToolError("VALIDATION", `ITFin does not allow requesting ${type.name} for ${args.from} – ${args.to} (e.g. not enough balance or the dates are not allowed).`, days);
|
|
493
|
+
}
|
|
494
|
+
if (info.isAttachFileToRequest) {
|
|
495
|
+
throw new ToolError("VALIDATION", `ITFin requires documents attached to a ${type.name} request. Ask the user to file it in the ITFin web app.`, days);
|
|
496
|
+
}
|
|
497
|
+
return { ...(await fileLeaveRequest(request, type.requestType)), ...days };
|
|
498
|
+
}));
|
|
499
|
+
server.registerTool("itfin_login", {
|
|
500
|
+
title: "Log in to ITFin",
|
|
501
|
+
description: "Opens a browser window on the ITFin workspace so the user can sign in with Google, then stores the new ITFin token (valid 7 days) and schedules a relogin reminder. Call it only when the user asks to log in. Waits up to 3 minutes; if the user is slower, it keeps waiting in the background and shows a notification on success. " +
|
|
502
|
+
"This sign-in window is the only browser the agent may open on its own; don't use a browser for any other ITFin work unless the user asks for it.",
|
|
503
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
504
|
+
}, () => handle(async () => {
|
|
505
|
+
if (!loginInProgress) {
|
|
506
|
+
const session = await startLogin(LOGIN_MAX_WAIT_MS);
|
|
507
|
+
loginInProgress = session.token.then(completeLogin).finally(() => {
|
|
508
|
+
loginInProgress = undefined;
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
const pending = loginInProgress;
|
|
512
|
+
const timeout = new Promise((r) => setTimeout(() => r("waiting"), LOGIN_BLOCK_MS).unref());
|
|
513
|
+
const result = await Promise.race([pending, timeout]);
|
|
514
|
+
if (result === "waiting") {
|
|
515
|
+
pending.then(() => reminders.notify("ITFin", "Logged in to ITFin."), (err) => reminders.notify("ITFin", `Login failed: ${err.message}`));
|
|
516
|
+
return { status: "waiting", message: "The login window is still open. The user will get a notification once they have signed in." };
|
|
517
|
+
}
|
|
518
|
+
return { status: "logged_in", ...result };
|
|
519
|
+
}));
|
|
520
|
+
server.registerTool("itfin_auth_status", {
|
|
521
|
+
title: "ITFin token status",
|
|
522
|
+
description: "Whether a valid ITFin token is stored, when it expires, and when the relogin reminder is due. Use it to warn the user before the token expires.",
|
|
523
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
524
|
+
}, () => handle(async () => {
|
|
525
|
+
const state = await tokenState();
|
|
526
|
+
const base = { email: state.email, expiresAt: state.expiresAt?.toISOString() };
|
|
527
|
+
if (!state.valid)
|
|
528
|
+
return { valid: false, reason: state.reason, ...base };
|
|
529
|
+
const reminder = await nextReminder(state.expiresAt, clock.now());
|
|
530
|
+
return { valid: true, ...base, nextReminderAt: reminder?.toISOString() };
|
|
531
|
+
}));
|
|
532
|
+
/** Computes the relogin reminder, taking holidays and weekends from ITFin when it can. */
|
|
533
|
+
async function nextReminder(expiresAt, now) {
|
|
534
|
+
const days = new Map();
|
|
535
|
+
try {
|
|
536
|
+
for (const d of await getDays(todayIn(now, config.timezone), todayIn(expiresAt, config.timezone)))
|
|
537
|
+
days.set(d.Date, { isHoliday: d.isHoliday, isWeekend: d.isWeekend });
|
|
538
|
+
}
|
|
539
|
+
catch {
|
|
540
|
+
// Fall back to Monday–Friday.
|
|
541
|
+
}
|
|
542
|
+
return reloginReminderAt({ deadline: usableUntil(expiresAt), now, config, days });
|
|
543
|
+
}
|
|
544
|
+
return server;
|
|
545
|
+
}
|
|
546
|
+
async function handle(fn) {
|
|
547
|
+
try {
|
|
548
|
+
const data = await fn();
|
|
549
|
+
return { content: [{ type: "text", text: JSON.stringify(data ?? { ok: true }) }] };
|
|
550
|
+
}
|
|
551
|
+
catch (err) {
|
|
552
|
+
const error = err instanceof ToolError
|
|
553
|
+
? { code: err.code, message: err.message, ...err.details }
|
|
554
|
+
: { code: "INTERNAL", message: err.message };
|
|
555
|
+
return { isError: true, content: [{ type: "text", text: JSON.stringify({ error }) }] };
|
|
556
|
+
}
|
|
557
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const run = promisify(execFile);
|
|
4
|
+
/** Keeps the ITFin token in the macOS Keychain as a generic password: service + workspace address. */
|
|
5
|
+
export class KeychainTokenStore {
|
|
6
|
+
service;
|
|
7
|
+
account;
|
|
8
|
+
constructor(service, account) {
|
|
9
|
+
this.service = service;
|
|
10
|
+
this.account = account;
|
|
11
|
+
}
|
|
12
|
+
async load() {
|
|
13
|
+
try {
|
|
14
|
+
const { stdout } = await run("security", ["find-generic-password", "-s", this.service, "-a", this.account, "-w"]);
|
|
15
|
+
return JSON.parse(stdout.trim());
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async save(token, extra = {}) {
|
|
22
|
+
const value = JSON.stringify({ token, ...extra });
|
|
23
|
+
// Pass the secret on stdin (security -i), not argv, so it never shows up in the process list.
|
|
24
|
+
const cmd = `add-generic-password -U -s ${quote(this.service)} -a ${quote(this.account)} -w ${quote(value)}\n`;
|
|
25
|
+
await new Promise((resolve, reject) => {
|
|
26
|
+
const child = spawn("security", ["-i"], { stdio: ["pipe", "ignore", "pipe"] });
|
|
27
|
+
let stderr = "";
|
|
28
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
29
|
+
child.on("error", reject);
|
|
30
|
+
child.on("close", (code) => (code === 0 && !stderr.trim() ? resolve() : reject(new Error(`Keychain write failed: ${stderr.trim() || code}`))));
|
|
31
|
+
child.stdin.end(cmd);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
async clear() {
|
|
35
|
+
await run("security", ["delete-generic-password", "-s", this.service, "-a", this.account]).catch(() => undefined);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function quote(s) {
|
|
39
|
+
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
40
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { DateTime } from "luxon";
|
|
2
|
+
/**
|
|
3
|
+
* The relogin reminder: the start of working time on the last working day whose working time starts
|
|
4
|
+
* before `deadline` (when the ITFin token stops being usable). If that moment has passed, it is due now.
|
|
5
|
+
*/
|
|
6
|
+
export function reloginReminderAt(opts) {
|
|
7
|
+
const { deadline: expiresAt, now, config, days } = opts;
|
|
8
|
+
if (now >= expiresAt)
|
|
9
|
+
return undefined;
|
|
10
|
+
const [hour = 10, minute = 0] = config.workStart.split(":").map(Number);
|
|
11
|
+
const today = DateTime.fromJSDate(now, { zone: config.timezone }).startOf("day");
|
|
12
|
+
for (let day = DateTime.fromJSDate(expiresAt, { zone: config.timezone }).startOf("day"); day >= today; day = day.minus({ days: 1 })) {
|
|
13
|
+
const start = day.set({ hour, minute });
|
|
14
|
+
if (start.toJSDate() >= expiresAt || !isWorkingDay(day, days))
|
|
15
|
+
continue;
|
|
16
|
+
return start.toJSDate() > now ? start.toJSDate() : now;
|
|
17
|
+
}
|
|
18
|
+
return now;
|
|
19
|
+
}
|
|
20
|
+
export function isWorkingDay(day, days) {
|
|
21
|
+
const flags = days.get(day.toISODate());
|
|
22
|
+
if (!flags)
|
|
23
|
+
return day.weekday < 6;
|
|
24
|
+
return !flags.isHoliday && !flags.isWeekend;
|
|
25
|
+
}
|
|
26
|
+
export function todayIn(now, timezone) {
|
|
27
|
+
return DateTime.fromJSDate(now, { zone: timezone }).toISODate();
|
|
28
|
+
}
|