zep-mcp 1.0.2

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/index.js ADDED
@@ -0,0 +1,1611 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { z } from "zod";
6
+ import puppeteer from "puppeteer-core";
7
+ import path from "path";
8
+ import os from "os";
9
+ import fs from "fs";
10
+ import https from "https";
11
+ import { execSync, spawn } from "child_process";
12
+ import { fileURLToPath } from "url";
13
+
14
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
+ const GITHUB_REPO = "rui-branco/zep-mcp";
16
+ const INSTALLED_SHA_FILE = path.join(__dirname, ".installed-sha");
17
+
18
+ function autoUpdate() {
19
+ try {
20
+ const localSha = fs.existsSync(INSTALLED_SHA_FILE)
21
+ ? fs.readFileSync(INSTALLED_SHA_FILE, "utf-8").trim()
22
+ : "";
23
+ const remoteSha = execSync(
24
+ `git ls-remote https://github.com/${GITHUB_REPO}.git HEAD`,
25
+ { stdio: "pipe", timeout: 5000 },
26
+ )
27
+ .toString()
28
+ .split("\t")[0]
29
+ .trim();
30
+ if (remoteSha && remoteSha !== localSha) {
31
+ const child = spawn(
32
+ "sh",
33
+ [
34
+ "-c",
35
+ `npm install -g git+ssh://git@github.com/${GITHUB_REPO}.git && echo "${remoteSha}" > "${INSTALLED_SHA_FILE}"`,
36
+ ],
37
+ { stdio: "ignore", detached: true },
38
+ );
39
+ child.unref();
40
+ }
41
+ } catch {
42
+ // Offline or error — skip update
43
+ }
44
+ }
45
+
46
+ autoUpdate();
47
+
48
+ const ZEP_HOST = "www.zep-online.de";
49
+ const ZEP_PATH = "/zepitobjects/view/index.php";
50
+ const CHROME_PATH =
51
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
52
+ const PROFILE_DIR = path.join(os.homedir(), ".config/zep-mcp/chrome-profile");
53
+ const COOKIE_FILE = path.join(os.homedir(), ".config/zep-mcp/cookies.json");
54
+ const CDP_PORT = 9333; // Separate port to avoid conflicts
55
+
56
+ const PROJECTS = {
57
+ "42ito.internal": "171",
58
+ "ito.techTeams.AI": "231",
59
+ "ito.techTeams.Angular": "234",
60
+ "kone.elevatorCall": "246",
61
+ "kone.mss": "188",
62
+ };
63
+
64
+ const PROJECT_TASKS = {
65
+ "kone.elevatorCall": {
66
+ 2025: "3331",
67
+ Development: "3332",
68
+ Backend: "3333",
69
+ Frontend: "3334",
70
+ Infrastructure: "3335",
71
+ "IriusRisk assessment and remediation": "4440",
72
+ Meeting: "3336",
73
+ Projectmanagement: "3337",
74
+ "UI-UX": "3338",
75
+ Support: "3353",
76
+ },
77
+ "kone.mss": {
78
+ 2025: "3832",
79
+ MSS: "3845",
80
+ "MSS - Backend": "3846",
81
+ "MSS - Frontend": "3847",
82
+ "MSS - Infrastructure": "3848",
83
+ "MSS - Meeting": "3849",
84
+ "MSS - Projectmanagement": "3850",
85
+ "MSS - Support": "3851",
86
+ "MSS - UI-UX": "3852",
87
+ Modap: "3837",
88
+ "Modap - Backend": "3838",
89
+ "Modap - Frontend": "3839",
90
+ "Modap - Infrastructure": "3840",
91
+ "Modap - Meeting": "3841",
92
+ "Modap - Projectmanagement": "3842",
93
+ "Modap - Support": "3843",
94
+ "Modap - UI-UX": "3844",
95
+ Support: "3853",
96
+ Incident: "3854",
97
+ Maintenance: "3855",
98
+ Meeting: "3856",
99
+ },
100
+ "ito.techTeams.Angular": {
101
+ 2025: "3570",
102
+ "Angular Coordination": "4266",
103
+ "Angular Research": "4265",
104
+ "Development (Work Sessions)": "4263",
105
+ Onboarding: "3571",
106
+ Weekly: "3572",
107
+ },
108
+ "42ito.internal": {
109
+ "House Keeping": "4736",
110
+ Meeting: "4737",
111
+ "Other (Fallback for unknown tasks)": "4738",
112
+ Training: "4739",
113
+ },
114
+ "ito.techTeams.AI": {
115
+ 2025: "3560",
116
+ "AI Team": "3561",
117
+ "AI General research": "3562",
118
+ "AI Meetings": "3563",
119
+ "AI Onboarding": "4531",
120
+ "AI Pitch deck and Workshop preparation": "3564",
121
+ "AI Prototyping": "4264",
122
+ "AI Research / Forschungsanträge": "3565",
123
+ "AI Strategy Development": "3566",
124
+ "Agentic AI Rollout": "4625",
125
+ "ITO Chatbot - Research / Development": "3567",
126
+ MLOps: "3569",
127
+ },
128
+ };
129
+
130
+ const LOCATIONS = {
131
+ auto: "NULL",
132
+ "home-office": "-HO-",
133
+ HO: "-HO-",
134
+ AT: "AT",
135
+ BE: "BE",
136
+ CZ: "CZ",
137
+ D: "D",
138
+ DK: "DK",
139
+ FI: "FI",
140
+ FR: "FR",
141
+ GB: "GB",
142
+ NL: "NL",
143
+ PT: "PT",
144
+ RU: "RU",
145
+ USA: "USA",
146
+ };
147
+
148
+ // ---- Browser + Cookie Management ----
149
+
150
+ let _browser = null;
151
+ let _headless = true;
152
+
153
+ async function ensureBrowser(headless = true) {
154
+ if (_browser && _browser.connected) return _browser;
155
+
156
+ // Try connecting to existing debug instance
157
+ try {
158
+ _browser = await puppeteer.connect({
159
+ browserURL: `http://127.0.0.1:${CDP_PORT}`,
160
+ defaultViewport: null,
161
+ });
162
+ return _browser;
163
+ } catch {}
164
+
165
+ // Launch Chrome (headless by default, visible only for login)
166
+ fs.mkdirSync(PROFILE_DIR, { recursive: true });
167
+ _headless = headless;
168
+ _browser = await puppeteer.launch({
169
+ executablePath: CHROME_PATH,
170
+ headless: headless ? "new" : false,
171
+ userDataDir: PROFILE_DIR,
172
+ args: [
173
+ `--remote-debugging-port=${CDP_PORT}`,
174
+ "--no-first-run",
175
+ "--no-default-browser-check",
176
+ "--window-size=1200,800",
177
+ ],
178
+ });
179
+
180
+ // Reuse cookies if we have them
181
+ const cookies = loadCookies();
182
+ if (cookies.length > 0) {
183
+ const page = (await _browser.pages())[0] || (await _browser.newPage());
184
+ await page.setCookie(...cookies);
185
+ }
186
+
187
+ return _browser;
188
+ }
189
+
190
+ async function closeBrowser() {
191
+ if (_browser && _browser.connected) {
192
+ await _browser.close().catch(() => {});
193
+ _browser = null;
194
+ }
195
+ }
196
+
197
+ function loadCookies() {
198
+ try {
199
+ if (fs.existsSync(COOKIE_FILE)) {
200
+ return JSON.parse(fs.readFileSync(COOKIE_FILE, "utf8"));
201
+ }
202
+ } catch {}
203
+ return [];
204
+ }
205
+
206
+ function saveCookies(cookies) {
207
+ fs.writeFileSync(COOKIE_FILE, JSON.stringify(cookies, null, 2));
208
+ }
209
+
210
+ async function getZepPage() {
211
+ const browser = await ensureBrowser(true);
212
+ const pages = await browser.pages();
213
+
214
+ // Find or create a page
215
+ let page = pages[0] || (await browser.newPage());
216
+
217
+ // Always navigate to the main form page
218
+ const formUrl = `https://${ZEP_HOST}${ZEP_PATH}`;
219
+ const currentUrl = page.url();
220
+ if (
221
+ !currentUrl.includes("zep-online.de/zepitobjects/view/index.php") ||
222
+ currentUrl.includes("ajax") ||
223
+ currentUrl.includes("login")
224
+ ) {
225
+ await page.goto(formUrl, {
226
+ waitUntil: "networkidle2",
227
+ timeout: 60000,
228
+ });
229
+ }
230
+
231
+ // Check if we need login
232
+ const url = page.url();
233
+ if (
234
+ url.includes("login") ||
235
+ url.includes("saml") ||
236
+ url.includes("microsoftonline")
237
+ ) {
238
+ // Close headless browser and ask user to run zep_login
239
+ await closeBrowser();
240
+ throw new Error(
241
+ "SSO login required. Run the zep_login tool first to authenticate, then retry.",
242
+ );
243
+ }
244
+
245
+ // Wait for form and entries table to be available
246
+ await page.waitForSelector("#ProjektzeitFormMgr", { timeout: 10000 });
247
+ await page.waitForSelector("table tr", { timeout: 10000 }).catch(() => {});
248
+
249
+ // Save cookies for next time
250
+ const cookies = await page.cookies();
251
+ saveCookies(cookies);
252
+
253
+ return page;
254
+ }
255
+
256
+ // ---- Helper: delete entry by ID ----
257
+
258
+ async function deleteEntryById(page, entryId) {
259
+ return await page.evaluate((entryId) => {
260
+ const rows = document.querySelectorAll("table tr");
261
+ for (const row of rows) {
262
+ const cells = row.querySelectorAll("td");
263
+ if (cells.length !== 10) continue;
264
+ const delLink = cells[0]?.querySelectorAll("a")[1];
265
+ if (!delLink) continue;
266
+ const onclick = delLink.getAttribute("onclick") || "";
267
+ if (onclick.includes("objectId=" + entryId)) {
268
+ const urlMatch = onclick.match(
269
+ /zepAjaxrequest_sendConfirmedDeleteGetRequest\s*\(\s*'[^']*'\s*,\s*'([^']*)'/,
270
+ );
271
+ if (urlMatch) {
272
+ zepAjaxrequest_sendGetRequest(urlMatch[1]);
273
+ return true;
274
+ }
275
+ }
276
+ }
277
+ return false;
278
+ }, entryId);
279
+ }
280
+
281
+ // ---- Helper: read full entry details from table row by ID ----
282
+
283
+ async function readEntryDetails(page, entryId) {
284
+ return await page.evaluate((entryId) => {
285
+ const rows = document.querySelectorAll("table tr");
286
+ for (const row of rows) {
287
+ const cells = row.querySelectorAll("td");
288
+ if (cells.length !== 10) continue;
289
+ const editLink = cells[0]?.querySelector("a");
290
+ if (!editLink) continue;
291
+ const onclick = editLink.getAttribute("onclick") || "";
292
+ if (onclick.includes("objectId=" + entryId)) {
293
+ return {
294
+ from: cells[1]?.textContent?.trim(),
295
+ to: cells[2]?.textContent?.trim(),
296
+ project: cells[5]?.textContent?.trim(),
297
+ task: cells[6]?.textContent?.trim(),
298
+ activity: cells[7]?.textContent?.trim(),
299
+ remark: cells[9]?.textContent?.trim(),
300
+ };
301
+ }
302
+ }
303
+ return null;
304
+ }, entryId);
305
+ }
306
+
307
+ // ---- Form submission ----
308
+
309
+ async function submitEntry(page, entry) {
310
+ // Always reload the page to get fresh data (avoids stale conflict checks)
311
+ await page.goto(`https://${ZEP_HOST}${ZEP_PATH}`, {
312
+ waitUntil: "networkidle2",
313
+ timeout: 30000,
314
+ });
315
+ await page.waitForSelector("form", { timeout: 10000 });
316
+
317
+ // Step 1: Check for time conflicts BEFORE touching the form (clean page state)
318
+ if (
319
+ !entry.onConflict ||
320
+ entry.onConflict === "ask" ||
321
+ entry.onConflict === "overwrite"
322
+ ) {
323
+ const [, mm, dd] = entry.date.split("-");
324
+ const targetDayLabel = `${dd}/${mm}`;
325
+
326
+ const conflicts = await page.evaluate(
327
+ (e) => {
328
+ const rows = document.querySelectorAll("table tr");
329
+ const found = [];
330
+ let inTargetDay = false;
331
+
332
+ for (const row of rows) {
333
+ const cells = row.querySelectorAll("td");
334
+ const rowspanCell = row.querySelector("td[rowspan]");
335
+ if (rowspanCell) {
336
+ inTargetDay = rowspanCell.textContent
337
+ .trim()
338
+ .includes(e.targetDayLabel);
339
+ }
340
+ if (row.textContent.includes("Total")) {
341
+ if (inTargetDay) break;
342
+ continue;
343
+ }
344
+ if (!inTargetDay) continue;
345
+
346
+ for (let i = 0; i < cells.length - 1; i++) {
347
+ const fromText = cells[i]?.textContent?.trim();
348
+ const toText = cells[i + 1]?.textContent?.trim();
349
+ if (
350
+ fromText &&
351
+ toText &&
352
+ /^\d{2}:\d{2}$/.test(fromText) &&
353
+ /^\d{2}:\d{2}$/.test(toText)
354
+ ) {
355
+ const [efh, efm] = e.from.split(":").map(Number);
356
+ const [eth, etm] = e.to.split(":").map(Number);
357
+ const [rfh, rfm] = fromText.split(":").map(Number);
358
+ const [rth, rtm] = toText.split(":").map(Number);
359
+ const eStart = efh * 60 + efm,
360
+ eEnd = eth * 60 + etm;
361
+ const rStart = rfh * 60 + rfm,
362
+ rEnd = rth * 60 + rtm;
363
+ if (eStart < rEnd && eEnd > rStart) {
364
+ let entryId = null;
365
+ const editLink = cells[0]?.querySelector("a");
366
+ if (editLink) {
367
+ const onclick = editLink.getAttribute("onclick") || "";
368
+ const idMatch = onclick.match(/objectId=(\d+)/);
369
+ if (idMatch) entryId = idMatch[1];
370
+ }
371
+ let project = "",
372
+ task = "";
373
+ for (let j = i + 2; j < cells.length; j++) {
374
+ const txt = cells[j]?.textContent?.trim();
375
+ if (
376
+ txt &&
377
+ (txt.includes("kone") ||
378
+ txt.includes("ito") ||
379
+ txt.includes("42ito"))
380
+ )
381
+ project = txt;
382
+ if (
383
+ txt &&
384
+ (txt.includes("Frontend") ||
385
+ txt.includes("Meeting") ||
386
+ txt.includes("Weekly") ||
387
+ txt.includes("Backend") ||
388
+ txt.includes("MSS"))
389
+ )
390
+ task = txt;
391
+ }
392
+ found.push({
393
+ from: fromText,
394
+ to: toText,
395
+ project,
396
+ task,
397
+ entryId,
398
+ });
399
+ }
400
+ break;
401
+ }
402
+ }
403
+ }
404
+ return found;
405
+ },
406
+ { ...entry, targetDayLabel },
407
+ );
408
+
409
+ if (conflicts.length > 0) {
410
+ if (entry.onConflict === "ask" || !entry.onConflict) {
411
+ const conflictInfo = conflicts
412
+ .map((c) => `${c.from}-${c.to} ${c.project} ${c.task}`)
413
+ .join(", ");
414
+ return {
415
+ success: false,
416
+ conflict: true,
417
+ error: `CONFLICT: ${entry.from}-${entry.to} overlaps with ${conflictInfo}. Ask user to choose: on_conflict="overwrite" (trim existing) or on_conflict="double_booking" (keep both).`,
418
+ };
419
+ }
420
+
421
+ if (entry.onConflict === "overwrite") {
422
+ // Smart overwrite: delete conflicting entries and re-create trimmed versions
423
+ const [efh, efm] = entry.from.split(":").map(Number);
424
+ const [eth, etm] = entry.to.split(":").map(Number);
425
+ const newStart = efh * 60 + efm;
426
+ const newEnd = eth * 60 + etm;
427
+ const fmtTime = (mins) =>
428
+ `${String(Math.floor(mins / 60)).padStart(2, "0")}:${String(mins % 60).padStart(2, "0")}`;
429
+
430
+ // Collect entries to re-create after deletion
431
+ const toRecreate = [];
432
+
433
+ for (const conflict of conflicts) {
434
+ if (!conflict.entryId) continue;
435
+ const [cfh, cfm] = conflict.from.split(":").map(Number);
436
+ const [cth, ctm] = conflict.to.split(":").map(Number);
437
+ const cStart = cfh * 60 + cfm;
438
+ const cEnd = cth * 60 + ctm;
439
+
440
+ // Read full details before deleting
441
+ const details = await readEntryDetails(page, conflict.entryId);
442
+
443
+ if (newStart <= cStart && newEnd >= cEnd) {
444
+ // New entry completely covers existing → just delete it
445
+ await deleteEntryById(page, conflict.entryId);
446
+ await new Promise((r) => setTimeout(r, 1500));
447
+ } else {
448
+ // Partial overlap → delete and re-create trimmed portion(s)
449
+ await deleteEntryById(page, conflict.entryId);
450
+ await new Promise((r) => setTimeout(r, 1500));
451
+
452
+ if (details) {
453
+ if (cStart < newStart) {
454
+ // Keep before-portion: conflict.from → newStart
455
+ toRecreate.push({
456
+ ...details,
457
+ from: conflict.from,
458
+ to: fmtTime(newStart),
459
+ });
460
+ }
461
+ if (cEnd > newEnd) {
462
+ // Keep after-portion: newEnd → conflict.to
463
+ toRecreate.push({
464
+ ...details,
465
+ from: fmtTime(newEnd),
466
+ to: conflict.to,
467
+ });
468
+ }
469
+ }
470
+ }
471
+ }
472
+
473
+ // Reload page, then re-create trimmed entries
474
+ for (const rec of toRecreate) {
475
+ await page.goto(`https://${ZEP_HOST}${ZEP_PATH}`, {
476
+ waitUntil: "networkidle2",
477
+ timeout: 30000,
478
+ });
479
+ await page.waitForSelector("form", { timeout: 10000 });
480
+
481
+ // Resolve project name to ID
482
+ let projectId = null;
483
+ for (const [name, id] of Object.entries(PROJECTS)) {
484
+ if (rec.project && rec.project.includes(name.split(".").pop())) {
485
+ projectId = id;
486
+ break;
487
+ }
488
+ }
489
+ // Resolve task name to ID
490
+ let taskId = null;
491
+ if (projectId) {
492
+ const projName = Object.entries(PROJECTS).find(
493
+ ([, id]) => id === projectId,
494
+ )?.[0];
495
+ if (projName) taskId = resolveTaskId(projName, rec.task);
496
+ }
497
+
498
+ if (!projectId || !taskId) continue; // skip if can't resolve
499
+
500
+ // Map activity text back to code
501
+ const actMap = { ar: "ar", fz: "fz", re: "re" };
502
+ const activity = actMap[rec.activity] || "ar";
503
+
504
+ // Map location text back to code
505
+ let location = "NULL";
506
+ for (const [, code] of Object.entries(LOCATIONS)) {
507
+ if (rec.location && rec.location.includes(code.replace(/-/g, ""))) {
508
+ location = code;
509
+ break;
510
+ }
511
+ }
512
+
513
+ const duration = calculateDuration(rec.from, rec.to);
514
+
515
+ // Set project
516
+ await page.evaluate((pid) => {
517
+ const form = document.querySelector("#ProjektzeitFormMgr");
518
+ form.querySelector('select[name="projektId"]').value = pid;
519
+ zepFormElement(
520
+ form.querySelector('select[name="projektId"]'),
521
+ ).refreshForm();
522
+ }, projectId);
523
+
524
+ await page.waitForFunction(
525
+ (tid) => {
526
+ const sel = document.querySelector(
527
+ "#ProjektzeitFormMgr select[name='vorgangId']",
528
+ );
529
+ return (
530
+ sel && Array.from(sel.options).some((opt) => opt.value === tid)
531
+ );
532
+ },
533
+ { timeout: 10000 },
534
+ taskId,
535
+ );
536
+
537
+ // Set fields
538
+ await page.evaluate(
539
+ (r) => {
540
+ const form = document.querySelector("#ProjektzeitFormMgr");
541
+ form.querySelector('input[name="datum"]').value = r.date;
542
+ form.querySelector('input[name="von"]').value = r.from;
543
+ form.querySelector('input[name="bis"]').value = r.to;
544
+ form.querySelector('input[name="dauer"]').value = r.duration;
545
+ form.querySelector('input[name="bemerkung"]').value =
546
+ r.remark || "";
547
+ const taskSel = form.querySelector('select[name="vorgangId"]');
548
+ if (taskSel) taskSel.value = r.taskId;
549
+ const actSel = form.querySelector('select[name="taetigkeit"]');
550
+ if (actSel) actSel.value = r.activity;
551
+ const ortSel = form.querySelector('select[name="ort"]');
552
+ if (ortSel) ortSel.value = r.location;
553
+ // Suppress double-booking popup
554
+ const dbField = form.querySelector(
555
+ 'input[name="doppelbuchungTrotzdemFragenObUeberschreiben"]',
556
+ );
557
+ if (dbField) dbField.value = "0";
558
+ const ubtField = form.querySelector(
559
+ 'input[name="ueberschreibenTrotzDoppelbuchung"]',
560
+ );
561
+ if (ubtField) ubtField.value = "1";
562
+ },
563
+ { ...rec, date: entry.date, duration, taskId, activity, location },
564
+ );
565
+
566
+ // Submit
567
+ await page.evaluate(() => {
568
+ const pm = $("form#ProjektzeitFormMgr").data("zepForm");
569
+ if (pm) pm.submitAjax();
570
+ });
571
+ await new Promise((r) => setTimeout(r, 3000));
572
+ }
573
+
574
+ // Final reload for the main entry submission
575
+ await page.goto(`https://${ZEP_HOST}${ZEP_PATH}`, {
576
+ waitUntil: "networkidle2",
577
+ timeout: 30000,
578
+ });
579
+ await page.waitForSelector("form", { timeout: 10000 });
580
+ }
581
+ }
582
+ }
583
+
584
+ // Step 2: Set project and trigger form refresh to load tasks
585
+ await page.evaluate((projectId) => {
586
+ const form = document.querySelector("#ProjektzeitFormMgr");
587
+ const projSel = form.querySelector('select[name="projektId"]');
588
+ projSel.value = projectId;
589
+ zepFormElement(projSel).refreshForm();
590
+ }, entry.projectId);
591
+
592
+ await page.waitForFunction(
593
+ (taskId) => {
594
+ const sel = document.querySelector(
595
+ "#ProjektzeitFormMgr select[name='vorgangId']",
596
+ );
597
+ if (!sel) return false;
598
+ return Array.from(sel.options).some((opt) => opt.value === taskId);
599
+ },
600
+ { timeout: 10000 },
601
+ entry.taskId,
602
+ );
603
+
604
+ // Step 3: Set all form fields
605
+ await page.evaluate((e) => {
606
+ const form = document.querySelector("#ProjektzeitFormMgr");
607
+
608
+ form.querySelector('input[name="datum"]').value = e.date;
609
+ form.querySelector('input[name="von"]').value = e.from;
610
+ form.querySelector('input[name="bis"]').value = e.to;
611
+ form.querySelector('input[name="dauer"]').value = e.duration;
612
+ form.querySelector('input[name="bemerkung"]').value = e.remark;
613
+
614
+ const taskSel = form.querySelector('select[name="vorgangId"]');
615
+ if (taskSel) taskSel.value = e.taskId;
616
+ const actSel = form.querySelector('select[name="taetigkeit"]');
617
+ if (actSel) actSel.value = e.activity;
618
+ const ortSel = form.querySelector('select[name="ort"]');
619
+ if (ortSel) ortSel.value = e.location;
620
+
621
+ const cb = form.querySelector('input[name="fakturierbar"]');
622
+ if (cb && cb.checked !== e.billable) cb.click();
623
+
624
+ // Suppress double-booking popup for non-"ask" modes
625
+ if (e.onConflict === "double_booking" || e.onConflict === "overwrite") {
626
+ const dbField = form.querySelector(
627
+ 'input[name="doppelbuchungTrotzdemFragenObUeberschreiben"]',
628
+ );
629
+ if (dbField) dbField.value = "0";
630
+ const ubtField = form.querySelector(
631
+ 'input[name="ueberschreibenTrotzDoppelbuchung"]',
632
+ );
633
+ if (ubtField) ubtField.value = "1";
634
+ }
635
+ }, entry);
636
+
637
+ // Step 3: Call ZEP's internal submitAjax directly on the private methods object.
638
+ // This bypasses isReady/global checks but uses ZEP's own AJAX with the CSRF token.
639
+ const submitResult = await page.evaluate(() => {
640
+ try {
641
+ const pm = $("form#ProjektzeitFormMgr").data("zepForm");
642
+ if (!pm) return { error: "zepForm private methods not found" };
643
+
644
+ // Call submitAjax directly — this sends the serialized form data
645
+ // via zepAjaxrequest_sendPostRequest which includes the x-requesttoken header
646
+ pm.submitAjax();
647
+
648
+ return { success: true };
649
+ } catch (e) {
650
+ return { error: e.message };
651
+ }
652
+ });
653
+
654
+ if (submitResult.error) {
655
+ return { success: false, error: submitResult.error };
656
+ }
657
+
658
+ // Wait for AJAX to complete
659
+ await new Promise((r) => setTimeout(r, 3000));
660
+
661
+ // Reload the page to get a fresh form for the next entry
662
+ await page.goto(`https://${ZEP_HOST}${ZEP_PATH}`, {
663
+ waitUntil: "networkidle2",
664
+ timeout: 30000,
665
+ });
666
+ await page.waitForSelector("#ProjektzeitFormMgr", { timeout: 10000 });
667
+
668
+ // Save fresh cookies
669
+ const cookies = await page.cookies();
670
+ saveCookies(cookies);
671
+
672
+ // Post-save verification: search all rows for matching from/to times
673
+ const verification = await page.evaluate((e) => {
674
+ const rows = document.querySelectorAll("table tr");
675
+ const allTimes = [];
676
+ for (const row of rows) {
677
+ const cells = row.querySelectorAll("td");
678
+ // Try to find From/To in the cells - check all cells for time patterns
679
+ for (let i = 0; i < cells.length - 1; i++) {
680
+ const text = cells[i]?.textContent?.trim();
681
+ const next = cells[i + 1]?.textContent?.trim();
682
+ if (
683
+ text &&
684
+ next &&
685
+ /^\d{2}:\d{2}$/.test(text) &&
686
+ /^\d{2}:\d{2}$/.test(next)
687
+ ) {
688
+ allTimes.push({ from: text, to: next, cellCount: cells.length });
689
+ if (text === e.from && next === e.to)
690
+ return { found: true, allTimes };
691
+ break; // only check first time pair per row
692
+ }
693
+ }
694
+ }
695
+ return { found: false, allTimes };
696
+ }, entry);
697
+
698
+ if (!verification.found) {
699
+ return {
700
+ success: true,
701
+ verified: false,
702
+ warning: "Entry submitted but not found on current week view",
703
+ debug: { existingTimes: verification.allTimes },
704
+ };
705
+ }
706
+
707
+ return { success: true, error: null, verified: true };
708
+ }
709
+
710
+ function calculateDuration(from, to) {
711
+ const [fh, fm] = from.split(":").map(Number);
712
+ const [th, tm] = to.split(":").map(Number);
713
+ const totalMinutes = th * 60 + tm - (fh * 60 + fm);
714
+ const hours = Math.floor(totalMinutes / 60);
715
+ const minutes = totalMinutes % 60;
716
+ return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
717
+ }
718
+
719
+ // ---- Login helper ----
720
+
721
+ async function doLogin() {
722
+ // Close any existing headless browser
723
+ await closeBrowser();
724
+
725
+ // Launch visible Chrome for SSO login
726
+ const browser = await ensureBrowser(false);
727
+ const pages = await browser.pages();
728
+ const page = pages[0] || (await browser.newPage());
729
+
730
+ await page.goto(`https://${ZEP_HOST}${ZEP_PATH}`, {
731
+ waitUntil: "networkidle2",
732
+ timeout: 60000,
733
+ });
734
+
735
+ // Wait for user to complete SSO login (poll for up to 2 minutes)
736
+ for (let i = 0; i < 24; i++) {
737
+ await new Promise((r) => setTimeout(r, 5000));
738
+ const url = page.url();
739
+ if (
740
+ url.includes("zep-online.de/zepitobjects/view/index.php") &&
741
+ !url.includes("login") &&
742
+ !url.includes("saml") &&
743
+ !url.includes("microsoftonline")
744
+ ) {
745
+ // Login successful - save cookies
746
+ const cookies = await page.cookies();
747
+ saveCookies(cookies);
748
+ // Close visible browser, next call will use headless
749
+ await closeBrowser();
750
+ return true;
751
+ }
752
+ }
753
+
754
+ await closeBrowser();
755
+ return false;
756
+ }
757
+
758
+ // ---- Task name resolver ----
759
+
760
+ function resolveTaskId(project, taskInput) {
761
+ const tasks = PROJECT_TASKS[project];
762
+
763
+ // If numeric, check if it's a known task ID first, then try as a name
764
+ if (/^\d+$/.test(taskInput)) {
765
+ // Check if it's a valid task ID for this project
766
+ if (tasks) {
767
+ const ids = Object.values(tasks);
768
+ if (ids.includes(taskInput)) return taskInput;
769
+ // Maybe it's a task name that happens to be numeric (like "2025")
770
+ if (tasks[taskInput]) return tasks[taskInput];
771
+ }
772
+ return taskInput; // assume it's a valid ID even if not in our map
773
+ }
774
+
775
+ if (!tasks) return null;
776
+
777
+ const lower = taskInput.toLowerCase();
778
+ // Try exact match first
779
+ for (const [name, id] of Object.entries(tasks)) {
780
+ if (name.toLowerCase() === lower) return id;
781
+ }
782
+ // Then partial match
783
+ for (const [name, id] of Object.entries(tasks)) {
784
+ if (
785
+ name.toLowerCase().includes(lower) ||
786
+ lower.includes(name.toLowerCase())
787
+ )
788
+ return id;
789
+ }
790
+ return null;
791
+ }
792
+
793
+ // ---- MCP Server ----
794
+
795
+ const instructions = `# ZEP Time Tracking MCP
796
+
797
+ This MCP provides tools for logging and managing time entries in ZEP (zep-online.de).
798
+
799
+ ## When to use these tools
800
+
801
+ Use these tools whenever the user wants to:
802
+ - Log time (e.g. "log 9-12 on kone frontend", "book my hours", "log today")
803
+ - Read time entries (e.g. "show my hours this week", "what did I log today")
804
+ - Edit or delete entries (e.g. "change my 9-12 to 9-11", "delete that entry")
805
+ - List projects or tasks (e.g. "show zep projects", "what tasks does kone have")
806
+
807
+ ## IMPORTANT
808
+
809
+ - When the user says to log time, use zep_log_time or zep_log_day immediately. Do NOT ask for confirmation unless there's a conflict.
810
+ - Default activity is "ar" (working), default location is "auto".
811
+ - When a conflict is returned, present the user with options: Overwrite, Double booking, or Cancel.
812
+ - If a tool reports "SSO login required", use zep_login first, then retry.
813
+ - Use zep_list_projects to show available projects and tasks.
814
+ `;
815
+
816
+ const server = new McpServer(
817
+ { name: "zep", version: "3.0.0" },
818
+ { instructions },
819
+ );
820
+
821
+ server.tool(
822
+ "zep_login",
823
+ "Open a visible Chrome window for SSO login to ZEP. Run this when other tools report SSO login required.",
824
+ {},
825
+ async () => {
826
+ try {
827
+ const success = await doLogin();
828
+ if (success) {
829
+ return {
830
+ content: [
831
+ {
832
+ type: "text",
833
+ text: "Login successful. Cookies saved. You can now use other ZEP tools.",
834
+ },
835
+ ],
836
+ };
837
+ }
838
+ return {
839
+ content: [
840
+ {
841
+ type: "text",
842
+ text: "Login timed out after 2 minutes. Please try again.",
843
+ },
844
+ ],
845
+ };
846
+ } catch (error) {
847
+ return {
848
+ content: [{ type: "text", text: `Login error: ${error.message}` }],
849
+ };
850
+ }
851
+ },
852
+ );
853
+
854
+ server.tool(
855
+ "zep_log_time",
856
+ "Log a time entry in ZEP. Task can be a name (e.g. 'Weekly', 'MSS - Frontend') or numeric ID. Use zep_get_tasks to list available tasks for a project. IMPORTANT: When a conflict is returned, present the user with an interactive selection prompt (not text) with options: Overwrite, Double booking, Cancel.",
857
+ {
858
+ date: z.string().describe("Date in YYYY-MM-DD format"),
859
+ from: z.string().describe("Start time in HH:MM format (e.g. 09:00)"),
860
+ to: z.string().describe("End time in HH:MM format (e.g. 17:00)"),
861
+ project: z.enum(Object.keys(PROJECTS)).describe("Project name"),
862
+ task: z
863
+ .string()
864
+ .describe("Task name (e.g. 'Weekly', 'MSS - Frontend') or numeric ID"),
865
+ activity: z
866
+ .enum(["ar", "fz", "re"])
867
+ .default("ar")
868
+ .describe("Activity type: ar(working), fz(freetime), re(traveling)"),
869
+ location: z
870
+ .enum(Object.keys(LOCATIONS))
871
+ .default("auto")
872
+ .describe("Work location (auto=let ZEP decide, HO=home-office)"),
873
+ remark: z.string().default("").describe("Optional remark/comment"),
874
+ billable: z.boolean().default(true).describe("Is the entry billable?"),
875
+ on_conflict: z
876
+ .enum(["ask", "double_booking", "overwrite"])
877
+ .default("ask")
878
+ .describe(
879
+ "What to do if time overlaps: ask=report conflict, double_booking=save alongside, overwrite=replace existing",
880
+ ),
881
+ },
882
+ async (params) => {
883
+ try {
884
+ // Resolve task name to ID
885
+ const taskId = resolveTaskId(params.project, params.task);
886
+ if (!taskId) {
887
+ const tasks = PROJECT_TASKS[params.project];
888
+ const available = tasks
889
+ ? Object.entries(tasks)
890
+ .map(([n, id]) => `${id}: ${n}`)
891
+ .join(", ")
892
+ : "none";
893
+ return {
894
+ content: [
895
+ {
896
+ type: "text",
897
+ text: `Unknown task "${params.task}" for ${params.project}. Available: ${available}`,
898
+ },
899
+ ],
900
+ };
901
+ }
902
+
903
+ const page = await getZepPage();
904
+ const duration = calculateDuration(params.from, params.to);
905
+
906
+ const result = await submitEntry(page, {
907
+ date: params.date,
908
+ from: params.from,
909
+ to: params.to,
910
+ duration,
911
+ projectId: PROJECTS[params.project],
912
+ taskId,
913
+ activity: params.activity,
914
+ location: LOCATIONS[params.location] || "-HO-",
915
+ remark: params.remark,
916
+ billable: params.billable,
917
+ onConflict: params.on_conflict,
918
+ });
919
+
920
+ if (result.conflict) {
921
+ return { content: [{ type: "text", text: result.error }] };
922
+ }
923
+ if (!result.success) {
924
+ return { content: [{ type: "text", text: `FAILED: ${result.error}` }] };
925
+ }
926
+
927
+ let status = `Logged: ${params.date} ${params.from}-${params.to} (${duration}) | ${params.project} | task ${params.task} | ${params.activity} | ${params.remark || "(no remark)"}`;
928
+ if (result.verified === false) {
929
+ status += `\nNOT VERIFIED: ${result.warning}`;
930
+ } else if (result.verified) {
931
+ status += `\nVERIFIED: Entry confirmed on page`;
932
+ }
933
+ return { content: [{ type: "text", text: status }] };
934
+ } catch (error) {
935
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
936
+ }
937
+ },
938
+ );
939
+
940
+ server.tool(
941
+ "zep_log_day",
942
+ "Log multiple time entries for a full day in ZEP",
943
+ {
944
+ date: z.string().describe("Date in YYYY-MM-DD format"),
945
+ entries: z
946
+ .array(
947
+ z.object({
948
+ from: z.string().describe("Start time HH:MM"),
949
+ to: z.string().describe("End time HH:MM"),
950
+ project: z.enum(Object.keys(PROJECTS)),
951
+ task: z
952
+ .string()
953
+ .describe(
954
+ "Task name (e.g. 'Weekly', 'MSS - Frontend') or numeric ID",
955
+ ),
956
+ activity: z.enum(["ar", "fz", "re"]).default("ar"),
957
+ location: z.enum(Object.keys(LOCATIONS)).default("auto"),
958
+ remark: z.string().default(""),
959
+ billable: z.boolean().default(true),
960
+ }),
961
+ )
962
+ .describe("Array of time entries for the day"),
963
+ on_conflict: z
964
+ .enum(["ask", "double_booking", "overwrite"])
965
+ .default("overwrite")
966
+ .describe(
967
+ "What to do if time overlaps with existing entries (default: overwrite/trim existing)",
968
+ ),
969
+ },
970
+ async (params) => {
971
+ try {
972
+ const page = await getZepPage();
973
+ const results = [];
974
+
975
+ for (let idx = 0; idx < params.entries.length; idx++) {
976
+ const entry = params.entries[idx];
977
+ const taskId = resolveTaskId(entry.project, entry.task);
978
+ if (!taskId) {
979
+ results.push({
980
+ time: `${entry.from}-${entry.to}`,
981
+ project: entry.project,
982
+ success: false,
983
+ error: `Unknown task "${entry.task}"`,
984
+ });
985
+ continue;
986
+ }
987
+ const duration = calculateDuration(entry.from, entry.to);
988
+ const result = await submitEntry(page, {
989
+ date: params.date,
990
+ from: entry.from,
991
+ to: entry.to,
992
+ duration,
993
+ projectId: PROJECTS[entry.project],
994
+ taskId,
995
+ activity: entry.activity || "ar",
996
+ location: LOCATIONS[entry.location] || "-HO-",
997
+ remark: entry.remark || "",
998
+ billable: entry.billable !== false,
999
+ onConflict: params.on_conflict,
1000
+ });
1001
+
1002
+ // If any entry has a conflict in "ask" mode, stop the entire batch
1003
+ if (result.conflict) {
1004
+ results.push({
1005
+ time: `${entry.from}-${entry.to}`,
1006
+ project: entry.project,
1007
+ success: false,
1008
+ error: result.error,
1009
+ });
1010
+ // Add remaining entries as skipped
1011
+ for (let j = idx + 1; j < params.entries.length; j++) {
1012
+ const skip = params.entries[j];
1013
+ results.push({
1014
+ time: `${skip.from}-${skip.to}`,
1015
+ project: skip.project,
1016
+ success: false,
1017
+ error: "Skipped due to earlier conflict",
1018
+ });
1019
+ }
1020
+ break;
1021
+ }
1022
+
1023
+ results.push({
1024
+ time: `${entry.from}-${entry.to}`,
1025
+ project: entry.project,
1026
+ success: result.success,
1027
+ verified: result.verified,
1028
+ warning: result.warning,
1029
+ error: result.error,
1030
+ });
1031
+ }
1032
+
1033
+ const summary = results
1034
+ .map((r) => {
1035
+ if (!r.success) return `${r.time} ${r.project}: FAILED - ${r.error}`;
1036
+ if (r.verified === false)
1037
+ return `${r.time} ${r.project}: OK (UNVERIFIED - ${r.warning})`;
1038
+ return `${r.time} ${r.project}: OK (verified)`;
1039
+ })
1040
+ .join("\n");
1041
+
1042
+ return {
1043
+ content: [{ type: "text", text: `Day ${params.date}:\n${summary}` }],
1044
+ };
1045
+ } catch (error) {
1046
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
1047
+ }
1048
+ },
1049
+ );
1050
+
1051
+ server.tool(
1052
+ "zep_read_entries",
1053
+ "Read time entries from ZEP for a given week",
1054
+ {
1055
+ week_start: z
1056
+ .string()
1057
+ .optional()
1058
+ .describe(
1059
+ "Week start date in YYYY-MM-DD format (Monday). Defaults to current week.",
1060
+ ),
1061
+ },
1062
+ async (params) => {
1063
+ try {
1064
+ const page = await getZepPage();
1065
+
1066
+ if (params.week_start) {
1067
+ // Navigate to the target week by finding the matching dropdown option
1068
+ const navResult = await page.evaluate((targetDate) => {
1069
+ const sel = document.querySelector("#pz_kwnav");
1070
+ if (!sel) return { error: "no_dropdown" };
1071
+
1072
+ const options = Array.from(sel.options).map(o => ({ value: o.value, text: o.textContent.trim() }));
1073
+
1074
+ // Parse target date
1075
+ const [y, m, d] = targetDate.split("-");
1076
+ const altFormats = [
1077
+ targetDate, // 2026-02-02
1078
+ `${d}.${m}.${y}`, // 02.02.2026
1079
+ `${d}/${m}/${y}`, // 02/02/2026
1080
+ ];
1081
+
1082
+ // Compute ISO week number for KW matching
1083
+ const date = new Date(targetDate + "T12:00:00");
1084
+ const dayOfWeek = date.getDay() || 7;
1085
+ const jan4 = new Date(date.getFullYear(), 0, 4);
1086
+ const jan4DayOfWeek = jan4.getDay() || 7;
1087
+ const weekNum = Math.ceil(((date - jan4) / 86400000 + jan4DayOfWeek + 3) / 7);
1088
+ const kwStr = String(weekNum).padStart(2, "0");
1089
+ const kwYear = date.getFullYear();
1090
+
1091
+ // The dropdown values are dates in YYYY-MM-DD format (e.g. "2026-02-02")
1092
+ // and text is like "5/2026 (02.02-08.02)"
1093
+ // We need to find the option whose date range contains our target date
1094
+ const targetMs = date.getTime();
1095
+ let matchValue = null;
1096
+
1097
+ for (const opt of options) {
1098
+ const v = opt.value;
1099
+ // Strategy 1: exact value match
1100
+ if (v === targetDate) { matchValue = v; break; }
1101
+ // Strategy 2: the option value is a date — check if our target falls within that week
1102
+ if (/^\d{4}-\d{2}-\d{2}$/.test(v)) {
1103
+ const optDate = new Date(v + "T12:00:00");
1104
+ const weekStart = optDate.getTime();
1105
+ const weekEnd = weekStart + 6 * 86400000; // 6 days later (Mon-Sun)
1106
+ if (targetMs >= weekStart && targetMs <= weekEnd) {
1107
+ matchValue = v;
1108
+ break;
1109
+ }
1110
+ }
1111
+ }
1112
+
1113
+ if (matchValue) {
1114
+ sel.value = matchValue;
1115
+ sel.dispatchEvent(new Event("change", { bubbles: true }));
1116
+ return { success: true, matchValue };
1117
+ }
1118
+
1119
+ return { error: "no_match", options: options.slice(0, 15), weekNum, kwStr, kwYear };
1120
+ }, params.week_start);
1121
+
1122
+ if (navResult.error === "no_dropdown" || navResult.error === "no_match") {
1123
+ return {
1124
+ content: [{
1125
+ type: "text",
1126
+ text: `Could not find week for ${params.week_start} (KW${navResult.kwStr}/${navResult.kwYear}) in dropdown. Available options: ${JSON.stringify(navResult.options)}`
1127
+ }]
1128
+ };
1129
+ }
1130
+
1131
+ if (navResult.success) {
1132
+ // Wait for page to reload after week change
1133
+ await page.waitForNavigation({ waitUntil: "networkidle2", timeout: 10000 }).catch(() => {});
1134
+ await page.waitForSelector("table tr", { timeout: 5000 }).catch(() => {});
1135
+ // Extra wait for dynamic content
1136
+ await new Promise(r => setTimeout(r, 1000));
1137
+
1138
+ }
1139
+ }
1140
+
1141
+ const entries = await page.evaluate(() => {
1142
+ const allRows = document.querySelectorAll("table tr");
1143
+ const results = [];
1144
+ let currentDay = "";
1145
+
1146
+ for (const row of allRows) {
1147
+ // Day header rows have class "day" or "tag_N" and 2 cells
1148
+ if (
1149
+ row.classList.contains("day") ||
1150
+ (row.className.match(/tag_\d/) &&
1151
+ row.querySelectorAll("td").length === 2)
1152
+ ) {
1153
+ const text = row.textContent.trim();
1154
+ const match = text.match(/(\w{2})(\d{2}\/\d{2})/);
1155
+ if (match) currentDay = match[1] + " " + match[2];
1156
+ continue;
1157
+ }
1158
+
1159
+ // Entry rows have 10 or 11 cells (11 when Location column is present)
1160
+ const cells = row.querySelectorAll("td");
1161
+ if (cells.length >= 10 && cells.length <= 11) {
1162
+ // Extract objectId from edit link's onclick
1163
+ const editLink = cells[0]?.querySelector("a");
1164
+ const onclick = editLink?.getAttribute("onclick") || "";
1165
+ const idMatch = onclick.match(/objectId=(\d+)/);
1166
+ if (!idMatch) continue; // Skip rows without edit links (e.g. total rows)
1167
+ const hasLocation = cells.length === 11;
1168
+ results.push({
1169
+ id: idMatch[1],
1170
+ day: currentDay,
1171
+ from: cells[1]?.textContent?.trim() || "",
1172
+ to: cells[2]?.textContent?.trim() || "",
1173
+ duration: cells[3]?.textContent?.trim() || "",
1174
+ project: cells[5]?.textContent?.trim() || "",
1175
+ task: cells[6]?.textContent?.trim() || "",
1176
+ activity: cells[7]?.textContent?.trim() || "",
1177
+ remark: cells[hasLocation ? 10 : 9]?.textContent?.trim() || "",
1178
+ });
1179
+ }
1180
+ }
1181
+ return results;
1182
+ });
1183
+
1184
+ if (entries.length === 0) {
1185
+ return {
1186
+ content: [
1187
+ { type: "text", text: "No time entries found for this week." },
1188
+ ],
1189
+ };
1190
+ }
1191
+
1192
+ return {
1193
+ content: [{ type: "text", text: JSON.stringify(entries, null, 2) }],
1194
+ };
1195
+ } catch (error) {
1196
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
1197
+ }
1198
+ },
1199
+ );
1200
+
1201
+ server.tool(
1202
+ "zep_list_projects",
1203
+ "List available ZEP projects and their task IDs",
1204
+ {},
1205
+ async () => {
1206
+ const output = Object.entries(PROJECT_TASKS)
1207
+ .map(([proj, tasks]) => {
1208
+ const taskList = Object.entries(tasks)
1209
+ .map(([name, id]) => ` ${id}: ${name}`)
1210
+ .join("\n");
1211
+ return `${proj} (ID: ${PROJECTS[proj]}):\n${taskList}`;
1212
+ })
1213
+ .join("\n\n");
1214
+
1215
+ return { content: [{ type: "text", text: output }] };
1216
+ },
1217
+ );
1218
+
1219
+ server.tool(
1220
+ "zep_get_tasks",
1221
+ "Fetch available tasks for a specific project from ZEP (requires loading the page)",
1222
+ {
1223
+ project: z.enum(Object.keys(PROJECTS)).describe("Project name"),
1224
+ },
1225
+ async (params) => {
1226
+ const known = PROJECT_TASKS[params.project];
1227
+ if (known) {
1228
+ const taskList = Object.entries(known)
1229
+ .map(([name, id]) => `${id}: ${name}`)
1230
+ .join("\n");
1231
+ return {
1232
+ content: [
1233
+ { type: "text", text: `Tasks for ${params.project}:\n${taskList}` },
1234
+ ],
1235
+ };
1236
+ }
1237
+ return {
1238
+ content: [
1239
+ {
1240
+ type: "text",
1241
+ text: `No cached tasks for ${params.project}. Check zep_list_projects.`,
1242
+ },
1243
+ ],
1244
+ };
1245
+ },
1246
+ );
1247
+
1248
+ server.tool(
1249
+ "zep_delete_entry",
1250
+ "Delete a time entry from ZEP by its ID (use zep_read_entries to get IDs). Entries can always be re-created, so proceed without asking for confirmation when the user's intent is clear.",
1251
+ {
1252
+ entry_id: z
1253
+ .string()
1254
+ .describe("The entry objectId to delete (from zep_read_entries)"),
1255
+ },
1256
+ async (params) => {
1257
+ try {
1258
+ const page = await getZepPage();
1259
+
1260
+ // Find the delete link for this entry by objectId
1261
+ const found = await page.evaluate((entryId) => {
1262
+ const rows = document.querySelectorAll("table tr");
1263
+ for (const row of rows) {
1264
+ const cells = row.querySelectorAll("td");
1265
+ if (cells.length !== 10) continue;
1266
+ const delLink = cells[0]?.querySelectorAll("a")[1];
1267
+ if (!delLink) continue;
1268
+ const onclick = delLink.getAttribute("onclick") || "";
1269
+ if (onclick.includes("objectId=" + entryId)) {
1270
+ return { rowIndex: Array.from(rows).indexOf(row) };
1271
+ }
1272
+ }
1273
+ return null;
1274
+ }, params.entry_id);
1275
+
1276
+ if (!found) {
1277
+ return {
1278
+ content: [
1279
+ {
1280
+ type: "text",
1281
+ text: `Entry ${params.entry_id} not found on current week.`,
1282
+ },
1283
+ ],
1284
+ };
1285
+ }
1286
+
1287
+ const deleted = await deleteEntryById(page, params.entry_id);
1288
+
1289
+ if (!deleted) {
1290
+ return {
1291
+ content: [
1292
+ {
1293
+ type: "text",
1294
+ text: `Could not extract delete URL for entry ${params.entry_id}.`,
1295
+ },
1296
+ ],
1297
+ };
1298
+ }
1299
+
1300
+ // Wait for AJAX delete to complete
1301
+ await new Promise((r) => setTimeout(r, 3000));
1302
+
1303
+ // Reload page to see updated entries
1304
+ await page.goto(`https://${ZEP_HOST}${ZEP_PATH}`, {
1305
+ waitUntil: "networkidle2",
1306
+ timeout: 30000,
1307
+ });
1308
+ await page.waitForSelector("#ProjektzeitFormMgr", { timeout: 10000 });
1309
+
1310
+ return {
1311
+ content: [{ type: "text", text: `Deleted entry ${params.entry_id}.` }],
1312
+ };
1313
+ } catch (error) {
1314
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
1315
+ }
1316
+ },
1317
+ );
1318
+
1319
+ server.tool(
1320
+ "zep_edit_entry",
1321
+ "Edit an existing time entry in ZEP by deleting and re-creating it with modified fields",
1322
+ {
1323
+ entry_id: z
1324
+ .string()
1325
+ .describe("The entry objectId to edit (from zep_read_entries)"),
1326
+ date: z
1327
+ .string()
1328
+ .optional()
1329
+ .describe("New date in YYYY-MM-DD format (leave empty to keep current)"),
1330
+ from: z
1331
+ .string()
1332
+ .optional()
1333
+ .describe("New start time HH:MM (leave empty to keep current)"),
1334
+ to: z
1335
+ .string()
1336
+ .optional()
1337
+ .describe("New end time HH:MM (leave empty to keep current)"),
1338
+ project: z
1339
+ .enum(Object.keys(PROJECTS))
1340
+ .optional()
1341
+ .describe("New project (leave empty to keep current)"),
1342
+ task: z
1343
+ .string()
1344
+ .optional()
1345
+ .describe("New task name or ID (leave empty to keep current)"),
1346
+ activity: z
1347
+ .enum(["ar", "fz", "re"])
1348
+ .optional()
1349
+ .describe("New activity type"),
1350
+ location: z
1351
+ .enum(Object.keys(LOCATIONS))
1352
+ .optional()
1353
+ .describe("New work location"),
1354
+ remark: z.string().optional().describe("New remark"),
1355
+ billable: z.boolean().optional().describe("New billable status"),
1356
+ },
1357
+ async (params) => {
1358
+ try {
1359
+ const page = await getZepPage();
1360
+
1361
+ // Step 1: Read current entry details from the table
1362
+ const current = await readEntryDetails(page, params.entry_id);
1363
+ if (!current) {
1364
+ return {
1365
+ content: [
1366
+ {
1367
+ type: "text",
1368
+ text: `Entry ${params.entry_id} not found on current week.`,
1369
+ },
1370
+ ],
1371
+ };
1372
+ }
1373
+
1374
+ // Also read the date from the day header for this entry
1375
+ const currentDate = await page.evaluate((entryId) => {
1376
+ const rows = document.querySelectorAll("table tr");
1377
+ let currentDay = "";
1378
+ for (const row of rows) {
1379
+ const rowspanCell = row.querySelector("td[rowspan]");
1380
+ if (rowspanCell) {
1381
+ const match = rowspanCell.textContent.match(/(\d{2})\/(\d{2})/);
1382
+ if (match) currentDay = match[0]; // DD/MM
1383
+ }
1384
+ const cells = row.querySelectorAll("td");
1385
+ if (cells.length !== 10) continue;
1386
+ const editLink = cells[0]?.querySelector("a");
1387
+ if (!editLink) continue;
1388
+ const onclick = editLink.getAttribute("onclick") || "";
1389
+ if (onclick.includes("objectId=" + entryId)) {
1390
+ return currentDay; // DD/MM
1391
+ }
1392
+ }
1393
+ return null;
1394
+ }, params.entry_id);
1395
+
1396
+ // Step 2: Delete the existing entry
1397
+ const deleted = await deleteEntryById(page, params.entry_id);
1398
+ if (!deleted) {
1399
+ return {
1400
+ content: [
1401
+ {
1402
+ type: "text",
1403
+ text: `Could not delete entry ${params.entry_id}.`,
1404
+ },
1405
+ ],
1406
+ };
1407
+ }
1408
+ await new Promise((r) => setTimeout(r, 2000));
1409
+
1410
+ // Step 3: Resolve project and task for the re-creation
1411
+ // Use new values if provided, otherwise resolve from current entry text
1412
+ let projectName = params.project;
1413
+ let projectId = params.project ? PROJECTS[params.project] : null;
1414
+ if (!projectId) {
1415
+ // Resolve from current entry's project text
1416
+ for (const [name, id] of Object.entries(PROJECTS)) {
1417
+ const shortName = name.split(".").pop();
1418
+ if (current.project && current.project.includes(shortName)) {
1419
+ projectName = name;
1420
+ projectId = id;
1421
+ break;
1422
+ }
1423
+ }
1424
+ }
1425
+ if (!projectId) {
1426
+ return {
1427
+ content: [
1428
+ {
1429
+ type: "text",
1430
+ text: `Could not resolve project from "${current.project}". Entry was deleted but not re-created.`,
1431
+ },
1432
+ ],
1433
+ };
1434
+ }
1435
+
1436
+ let taskId = params.task ? resolveTaskId(projectName, params.task) : null;
1437
+ if (!taskId) {
1438
+ taskId = resolveTaskId(projectName, current.task);
1439
+ }
1440
+ if (!taskId) {
1441
+ return {
1442
+ content: [
1443
+ {
1444
+ type: "text",
1445
+ text: `Could not resolve task from "${current.task}". Entry was deleted but not re-created.`,
1446
+ },
1447
+ ],
1448
+ };
1449
+ }
1450
+
1451
+ // Determine final values (new overrides current)
1452
+ const finalFrom = params.from || current.from;
1453
+ const finalTo = params.to || current.to;
1454
+ const finalActivity = params.activity || current.activity || "ar";
1455
+ const finalRemark =
1456
+ params.remark !== undefined ? params.remark : current.remark || "";
1457
+
1458
+ // Resolve location
1459
+ let finalLocation = "NULL";
1460
+ if (params.location) {
1461
+ finalLocation = LOCATIONS[params.location] || "-HO-";
1462
+ } else if (current.location) {
1463
+ // Map location text back to code
1464
+ for (const [, code] of Object.entries(LOCATIONS)) {
1465
+ if (current.location.includes(code.replace(/-/g, ""))) {
1466
+ finalLocation = code;
1467
+ break;
1468
+ }
1469
+ }
1470
+ }
1471
+
1472
+ // Resolve date: use params.date, or reconstruct from DD/MM + current year
1473
+ let finalDate = params.date;
1474
+ if (!finalDate && currentDate) {
1475
+ const [dd, mm] = currentDate.split("/");
1476
+ const year = new Date().getFullYear();
1477
+ finalDate = `${year}-${mm}-${dd}`;
1478
+ }
1479
+ if (!finalDate) {
1480
+ return {
1481
+ content: [
1482
+ {
1483
+ type: "text",
1484
+ text: `Could not determine date. Entry was deleted but not re-created.`,
1485
+ },
1486
+ ],
1487
+ };
1488
+ }
1489
+
1490
+ const duration = calculateDuration(finalFrom, finalTo);
1491
+
1492
+ // Step 4: Re-create the entry with modifications
1493
+ await page.goto(`https://${ZEP_HOST}${ZEP_PATH}`, {
1494
+ waitUntil: "networkidle2",
1495
+ timeout: 30000,
1496
+ });
1497
+ await page.waitForSelector("form", { timeout: 10000 });
1498
+
1499
+ // Set project
1500
+ await page.evaluate((pid) => {
1501
+ const form = document.querySelector("#ProjektzeitFormMgr");
1502
+ form.querySelector('select[name="projektId"]').value = pid;
1503
+ zepFormElement(
1504
+ form.querySelector('select[name="projektId"]'),
1505
+ ).refreshForm();
1506
+ }, projectId);
1507
+
1508
+ await page.waitForFunction(
1509
+ (tid) => {
1510
+ const sel = document.querySelector(
1511
+ "#ProjektzeitFormMgr select[name='vorgangId']",
1512
+ );
1513
+ return (
1514
+ sel && Array.from(sel.options).some((opt) => opt.value === tid)
1515
+ );
1516
+ },
1517
+ { timeout: 10000 },
1518
+ taskId,
1519
+ );
1520
+
1521
+ // Set all fields
1522
+ await page.evaluate(
1523
+ (e) => {
1524
+ const form = document.querySelector("#ProjektzeitFormMgr");
1525
+ form.querySelector('input[name="datum"]').value = e.date;
1526
+ form.querySelector('input[name="von"]').value = e.from;
1527
+ form.querySelector('input[name="bis"]').value = e.to;
1528
+ form.querySelector('input[name="dauer"]').value = e.duration;
1529
+ form.querySelector('input[name="bemerkung"]').value = e.remark;
1530
+ const taskSel = form.querySelector('select[name="vorgangId"]');
1531
+ if (taskSel) taskSel.value = e.taskId;
1532
+ const actSel = form.querySelector('select[name="taetigkeit"]');
1533
+ if (actSel) actSel.value = e.activity;
1534
+ const ortSel = form.querySelector('select[name="ort"]');
1535
+ if (ortSel) ortSel.value = e.location;
1536
+ // Suppress double-booking popup
1537
+ const dbField = form.querySelector(
1538
+ 'input[name="doppelbuchungTrotzdemFragenObUeberschreiben"]',
1539
+ );
1540
+ if (dbField) dbField.value = "0";
1541
+ const ubtField = form.querySelector(
1542
+ 'input[name="ueberschreibenTrotzDoppelbuchung"]',
1543
+ );
1544
+ if (ubtField) ubtField.value = "1";
1545
+ },
1546
+ {
1547
+ date: finalDate,
1548
+ from: finalFrom,
1549
+ to: finalTo,
1550
+ duration,
1551
+ remark: finalRemark,
1552
+ taskId,
1553
+ activity: finalActivity,
1554
+ location: finalLocation,
1555
+ },
1556
+ );
1557
+
1558
+ // Submit
1559
+ const submitResult = await page.evaluate(() => {
1560
+ try {
1561
+ const pm = $("form#ProjektzeitFormMgr").data("zepForm");
1562
+ if (!pm) return { error: "zepForm not found" };
1563
+ pm.submitAjax();
1564
+ return { success: true };
1565
+ } catch (e) {
1566
+ return { error: e.message };
1567
+ }
1568
+ });
1569
+
1570
+ if (submitResult.error) {
1571
+ return {
1572
+ content: [
1573
+ {
1574
+ type: "text",
1575
+ text: `Edit failed during re-creation: ${submitResult.error}. Original entry was deleted.`,
1576
+ },
1577
+ ],
1578
+ };
1579
+ }
1580
+
1581
+ await new Promise((r) => setTimeout(r, 3000));
1582
+
1583
+ const changes = [];
1584
+ if (params.from || params.to)
1585
+ changes.push(`time: ${finalFrom}-${finalTo}`);
1586
+ if (params.project) changes.push(`project: ${params.project}`);
1587
+ if (params.task) changes.push(`task: ${params.task}`);
1588
+ if (params.location) changes.push(`location: ${params.location}`);
1589
+ if (params.activity) changes.push(`activity: ${params.activity}`);
1590
+ if (params.remark !== undefined) changes.push(`remark: ${params.remark}`);
1591
+
1592
+ return {
1593
+ content: [
1594
+ {
1595
+ type: "text",
1596
+ text: `Edited entry ${params.entry_id}: ${changes.join(", ") || "updated"}`,
1597
+ },
1598
+ ],
1599
+ };
1600
+ } catch (error) {
1601
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
1602
+ }
1603
+ },
1604
+ );
1605
+
1606
+ async function main() {
1607
+ const transport = new StdioServerTransport();
1608
+ await server.connect(transport);
1609
+ }
1610
+
1611
+ main().catch(console.error);