systa-mcp 1.9.0 → 1.10.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.
Files changed (4) hide show
  1. package/README.md +9 -4
  2. package/SKILL.md +14 -7
  3. package/package.json +1 -1
  4. package/server.js +492 -15
package/README.md CHANGED
@@ -101,7 +101,7 @@ claude mcp add systa \
101
101
 
102
102
  ## Araçlar (tools)
103
103
 
104
- Toplam **90 araç** vardır; `tools/list` yanıtı anahtarın kapsamına göre filtrelenir
104
+ Toplam **93 araç** vardır; `tools/list` yanıtı anahtarın kapsamına göre filtrelenir
105
105
  (bkz. "Scope-aware araç listesi"). Aşağıdakiler en sık kullanılan çekirdek araçlardır —
106
106
  tam katalog `list_capabilities` ile keşfedilir.
107
107
 
@@ -136,15 +136,20 @@ onu önler. Sıra: **bak → konuş → sahiplen → sinyal ver → bırak.**
136
136
  | `send_work_session_message` | Birinin açık oturumuna yaz (kapalı oturum 409 döner) — `collaboration.message` |
137
137
  | `send_room_message` | Kaydın kendisine kalıcı not: `project` / `request` / `card` — `collaboration.message` |
138
138
  | `read_messages` | Konuşmayı oku; `afterId` ile "son okuduğumdan beri ne geldi" |
139
- | `listen_room` | Sınırlı süre canlı dinle (varsayılan 30 sn, en çok 120) — `work-session.view-active` |
139
+ | `listen_room` | Kısa süreli bloke dinleme (varsayılan 30 sn, en çok 240) — `work-session.view-active` |
140
+ | `subscribe_room` | Arka planda dinlemeye başla, ANINDA dön — izleme kipi budur |
141
+ | `drain_room_events` | Aboneliklerde biriken olayları anında al (tamponu boşaltır, API'ye dokunmaz) |
142
+ | `unsubscribe_room` | Aboneliği kapat; tamponda kalan olaylar son kez döner |
140
143
 
141
144
  **Odaya katılma diye bir adım yoktur.** Oda abone olunan kanal değil, kaydın kendisidir;
142
145
  erişim kapsamın yetiyorsa okur ve yazarsın. Gantt ve k&k chart üzerindeki düğüm proje,
143
146
  talep ya da kart olabilir — mesaj düğümün **temsil ettiği şeyin** odasına gider.
144
147
 
145
148
  **Sürekli dinleme (monitor) kipi yoktur.** MCP istemcisine push gelmez. Bekliyorsan
146
- `listen_room`, çalışıyorsan uğrayış başına tek `read_messages(afterId)`. İkisini birden
147
- yapmak ya da `listen_room` çağrılarını zincirlemek turu boşa harcar.
149
+ `listen_room` (en çok 240 sn — daha uzunu MCP istemcileri tarafından kesilir), çalışırken
150
+ haberdar olmak istiyorsan `subscribe_room` + `drain_room_events` (arka planda dinler, hiçbir
151
+ çağrı bloke olmaz, API yoklanmaz), sadece arada bir uğruyorsan uğrayış başına tek
152
+ `read_messages(afterId)`. `listen_room` çağrılarını zincirlemek turu boşa harcar.
148
153
 
149
154
  `systa_api_call` ile, özel bir aracı olmayan herhangi bir endpoint çağrılabilir
150
155
  (örn. `POST /requests`, `POST /requests/42/comments`). Anahtarın scope'u dışındaki
package/SKILL.md CHANGED
@@ -1,5 +1,5 @@
1
1
  ---
2
- version: 1.9.0
2
+ version: 1.10.0
3
3
  name: systa
4
4
  description: |
5
5
  Drive SysTa (Talep Takip Sistemi / SysTa) — a
@@ -234,18 +234,25 @@ Gantt ve k&k chart üzerinde bir düğüm proje, talep ya da kart olabilir. Mesa
234
234
 
235
235
  SysTa'nın canlı kanalı bir yayın odasıdır ve ön yüzün dinlediği odanın **aynısıdır**: tarayıcıdaki kullanıcı hangi olayı görüyorsa sen de onu görürsün. Üç şeyi bilmen yeterli:
236
236
 
237
- 1. **Katılma adımı yoktur.** `listen_room(roomType, roomId)` çağrısı kanalı açar ve abonelik o anda kurulur. Ayrı bir "odaya gir" çağrısı, üyelik kaydı ya da el sıkışma yoktur. Erişim kapsamın o kaydı görmene izin veriyorsa dinleyebilirsin; vermiyorsa çağrı boş sonuç değil **doğrudan ret** döner.
237
+ 1. **Katılma adımı yoktur.** `subscribe_room(roomType, roomId)` ya da `listen_room(roomType, roomId)` çağrısı kanalı açar ve abonelik o anda kurulur. Ayrı bir "odaya gir" çağrısı, üyelik kaydı ya da el sıkışma yoktur. Erişim kapsamın o kaydı görmene izin veriyorsa dinleyebilirsin; vermiyorsa çağrı boş sonuç değil **doğrudan ret** döner.
238
238
  2. **Olay mesajın KENDİSİNİ taşır.** Gelen olayın gövdesi `read_messages` ile aynı biçimdedir: gönderen, metin, kimlik, hepsi içindedir. Yani "bildirim geldi, şimdi içeriği çekeyim" diye **ikinci bir API çağrısı yapmana gerek yoktur**. Ek çağrı yalnız tek bir durumda gerekir: akış ortasında koparsa, `read_messages(afterId = gördüğün son id)` ile tam kaldığın yerden devam edersin.
239
239
  3. **Odada insan olmasa da duyarsın.** Agent kanalı sunucuda bilinçli olarak ayrı tutulur; "odada kimse yok" optimizasyonu senin yayınını kesmez. Gece yarısı tek başına çalışıyorsan da mesaj sana ulaşır.
240
240
 
241
- **Yeni mesajı nasıl alırsın: iki yol var, ikisini birden yapma.**
241
+ **Yeni mesajı nasıl alırsın: üç yol var, hangisinde olduğunu bil.**
242
242
 
243
- Önce şunu bil: MCP istemcisine **push yoktur**. Arka planda çalışan bir abonelik, seni uyandıran bir olay yoktur. Ya beklersin ya dönüp sorarsın.
243
+ Doğru soru şudur: **şu an bekliyor muyum, çalışırken haberdar mı olmak istiyorum, yoksa sadece arada bir uğruyor muyum?** Üçünün aracı ayrıdır ve birini diğerinin yerine kullanmak pahalıdır.
244
244
 
245
- 1. **Bekliyorsan → `listen_room(roomType, roomId, seconds)`.** Bir soru sordun ve cevabı gelmeden devam edemiyorsun; ya da birinin dosyayı bırakmasını bekliyorsun. `listen_room` canlı kanalı açar, **sınırlı süre** bekler (varsayılan 30 saniye, en çok bir saat) ve o sırada geleni döndürür. Sunucu kanalı süresiz açık tutar ve 25 saniyede bir keepalive gönderir; yani tavanı belirleyen sunucunun sabrı değil **senin turun**: bu çağrı açıkken dosya düzenleyemez, derleme çalıştıramaz, başka araç çağıramazsın. Süreyi beklediğin şeye göre seç — cevabı hemen gereken bir soru için yarım dakika, bilinçli olarak dinleyici modunda durduğun bir oda için dakikalar ya da saat. `stoppedBy` neden döndüğünü söyler: `timeout` | `maxEvents` | `streamEnded`. Beklemeye devam etmek için yeniden çağırırsın.
246
- 2. **Çalışıyorsan → `read_messages(..., afterId)`.** Yapacak işin var, sadece sen meşgulken ne olduğunu öğrenmek istiyorsun. Her uğrayışta **tek çağrı**, döngü değil. Uğrayışlar arasında asıl işini yap.
245
+ 1. **Çalışırken haberdar olmak istiyorsan → `subscribe_room` + `drain_room_events`.** Asıl kip budur ve 1.10.0 ile geldi. `subscribe_room(roomType, roomId)` odanın canlı kanalını **arka planda** açar ve **anında döner** — beklemez, turunu bloke etmez, bir araç çağrısını açık tutmaz. Sen kendi işini yaparsın (dosya düzenler, derleme çalıştırır, kart taşırsın); gelen olaylar bir tamponda birikir. Adımlar arasında `drain_room_events` çağırırsın: o da **anında döner** ve son çağrıdan beri ne geldiyse verir. İş bitince `unsubscribe_room` ya da hiç: abonelikler süreçle birlikte ölür.
247
246
 
248
- **Sürekli dinleme (monitor) diye bir kip yoktur.** `listen_room` çağrılarını arka arkaya zincirleyip odayı "izlemeye almak" turunu boşa harcar: bir saat sessiz kalan oda sana kırk kez boş sonuç döndürür. Aynı şekilde `read_messages`'ı sıkı döngüde çağırmak da yanlış cevaptır. Doğru soru şudur: **şu an bekliyor muyum, yoksa çalışıyor muyum?**
247
+ **Bu yoklamak değildir.** API'ye "yeni bir şey var mı?" diye tekrar tekrar sormuyorsun. Tek bir canlı kanal açık durur ve olayları sana iter; `drain_room_events` yalnız yerel tamponu okur, API'ye hiç dokunmaz. On kez boşaltmak bir kez boşaltmakla aynı maliyettedir.
248
+
249
+ 2. **Şu anda bekliyorsan → `listen_room(roomType, roomId, seconds)`.** Bir soru sordun, cevabı gelmeden devam edemiyorsun ve cevabı dakikalar içinde bekliyorsun. Bu çağrı açıkken dosya düzenleyemez, derleme çalıştıramaz, başka araç çağıramazsın. Tavan **240 saniyedir** — ve bu keyfi değil: çağrı açıkken MCP istemcisine **tek bayt akmaz** (kanal canlılık sinyali SysTa ile MCP sunucusu arasındadır, istemci onu görmez), istemci de sessiz çağrıyı askıda sayıp keser. Ölçüldü 2026-09-11: bir Codex istemcisi 3300 saniyelik beklemeyi **altıncı dakikada** `terminated` ile kapattı. Eskiden yazan "en çok bir saat" ifadesi hiçbir istemcide çalışmıyordu. Birkaç dakikadan uzun her şey için 1. maddeyi kullan.
250
+
251
+ 3. **Sadece arada bir uğruyorsan → `read_messages(..., afterId)`.** Aboneliğin yok ve geçmişe bakıyorsun. Her uğrayışta **tek çağrı**, döngü değil.
252
+
253
+ **Zincirleme yapma.** `listen_room` çağrılarını arka arkaya dizip odayı "izlemeye almak" turunu boşa harcar ve zaten istemci tarafından kesilir; `read_messages`'ı sıkı döngüde çağırmak da yanlış cevaptır. İzleme kipi artık gerçekten vardır ve adı `subscribe_room`'dur.
254
+
255
+ **Abonelikte kayıp SESSİZ DEĞİLDİR.** Aynı anda en çok 10 oda, oda başına en çok 200 olay tamponlanır. Tampon taşarsa **en eski** olay düşer ve `dropped` bunu sayar. Kanal koparsa kendiliğinden yeniden bağlanır ve `reconnects` artar. Her iki durumda da sonuç `gapSinceLastEventId` taşır: `read_messages(roomType, roomId, afterId = o değer)` ile aralığı tam olarak kapatırsın. `status` aboneliğin nerede olduğunu söyler (`live` | `connecting` | `reconnecting` | `closed`); kapanmışsa `closedReason` sebebini verir (`idleTimeout` — 30 dakika boşaltılmadı, `maxLifetime` — 4 saat doldu, `refused` — erişimin yok, `unsubscribed`, `error`). Yeniden gerekiyorsa yeniden abone ol.
249
256
 
250
257
  **İmleç.** `afterId` = okuduğun son mesajın kimliği; yalnız ondan sonrakileri alırsın. **Zaman damgasını imleç olarak kullanma** — API milisaniye yazar, kolon mikrosaniye tutar ve aynı satır iki kez gelir (ölçüldü 2026-09-08); yanıttaki `pagination.nextAfterId` alanını kullan. İlk çağrıyı imleçsiz yap, yakın geçmişi al, `nextAfterId` değerini sakla ve bundan sonra onu kullan.
251
258
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "systa-mcp",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "MCP stdio server for SysTa (Talep Takip Sistemi) — lets AI agents (Claude, Codex) use the SysTa REST API via a scoped API key. Zero npm dependencies (native Node).",
5
5
  "type": "commonjs",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -1357,6 +1357,250 @@ const MODULE_GATE_NOTE =
1357
1357
  'NOT an invalid key and NOT a missing permission -- do not tell the user to regenerate the ' +
1358
1358
  'key; tell them to have the vendor administrator enable API/MCP access.';
1359
1359
 
1360
+ // ── Arka plan oda aboneligi (1.10.0) ─────────────────────────────────────────
1361
+ //
1362
+ // NEDEN VAR. `listen_room` bir MCP `tools/call` cagrisini bekleme suresince
1363
+ // ACIK TUTAR. Bekleme boyunca kanaldan istemciye tek bayt akmaz, cunku
1364
+ // keepalive SysTa ile MCP sunucusu arasindaki SSE kanalindadir — istemci onu
1365
+ // GORMEZ. Istemcinin kendi istek zaman asimi devreye girer ve cagriyi
1366
+ // `terminated` ile keser. Olculdu 2026-09-11: Codex CLI, 3300 saniyelik bir
1367
+ // beklemeyi altinci dakikada kesti. Sunucu tarafinda kusur yoktu; istemci
1368
+ // beklemeyi askida kalmis sayiyor.
1369
+ //
1370
+ // COZUM. Beklemeyi cagrinin ICINDEN cikarmak. `subscribe_room` arka planda SSE
1371
+ // baglantisini acar ve ANINDA doner; olaylar bu surecte bir tamponda birikir;
1372
+ // `drain_room_events` tamponu ANINDA bosaltir. Ajanin turu hicbir noktada
1373
+ // bloke olmaz, API surekli yoklanmaz (bekleme isini yine SSE yapar) ve
1374
+ // istemcinin zaman asimi devreye giremez, cunku hicbir cagri uzun surmez.
1375
+ //
1376
+ // SINIRLAR ACIKTIR VE SESSIZ DEGILDIR. Tampon dolarsa EN ESKI olay dusurulur ve
1377
+ // `dropped` sayaci artar; ajan bunu gorup `read_messages(afterId)` ile telafi
1378
+ // eder. Baglanti koparsa dongu kendiliginden yeniden baglanir, `reconnects`
1379
+ // artar ve `gapSinceLastEventId` verilir — o aralik icin yine `read_messages`
1380
+ // dogru aractir.
1381
+ const ABONE_ODA_TAVANI = 10;
1382
+ const ABONE_TAMPON_TAVANI = 200;
1383
+ // Ajan tamponu bu kadar sure hic bosaltmazsa abonelik kendini kapatir. Amac
1384
+ // unutulmus aboneliklerin sonsuza kadar acik kalmamasi; sure uzun tutuldu,
1385
+ // cunku ajan uzun bir derleme ya da test kosumu sirasinda mesru olarak
1386
+ // sessiz kalabilir.
1387
+ const ABONE_BOSTA_MS = 30 * 60 * 1000;
1388
+ const ABONE_MUTLAK_OMUR_MS = 4 * 60 * 60 * 1000;
1389
+ const ABONE_BACKOFF_MS = [1000, 2000, 4000, 8000, 15000];
1390
+
1391
+ /** @type {Map<string, object>} */
1392
+ const ABONELIKLER = new Map();
1393
+
1394
+ const aboneAnahtari = (roomType, roomId) => `${roomType}:${roomId}`;
1395
+
1396
+ const bekle = (ms) =>
1397
+ new Promise((resolve) => {
1398
+ const t = setTimeout(resolve, ms);
1399
+ // Bekleme SURECI AYAKTA TUTMASIN: stdin kapandiginda surec cikabilmeli.
1400
+ if (typeof t.unref === 'function') {
1401
+ t.unref();
1402
+ }
1403
+ });
1404
+
1405
+ /**
1406
+ * Bir olayin tasidigi mesaj kimligini bulur (varsa). Kopukluk sonrasi
1407
+ * `read_messages(afterId)` icin baslangic noktasi budur.
1408
+ * @param {object} olay - parseSseRecord ciktisi
1409
+ * @returns {number|null}
1410
+ */
1411
+ const olayMesajKimligi = (olay) => {
1412
+ const veri = olay && olay.data;
1413
+ if (!veri || typeof veri !== 'object') {
1414
+ return null;
1415
+ }
1416
+ const aday = veri.id ?? veri.messageId ?? (veri.message && veri.message.id);
1417
+ const sayi = Number(aday);
1418
+ return Number.isFinite(sayi) ? sayi : null;
1419
+ };
1420
+
1421
+ /**
1422
+ * SSE tamponundaki TAM kayitlari olaya cevirip abonelige yazar; yarim kalan
1423
+ * son parcayi geri dondurur (bir sonraki parcayla birlesecek).
1424
+ * @param {object} abone
1425
+ * @param {string} tampon
1426
+ * @returns {string} islenmemis kalan
1427
+ */
1428
+ const sseTamponunuIsle = (abone, tampon) => {
1429
+ let kalan = tampon;
1430
+ let index = kalan.indexOf('\n\n');
1431
+ while (index !== -1) {
1432
+ const olay = parseSseRecord(kalan.slice(0, index));
1433
+ kalan = kalan.slice(index + 2);
1434
+ if (olay !== null) {
1435
+ aboneyeYaz(abone, olay);
1436
+ }
1437
+ index = kalan.indexOf('\n\n');
1438
+ }
1439
+ return kalan;
1440
+ };
1441
+
1442
+ /**
1443
+ * Aboneligin SSE baglantisini acar. Yetki/erisim reddinde aboneligi KAPATIR:
1444
+ * 401/403/404 yeniden denemeyle duzelmez ve sessizce yeniden denemek, ajanin
1445
+ * odayi dinledigini SANMASINA yol acardi.
1446
+ * @param {object} abone
1447
+ * @param {AbortController} controller
1448
+ * @returns {Promise<Response|null>} akis yanitı; null = bu turda acilamadi
1449
+ */
1450
+ const aboneAkisiniAc = async (abone, controller) => {
1451
+ const headers = { Authorization: `Bearer ${API_KEY}`, Accept: 'text/event-stream' };
1452
+ if (VENDOR_CODE) {
1453
+ headers['X-Vendor-Code'] = VENDOR_CODE;
1454
+ }
1455
+ const response = await fetch(new URL(BASE_URL + abone.apiPath), {
1456
+ method: 'GET',
1457
+ headers,
1458
+ signal: controller.signal,
1459
+ });
1460
+ if (response.ok) {
1461
+ return response;
1462
+ }
1463
+ const text = await response.text();
1464
+ if (response.status === 401 || response.status === 403 || response.status === 404) {
1465
+ abone.lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`;
1466
+ abone.closedReason = 'refused';
1467
+ return null;
1468
+ }
1469
+ abone.lastError = `HTTP ${response.status}`;
1470
+ throw new Error(abone.lastError);
1471
+ };
1472
+
1473
+ /**
1474
+ * Aboneligin arka plan dongusu: SSE'yi acar, olaylari tampona yazar, kopukta
1475
+ * artan gecikmeyle yeniden baglanir. `abone.kapali` true olunca sona erer.
1476
+ * @param {object} abone
1477
+ * @returns {Promise<void>}
1478
+ */
1479
+ const aboneDongusu = async (abone) => {
1480
+ let backoffIndex = 0;
1481
+ while (!abone.kapali) {
1482
+ if (Date.now() - abone.startedAt > ABONE_MUTLAK_OMUR_MS) {
1483
+ abone.closedReason = 'maxLifetime';
1484
+ break;
1485
+ }
1486
+ const controller = new AbortController();
1487
+ abone.controller = controller;
1488
+ try {
1489
+ const response = await aboneAkisiniAc(abone, controller);
1490
+ if (response === null) {
1491
+ break;
1492
+ }
1493
+ abone.status = 'live';
1494
+ abone.lastError = null;
1495
+ backoffIndex = 0;
1496
+ const decoder = new TextDecoder();
1497
+ let tampon = '';
1498
+ for await (const chunk of response.body) {
1499
+ tampon = sseTamponunuIsle(abone, tampon + decoder.decode(chunk, { stream: true }));
1500
+ }
1501
+ } catch (error) {
1502
+ if (abone.kapali) {
1503
+ break;
1504
+ }
1505
+ if (error.name !== 'AbortError') {
1506
+ abone.lastError = error.message;
1507
+ }
1508
+ }
1509
+ if (abone.kapali || abone.closedReason) {
1510
+ break;
1511
+ }
1512
+ // Akis normal bitse de kopsa da yeniden baglaniriz; fark yalniz gecikmede.
1513
+ abone.reconnects += 1;
1514
+ abone.status = 'reconnecting';
1515
+ await bekle(ABONE_BACKOFF_MS[Math.min(backoffIndex, ABONE_BACKOFF_MS.length - 1)]);
1516
+ backoffIndex += 1;
1517
+ }
1518
+ abone.status = 'closed';
1519
+ abone.kapali = true;
1520
+ if (!abone.closedReason) {
1521
+ abone.closedReason = 'unsubscribed';
1522
+ }
1523
+ };
1524
+
1525
+ /**
1526
+ * Olayi aboneligin halka tamponuna yazar. Tampon doluysa EN ESKI olay dusurulur
1527
+ * ve sayac artar — sessiz kayip yok.
1528
+ * @param {object} abone
1529
+ * @param {object} olay
1530
+ */
1531
+ const aboneyeYaz = (abone, olay) => {
1532
+ const kimlik = olayMesajKimligi(olay);
1533
+ if (kimlik !== null) {
1534
+ abone.lastEventId = kimlik;
1535
+ }
1536
+ abone.events.push(olay);
1537
+ abone.totalEvents += 1;
1538
+ while (abone.events.length > ABONE_TAMPON_TAVANI) {
1539
+ abone.events.shift();
1540
+ abone.dropped += 1;
1541
+ }
1542
+ };
1543
+
1544
+ /**
1545
+ * Bosta kalmis ya da omrunu doldurmus abonelikleri kapatir. Her abonelik
1546
+ * cagrisinda calisir; ayri bir zamanlayici YOK (zamanlayici sureci ayakta
1547
+ * tutardi).
1548
+ */
1549
+ const aboneleriSupur = () => {
1550
+ const simdi = Date.now();
1551
+ for (const abone of ABONELIKLER.values()) {
1552
+ if (abone.kapali) {
1553
+ continue;
1554
+ }
1555
+ const bosta = simdi - (abone.lastDrainAt || abone.startedAt);
1556
+ if (bosta > ABONE_BOSTA_MS) {
1557
+ abone.closedReason = 'idleTimeout';
1558
+ aboneKapat(abone);
1559
+ } else if (simdi - abone.startedAt > ABONE_MUTLAK_OMUR_MS) {
1560
+ abone.closedReason = 'maxLifetime';
1561
+ aboneKapat(abone);
1562
+ }
1563
+ }
1564
+ };
1565
+
1566
+ /**
1567
+ * Aboneligi kapatir: dongu bir sonraki adimda sonlanir, acik SSE istegi hemen
1568
+ * iptal edilir.
1569
+ * @param {object} abone
1570
+ */
1571
+ const aboneKapat = (abone) => {
1572
+ abone.kapali = true;
1573
+ abone.status = 'closed';
1574
+ if (abone.controller) {
1575
+ try {
1576
+ abone.controller.abort();
1577
+ } catch {
1578
+ // Zaten kapanmis baglantiyi iptal etmek hata degildir.
1579
+ }
1580
+ }
1581
+ };
1582
+
1583
+ /**
1584
+ * Aboneligin ajana gosterilen ozeti. Tampon ICERIGI burada YOK — olaylari
1585
+ * yalniz `drain_room_events` verir ve verdikten sonra tamponu bosaltir.
1586
+ * @param {object} abone
1587
+ * @returns {object}
1588
+ */
1589
+ const aboneOzeti = (abone) => ({
1590
+ roomType: abone.roomType,
1591
+ roomId: abone.roomId,
1592
+ status: abone.status,
1593
+ pending: abone.events.length,
1594
+ totalEvents: abone.totalEvents,
1595
+ dropped: abone.dropped,
1596
+ reconnects: abone.reconnects,
1597
+ lastEventId: abone.lastEventId,
1598
+ startedAt: new Date(abone.startedAt).toISOString(),
1599
+ lastDrainAt: abone.lastDrainAt ? new Date(abone.lastDrainAt).toISOString() : null,
1600
+ lastError: abone.lastError,
1601
+ closedReason: abone.closedReason,
1602
+ });
1603
+
1360
1604
  const TOOLS = [
1361
1605
  {
1362
1606
  name: 'list_requests',
@@ -2404,7 +2648,7 @@ WAITING FOR THE ANSWER. A session message does not notify you back. If you canno
2404
2648
 
2405
2649
  Reading needs "work-session.view-active"; WRITING needs "collaboration.message". Seeing coordination and joining it are separate privileges.
2406
2650
 
2407
- Body is plain text, at most 2000 characters. Trailing and leading spaces are trimmed; an empty body is refused.`,
2651
+ Body is plain text, at most 4000 characters. Trailing and leading spaces are trimmed; an empty body is refused.`,
2408
2652
  inputSchema: {
2409
2653
  type: 'object',
2410
2654
  additionalProperties: false,
@@ -2414,7 +2658,7 @@ Body is plain text, at most 2000 characters. Trailing and leading spaces are tri
2414
2658
  type: 'integer',
2415
2659
  description: 'Open work session id — read it from list_active_work_sessions.',
2416
2660
  },
2417
- body: { type: 'string', description: 'Plain text, 1..2000 characters.' },
2661
+ body: { type: 'string', description: 'Plain text, 1..4000 characters.' },
2418
2662
  },
2419
2663
  },
2420
2664
  handler: (args) =>
@@ -2439,7 +2683,7 @@ ROOM OR SESSION. The message is about the record -> room. The message is about s
2439
2683
 
2440
2684
  Requires "collaboration.message". Reading is a weaker privilege than writing: a role may watch coordination without joining it.
2441
2685
 
2442
- Body is plain text, at most 2000 characters.`,
2686
+ Body is plain text, at most 4000 characters.`,
2443
2687
  inputSchema: {
2444
2688
  type: 'object',
2445
2689
  additionalProperties: false,
@@ -2447,7 +2691,7 @@ Body is plain text, at most 2000 characters.`,
2447
2691
  properties: {
2448
2692
  roomType: { type: 'string', enum: ['project', 'request', 'card'] },
2449
2693
  roomId: { type: 'integer', description: 'Project id, request number, or card GLOBAL id.' },
2450
- body: { type: 'string', description: 'Plain text, 1..2000 characters.' },
2694
+ body: { type: 'string', description: 'Plain text, 1..4000 characters.' },
2451
2695
  },
2452
2696
  },
2453
2697
  handler: (args) =>
@@ -2512,19 +2756,20 @@ FIRST CALL: read once with no cursor to get the recent history, remember paginat
2512
2756
  name: 'listen_room',
2513
2757
  description: `Wait for live activity in a room instead of polling. Opens the live channel, listens for a bounded time, and returns whatever arrived.
2514
2758
 
2515
- THIS IS A BOUNDED WAIT, NOT A SUBSCRIPTION. It returns after "seconds" (default 30, max 3600 = one hour), or earlier once maxEvents is reached. It does not keep running in the background and nothing is delivered to you afterwards. stoppedBy tells you why it returned: timeout | maxEvents | streamEnded. To keep waiting, call it again.
2759
+ THIS IS A BOUNDED WAIT, NOT A SUBSCRIPTION. It returns after "seconds" (default 30, max 240 = four minutes), or earlier once maxEvents is reached. It does not keep running in the background and nothing is delivered to you afterwards. stoppedBy tells you why it returned: timeout | maxEvents | streamEnded. To keep waiting, call it again.
2516
2760
 
2517
- HOW LONG SHOULD YOU WAIT. The server keeps the channel open indefinitely (it sends a keepalive every 25 seconds), so the ceiling here is about YOUR turn, not the server's patience. While this call is open your turn is blocked: you cannot edit a file, run a build or call another tool. So pick the wait from what you are waiting for:
2518
- * a colleague is mid-sentence and you need the reply now -> 30 to 120 seconds
2519
- * you asked a question and have nothing else to do until it is answered -> a few minutes
2520
- * you are deliberately standing by as a listener on a shared room -> minutes to an hour
2521
- Waiting an hour when you had work to do is the expensive mistake, not the long ceiling itself. If you have work, do the work and check back with read_messages(afterId) instead.
2761
+ WHY THE CEILING IS FOUR MINUTES AND NOT LONGER. While this call is open, NOTHING travels to your MCP client — the channel keepalive runs between the SysTa server and this MCP server, and your client cannot see it. Clients treat a silent call as hung and abort it. Measured 2026-09-11: a Codex CLI client killed a 3300-second wait in the sixth minute with "terminated". The old one-hour ceiling therefore worked on no client at all; it only produced aborted calls. If you need to watch a room for longer than a few minutes, listen_room is the WRONG tool — use subscribe_room.
2522
2762
 
2523
- Note that your own MCP client may abort a tool call before the server does; if a long wait comes back early, that is the client's limit, not an error.
2763
+ FOR ANYTHING LONGER, USE subscribe_room. subscribe_room opens the channel in the background and returns instantly; drain_room_events then hands you whatever arrived, also instantly. Your turn is never blocked, no call is ever long enough to be aborted, and the API is not polled. That is the monitoring mode; this tool is not.
2524
2764
 
2525
- WHEN TO USE IT. You are blocked on an answer: you asked somebody with send_work_session_message or send_room_message and cannot continue until they reply. Or you are waiting for a colleague to release a file. In both cases you have nothing useful to do meanwhile, so waiting inside one call is cheaper and faster than repeated reads.
2765
+ HOW LONG SHOULD YOU WAIT HERE. While this call is open your turn is blocked: you cannot edit a file, run a build or call another tool. So pick the wait from what you are waiting for:
2766
+ * a colleague is mid-sentence and you need the reply now -> 30 to 120 seconds
2767
+ * you asked a question and have nothing else to do until it is answered -> up to the 240 ceiling
2768
+ * anything longer, or anything you want to watch WHILE working -> subscribe_room
2769
+
2770
+ WHEN TO USE IT. You are blocked on an answer right now: you asked somebody with send_work_session_message or send_room_message and cannot continue until they reply, and you expect the reply within minutes. You have nothing useful to do meanwhile, so one short wait beats repeated reads.
2526
2771
 
2527
- WHEN NOT TO USE IT. You have work to do. Then do the work and check in afterwards with read_messages(afterId). Chaining listen_room calls back to back to "monitor" a room burns your turn for nothing — there is no monitoring mode, and a room that is quiet for an hour returns you an empty result forty times.
2772
+ WHEN NOT TO USE IT. You have work to do. Then subscribe_room, do the work, and drain_room_events between steps. Chaining listen_room calls back to back to "monitor" a room burns your turn for nothing.
2528
2773
 
2529
2774
  HOW THE CHANNEL WORKS (you cannot read the code, so here it is). The live channel is a broadcast room and it is the SAME room the web UI listens to: whatever a person in the browser sees, you see. Three facts:
2530
2775
  * There is no join step. This call opens the channel and the subscription is created right then. No separate "enter room" call, no membership record, no handshake.
@@ -2544,7 +2789,7 @@ Requires "work-session.view-active". You only reach rooms your access scope alre
2544
2789
  seconds: {
2545
2790
  type: 'integer',
2546
2791
  description:
2547
- 'How long to wait before returning, default 30, max 3600 (one hour). Your turn is blocked for this whole time, so match it to what you are waiting for.',
2792
+ 'How long to wait before returning, default 30, max 240 (four minutes). Your turn is blocked for this whole time. Longer waits are aborted by MCP clients, so for anything longer use subscribe_room instead.',
2548
2793
  },
2549
2794
  maxEvents: {
2550
2795
  type: 'integer',
@@ -2558,11 +2803,243 @@ Requires "work-session.view-active". You only reach rooms your access scope alre
2558
2803
  {
2559
2804
  // Tavan bir SAAT. Sunucu akisi suresiz acik tutar (25 sn'de bir
2560
2805
  // keepalive), yani sinir sunucunun sabri degil ajanin turudur.
2561
- seconds: Math.min(Number(args.seconds) || 30, 3600),
2806
+ // Tavan 240 SANIYE. Sunucu akisi suresiz acik tutabilir, ama MCP
2807
+ // ISTEMCISI sessiz gecen bir cagriyi askida sayip keser: olculdu
2808
+ // 2026-09-11, Codex CLI 3300 saniyelik beklemeyi altinci dakikada
2809
+ // `terminated` ile kapatti. Hicbir istemcide calismayan bir saatlik
2810
+ // tavan yaniltici oldugu icin gercekci bir degere cekildi. Daha
2811
+ // uzun dinleme icin doğru arac subscribe_room + drain_room_events.
2812
+ seconds: Math.min(Number(args.seconds) || 30, 240),
2562
2813
  maxEvents: Math.min(Number(args.maxEvents) || 50, 200),
2563
2814
  },
2564
2815
  ),
2565
2816
  },
2817
+ {
2818
+ name: 'subscribe_room',
2819
+ description: `Start listening to a room IN THE BACKGROUND and return immediately. This is the monitoring mode listen_room does not have.
2820
+
2821
+ WHAT IT DOES. It opens the room's live channel inside this MCP server process and keeps it open. Events that arrive are buffered for you. The call itself returns at once — it does NOT wait, it does NOT block your turn, and it does NOT hold a tool call open. You then go do your own work (edit files, run builds, move cards) and call drain_room_events whenever you want to see what arrived. The waiting is done by the open channel, not by you.
2822
+
2823
+ WHY THIS EXISTS. listen_room holds one tool call open for the whole wait. During that wait nothing travels to your MCP client, so your client's own request timeout can fire and abort the call ("terminated") long before the wait is over. Measured 2026-09-11: a Codex CLI client killed a 3300-second wait in the sixth minute. Nothing was broken server-side — the client simply treats a silent call as hung. subscribe_room removes that failure mode entirely, because no call is ever long.
2824
+
2825
+ THIS IS NOT POLLING. You are not asking the API "anything new?" over and over. One live channel stays open and pushes events to this process; drain_room_events reads a local buffer and never touches the API. Ten drains cost the same as one.
2826
+
2827
+ TYPICAL SHAPE OF A SESSION:
2828
+ 1. subscribe_room for each room you care about (your request room, the cards you are working on)
2829
+ 2. do your actual work
2830
+ 3. drain_room_events between steps — it returns instantly whether or not anything arrived
2831
+ 4. unsubscribe_room when you are done, or just end your session (subscriptions die with the process)
2832
+
2833
+ LIMITS ARE VISIBLE, NEVER SILENT. At most 10 rooms at once and 200 buffered events per room. If the buffer overflows the OLDEST event is dropped and "dropped" counts it, so you know to recover with read_messages(afterId=lastEventId). If the channel drops it reconnects on its own and "reconnects" counts it; the same recovery applies to the gap. A subscription closes itself after 30 minutes with no drain, or 4 hours total, and says so in closedReason.
2834
+
2835
+ Same room, same access rules and same event shape as listen_room and read_messages. Subscribing twice to one room is not an error; it returns the existing subscription untouched.
2836
+
2837
+ Requires "work-session.view-active". You only reach rooms your access scope already allows.`,
2838
+ inputSchema: {
2839
+ type: 'object',
2840
+ additionalProperties: false,
2841
+ required: ['roomType', 'roomId'],
2842
+ properties: {
2843
+ roomType: { type: 'string', enum: ['project', 'request', 'card'] },
2844
+ roomId: { type: 'integer', description: 'Project id, request number, or card GLOBAL id.' },
2845
+ },
2846
+ },
2847
+ handler: async (args) => {
2848
+ if (!BASE_URL || !API_KEY) {
2849
+ throw new Error('SYSTA_API_BASE_URL / SYSTA_API_KEY is not configured');
2850
+ }
2851
+ aboneleriSupur();
2852
+ const anahtar = aboneAnahtari(args.roomType, args.roomId);
2853
+ const mevcut = ABONELIKLER.get(anahtar);
2854
+ if (mevcut && !mevcut.kapali) {
2855
+ return {
2856
+ status: 200,
2857
+ ok: true,
2858
+ data: { subscription: aboneOzeti(mevcut), alreadySubscribed: true },
2859
+ };
2860
+ }
2861
+ const canli = [...ABONELIKLER.values()].filter((a) => !a.kapali);
2862
+ if (canli.length >= ABONE_ODA_TAVANI) {
2863
+ return {
2864
+ status: 429,
2865
+ ok: false,
2866
+ data: {
2867
+ error: 'TOO_MANY_SUBSCRIPTIONS',
2868
+ message: `At most ${ABONE_ODA_TAVANI} rooms at once. Call unsubscribe_room on a room you no longer need.`,
2869
+ subscriptions: canli.map(aboneOzeti),
2870
+ },
2871
+ };
2872
+ }
2873
+ const abone = {
2874
+ roomType: args.roomType,
2875
+ roomId: args.roomId,
2876
+ apiPath: `/rooms/${encodeURIComponent(args.roomType)}/${encodeURIComponent(args.roomId)}/stream`,
2877
+ events: [],
2878
+ dropped: 0,
2879
+ totalEvents: 0,
2880
+ reconnects: 0,
2881
+ lastEventId: null,
2882
+ startedAt: Date.now(),
2883
+ lastDrainAt: null,
2884
+ status: 'connecting',
2885
+ lastError: null,
2886
+ closedReason: null,
2887
+ kapali: false,
2888
+ controller: null,
2889
+ };
2890
+ ABONELIKLER.set(anahtar, abone);
2891
+ // Fire-and-forget: dongu kendi hatasini abonelige yazar, sureci dusurmez.
2892
+ aboneDongusu(abone).catch((error) => {
2893
+ abone.lastError = error.message;
2894
+ abone.status = 'closed';
2895
+ abone.kapali = true;
2896
+ abone.closedReason = 'error';
2897
+ });
2898
+ return {
2899
+ status: 200,
2900
+ ok: true,
2901
+ data: {
2902
+ subscription: aboneOzeti(abone),
2903
+ alreadySubscribed: false,
2904
+ note: 'Listening in the background. Do your work, then call drain_room_events — it returns instantly.',
2905
+ },
2906
+ };
2907
+ },
2908
+ },
2909
+ {
2910
+ name: 'drain_room_events',
2911
+ description: `Take everything that arrived on your background subscriptions since the last drain. Returns INSTANTLY — it never waits and never calls the API.
2912
+
2913
+ This is the read half of subscribe_room. The buffer is emptied by this call, so each event is handed to you exactly once. An empty result genuinely means nobody said anything; it does not mean the channel is broken (check "status" if unsure).
2914
+
2915
+ Call it freely. It is a local buffer read, so draining ten times between build steps costs nothing. This is the correct way to stay aware of a room while you work — not chained listen_room calls, and not repeated read_messages.
2916
+
2917
+ WITHOUT ARGUMENTS it drains every room you are subscribed to. Give roomType+roomId to drain just one.
2918
+
2919
+ EVENTS CARRY THE MESSAGE ITSELF — same shape as read_messages, sender and text included. You do NOT need a second call to fetch the content.
2920
+
2921
+ WHEN YOU MUST RECOVER. Two fields tell you that you may have missed something:
2922
+ * dropped > 0 — the buffer overflowed (more than 200 events between drains).
2923
+ * reconnects went up and the result carries gapSinceLastEventId — the channel dropped and came back.
2924
+ In both cases call read_messages(roomType, roomId, afterId = lastEventId) to fill the gap exactly. Nothing is silently lost; you are always told.
2925
+
2926
+ status tells you where the subscription stands: live | connecting | reconnecting | closed. A closed subscription carries closedReason (idleTimeout | maxLifetime | refused | unsubscribed | error) — subscribe again if you still need it.`,
2927
+ inputSchema: {
2928
+ type: 'object',
2929
+ additionalProperties: false,
2930
+ properties: {
2931
+ roomType: { type: 'string', enum: ['project', 'request', 'card'] },
2932
+ roomId: { type: 'integer', description: 'Project id, request number, or card GLOBAL id.' },
2933
+ },
2934
+ },
2935
+ handler: async (args) => {
2936
+ aboneleriSupur();
2937
+ if ((args.roomType && !args.roomId) || (!args.roomType && args.roomId)) {
2938
+ throw new Error('Give both roomType and roomId, or neither (drains every subscription)');
2939
+ }
2940
+ const hedefler = args.roomType
2941
+ ? [ABONELIKLER.get(aboneAnahtari(args.roomType, args.roomId))].filter(Boolean)
2942
+ : [...ABONELIKLER.values()];
2943
+ if (args.roomType && hedefler.length === 0) {
2944
+ return {
2945
+ status: 404,
2946
+ ok: false,
2947
+ data: {
2948
+ error: 'NOT_SUBSCRIBED',
2949
+ message: `No subscription for ${args.roomType}:${args.roomId}. Call subscribe_room first.`,
2950
+ },
2951
+ };
2952
+ }
2953
+ const simdi = Date.now();
2954
+ const rooms = hedefler.map((abone) => {
2955
+ const events = abone.events;
2956
+ const dropped = abone.dropped;
2957
+ const oncekiReconnects = abone.drainedReconnects || 0;
2958
+ abone.events = [];
2959
+ abone.dropped = 0;
2960
+ abone.drainedReconnects = abone.reconnects;
2961
+ abone.lastDrainAt = simdi;
2962
+ const kopukluk = abone.reconnects > oncekiReconnects;
2963
+ return {
2964
+ roomType: abone.roomType,
2965
+ roomId: abone.roomId,
2966
+ status: abone.status,
2967
+ events,
2968
+ dropped,
2969
+ reconnects: abone.reconnects,
2970
+ lastEventId: abone.lastEventId,
2971
+ // Kopukluk ya da tasma varsa aralik AYNEN soylenir; ajan bunu
2972
+ // read_messages(afterId) ile kapatir.
2973
+ gapSinceLastEventId: kopukluk || dropped > 0 ? abone.lastEventId : null,
2974
+ lastError: abone.lastError,
2975
+ closedReason: abone.closedReason,
2976
+ };
2977
+ });
2978
+ // Kapanmis ve bosaltilmis abonelikler haritada tutulmaz.
2979
+ for (const abone of hedefler) {
2980
+ if (abone.kapali && abone.events.length === 0) {
2981
+ ABONELIKLER.delete(aboneAnahtari(abone.roomType, abone.roomId));
2982
+ }
2983
+ }
2984
+ return {
2985
+ status: 200,
2986
+ ok: true,
2987
+ data: {
2988
+ rooms,
2989
+ totalEvents: rooms.reduce((toplam, oda) => toplam + oda.events.length, 0),
2990
+ },
2991
+ };
2992
+ },
2993
+ },
2994
+ {
2995
+ name: 'unsubscribe_room',
2996
+ description: `Stop a background subscription started with subscribe_room and close its channel. Any events still buffered are returned one last time, so ending a subscription never loses what already arrived.
2997
+
2998
+ Without arguments it stops EVERY subscription. Give roomType+roomId to stop just one.
2999
+
3000
+ You do not have to call this before ending your session — subscriptions live inside this MCP server process and die with it. Use it when you are done with a room but still working, especially if you want to free a slot (10 rooms maximum).`,
3001
+ inputSchema: {
3002
+ type: 'object',
3003
+ additionalProperties: false,
3004
+ properties: {
3005
+ roomType: { type: 'string', enum: ['project', 'request', 'card'] },
3006
+ roomId: { type: 'integer', description: 'Project id, request number, or card GLOBAL id.' },
3007
+ },
3008
+ },
3009
+ handler: async (args) => {
3010
+ if ((args.roomType && !args.roomId) || (!args.roomType && args.roomId)) {
3011
+ throw new Error('Give both roomType and roomId, or neither (stops every subscription)');
3012
+ }
3013
+ const hedefler = args.roomType
3014
+ ? [ABONELIKLER.get(aboneAnahtari(args.roomType, args.roomId))].filter(Boolean)
3015
+ : [...ABONELIKLER.values()];
3016
+ if (args.roomType && hedefler.length === 0) {
3017
+ return {
3018
+ status: 404,
3019
+ ok: false,
3020
+ data: {
3021
+ error: 'NOT_SUBSCRIBED',
3022
+ message: `No subscription for ${args.roomType}:${args.roomId}.`,
3023
+ },
3024
+ };
3025
+ }
3026
+ const closed = hedefler.map((abone) => {
3027
+ abone.closedReason = 'unsubscribed';
3028
+ aboneKapat(abone);
3029
+ const events = abone.events;
3030
+ abone.events = [];
3031
+ ABONELIKLER.delete(aboneAnahtari(abone.roomType, abone.roomId));
3032
+ return {
3033
+ roomType: abone.roomType,
3034
+ roomId: abone.roomId,
3035
+ finalEvents: events,
3036
+ dropped: abone.dropped,
3037
+ lastEventId: abone.lastEventId,
3038
+ };
3039
+ });
3040
+ return { status: 200, ok: true, data: { closed } };
3041
+ },
3042
+ },
2566
3043
  {
2567
3044
  name: 'list_allowed_transitions',
2568
3045
  description: