systa-mcp 1.5.1 → 1.9.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 +34 -1
- package/SKILL.md +59 -2
- package/package.json +1 -1
- package/server.js +702 -26
package/README.md
CHANGED
|
@@ -101,7 +101,7 @@ claude mcp add systa \
|
|
|
101
101
|
|
|
102
102
|
## Araçlar (tools)
|
|
103
103
|
|
|
104
|
-
Toplam **
|
|
104
|
+
Toplam **90 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
|
|
|
@@ -119,6 +119,33 @@ tam katalog `list_capabilities` ile keşfedilir.
|
|
|
119
119
|
| `describe_module` | Bir modülün (örn. request/kanban/plan) amacı, kavramları ve çağrılabilir endpoint'leri — kullanıcı niyetini doğru modüle eşlemek için (oryantasyon) |
|
|
120
120
|
| `describe_endpoint` | Tek endpoint detayı: alan tipleri, güvenlik sınıfı ve tam HTTP Response Contract v2 — çağrı gövdesini kurmadan ve yanıtı zincirlemeden önce |
|
|
121
121
|
|
|
122
|
+
### Agent koordinasyonu (çalışma oturumu ve sohbet)
|
|
123
|
+
|
|
124
|
+
İki agent aynı kayıtta habersiz çalışırsa birbirinin işini ezer. Bu araçlar tam olarak
|
|
125
|
+
onu önler. Sıra: **bak → konuş → sahiplen → sinyal ver → bırak.**
|
|
126
|
+
|
|
127
|
+
| Araç | Açıklama |
|
|
128
|
+
| ----------------------------- | ----------------------------------------------------------------------------------------- |
|
|
129
|
+
| `list_active_work_sessions` | Şu anda kim neyin üzerinde çalışıyor — düzenlemeden ÖNCE bak — `work-session.view-active` |
|
|
130
|
+
| `start_work_session` | Kaydı sahiplen (talep ya da kart, tam biri); nota YERİNİ yaz — `work-session.manage` |
|
|
131
|
+
| `send_work_session_heartbeat` | Çalışırken sinyal ver; 15 dakika sessizlik oturumu kapatır — `work-session.manage` |
|
|
132
|
+
| `update_work_session_note` | Yer değiştirdiysen notu tazele (`null` notu siler) — `work-session.manage` |
|
|
133
|
+
| `end_work_session` | İddiayı bırak; gövde göndermek gerekmez — `work-session.manage` |
|
|
134
|
+
| `list_my_work_sessions` | Kendi açık oturumların — `work-session.manage` |
|
|
135
|
+
| `list_work_session_history` | Bir kaydın geçmiş oturumları (imleç geriye gider) — `work-session.view-active` |
|
|
136
|
+
| `send_work_session_message` | Birinin açık oturumuna yaz (kapalı oturum 409 döner) — `collaboration.message` |
|
|
137
|
+
| `send_room_message` | Kaydın kendisine kalıcı not: `project` / `request` / `card` — `collaboration.message` |
|
|
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` |
|
|
140
|
+
|
|
141
|
+
**Odaya katılma diye bir adım yoktur.** Oda abone olunan kanal değil, kaydın kendisidir;
|
|
142
|
+
erişim kapsamın yetiyorsa okur ve yazarsın. Gantt ve k&k chart üzerindeki düğüm proje,
|
|
143
|
+
talep ya da kart olabilir — mesaj düğümün **temsil ettiği şeyin** odasına gider.
|
|
144
|
+
|
|
145
|
+
**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.
|
|
148
|
+
|
|
122
149
|
`systa_api_call` ile, özel bir aracı olmayan herhangi bir endpoint çağrılabilir
|
|
123
150
|
(örn. `POST /requests`, `POST /requests/42/comments`). Anahtarın scope'u dışındaki
|
|
124
151
|
çağrılar `403` döner.
|
|
@@ -226,3 +253,9 @@ npm publish # publishConfig.access=public ile public yayınlanır
|
|
|
226
253
|
```
|
|
227
254
|
|
|
228
255
|
Sürüm `package.json` ve `server.js` (`SERVER_VERSION`) içinde **birlikte** yükseltilir.
|
|
256
|
+
|
|
257
|
+
### Yayınlanmamış değişiklikler (sonraki yayına girer)
|
|
258
|
+
|
|
259
|
+
- `resolve_user_by_name` çıktısı `list_users` ile aynı nested şekli ilan eder: `company{id,name}`,
|
|
260
|
+
`department{id,name}` (eski düz `companyName`/`departmentName` kaldırıldı — Faz 33).
|
|
261
|
+
- SKILL.md: `stakeHolders` → `stakeholders` (tek yazım; arka uç `stakeHolders` kabul etmez).
|
package/SKILL.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
version: 1.
|
|
2
|
+
version: 1.9.0
|
|
3
3
|
name: systa
|
|
4
4
|
description: |
|
|
5
5
|
Drive SysTa (Talep Takip Sistemi / SysTa) — a
|
|
@@ -163,6 +163,7 @@ veya `describe_endpoint` ile gövdeyi netleştir.
|
|
|
163
163
|
5. **Resolve dynamic required fields up front.** Before a create/mutation, resolve the entity's required fields _for this vendor_ (e.g. `statusId` via `GET /api/statuses`, `companyId` from `who-am-i`, approver name → `userId` via user lookup). Don't submit a half-filled body and let it 400 — pre-resolve, then submit. For a main request form use the `form` module; for an alt form use `describe_endpoint('/api/sub-forms/:id','GET')` and then read `GET /api/sub-forms/:id`. Never substitute `/forms/:id` for an alt form.
|
|
164
164
|
6. **Safety tiers gate confirmation.** `read_only` → just do it. `mutating`/`idempotent` → do it, then report. **`destructive`** (revoke access, delete, hard removal) and live side-effecting actions (publish, send invite, advance release workflow, test webhook) → show a one-line "şunu yapacağım" and get an explicit OK first. Publishing (announcement/release notes/product update) is **never** auto-published — show a summary, get approval.
|
|
165
165
|
7. **Don't over-ask scope.** `companyId`/`userId`/`vendorSchema` come from `who-am-i`; don't ask the user for them when a single sane value exists.
|
|
166
|
+
8. **Write bodies are NESTED (raw `systa_api_call` POST/PUT/PATCH).** Identity references go as groups, exactly as `describe_endpoint(...).fields` shows: `{ "status": { "id": 9 }, "company": { "id": 12 }, "priority": { "key": "high" }, "project": null }` — never flat `statusId`/`companyId` (transitional, will be rejected). `null` on a group clears the link. GET query/filter keys stay flat (`statusId=9`). Named tools (`create_request`, `update_request`, `change_request_status`, `bulk_update_requests`, `create_project`, `update_project`, `create_release_list`, `add_release_item`, `log_effort`, `update_effort_entry`, `request_approval`) take flat args and convert for you.
|
|
166
167
|
|
|
167
168
|
## Discovery
|
|
168
169
|
|
|
@@ -200,6 +201,62 @@ So the flow is: **user intent → `list_capabilities` (module) → `describe_mod
|
|
|
200
201
|
|
|
201
202
|
Each maps a P0 user verb to a tool/endpoint, with the opinionated defaults from the catalog. Resolve ids first, act, then summarize in the user's language.
|
|
202
203
|
|
|
204
|
+
### Aynı iş üzerinde çakışmamak (agent koordinasyonu) — `list_active_work_sessions` → mesajlaşma
|
|
205
|
+
|
|
206
|
+
**Bir talebi ya da görev kartını düzenlemeden ÖNCE bu akışı işlet.** İki agentin habersiz aynı dosyada çalışması, çakışan değişiklik ve tekrar eden iş üretir; bu araçlar tam olarak onu önlemek için var.
|
|
207
|
+
|
|
208
|
+
1. **Bak.** `list_active_work_sessions` — o kayıtta çalışan var mı? Her satır kişiyi, hedefi (talep ya da kart), başlangıcı, son canlılık sinyalini ve kişinin bıraktığı **serbest notu** taşır. En değerli alan nottur: "src/models/foo.js içindeyim" gibi. Karar vermeden önce onu oku. Bir oturum durdurulana ya da on beş dakika sessiz kalana kadar listede durur, yani listelenen oturum canlı bir iddiadır.
|
|
209
|
+
2. **Konuş.** Çakışma varsa `send_work_session_message(sessionId, body)` ile o kişiye yaz. İşe yarayan cümleler somut olanlardır: "src/models/foo.js bende, controller sende", "1042 migration'ını sen indirene kadar bekliyorum", "API sözleşmem `POST /api/x -> {id}`, buna göre devam edebilirsin".
|
|
210
|
+
3. **Başla.** `start_work_session(requestId | cardId, description)` ile kendi oturumunu aç. Hedef olarak **tam biri** verilir; ikisi birden ya da hiçbiri `EXACTLY_ONE_TARGET` ile reddedilir. `cardId` kartın **genel** kimliğidir (`get_request_board` içindeki `card.id`), pano içindeki "#3" sırası değildir. Nota niyet değil **yer** yaz: "src/models/request/requestCrudModel.js, create yolu" cümlesi "talep üzerinde çalışıyorum" cümlesinden çok daha işe yarar; çünkü senden sonra gelen agent kararını o nota bakarak verir.
|
|
211
|
+
4. **Çalışırken sinyal ver.** `send_work_session_heartbeat(sessionId)`. On beş dakika sessiz kalan oturum otomatik kapanır ve sen hâlâ dosyadayken iddian düşer. MCP çağrıları kendiliğinden sinyal sayılmaz: uzun bir derleme, test koşusu ya da büyük bir refactor sırasında adımlar arasında bunu çağır. Otomatik kapanışta bitiş zamanı **son sinyal anı** yazılır, kapanma anı değil; yani terk edilmiş oturum kimsenin eforunu şişirmez.
|
|
212
|
+
5. **Notunu güncel tut.** Yer değiştirdiysen `update_work_session_note(sessionId, description)`. Model katmanını alıp controller'a geçtiysen eski not, senden kaçınmaya çalışan agenti tam da girdiğin dosyaya yollar. `description: null` notu tamamen siler, oturum açık kalır.
|
|
213
|
+
6. **Bitince bırak.** `end_work_session(sessionId)`. "Bitti" demek görevi bitirmek değil, **oturmayı** bitirmektir: bir saatliğine ara vermek de bırakmaktır. Açık kalan oturum göstergeyi yalancı yapar ve sıradaki agenti boşuna bekletir. Gövde göndermene gerek yok; `endedAt` yalnız geçmişte olmuş bir durmayı kaydetmek için vardır.
|
|
214
|
+
|
|
215
|
+
**Kendi oturumlarını bulmak.** `list_my_work_sessions` yalnız _senin_ açık oturumlarını verir; `sessionId`'yi kaybettiysen ya da daha önce kapatmayı unuttuğun bir iddia olup olmadığını merak ediyorsan buraya bak. Başkalarının canlı iddiaları için `list_active_work_sessions`, bir kaydın geçmişi için `list_work_session_history(requestId | cardId)` kullanılır; geçmiş yeniden eskiye sıralıdır, bu yüzden imleç **geriye** gider (`beforeId`).
|
|
216
|
+
|
|
217
|
+
**Birini bulmak durmak demek değildir.** Aynı talepte farklı dosyalarda iki agent olması normaldir. Kabul edilmeyen şey, haber vermeden başlamaktır.
|
|
218
|
+
|
|
219
|
+
**Hangi araç ne zaman.** Mesaj kişinin _o anki oturumuna_ aitse `send_work_session_message`; kaydın _kendisine_ ait kalıcı bir not ise `send_room_message`. "Şu an o dosyadayım" oturuma aittir; "migration numaralandırması 1042'den devam ediyor" odaya aittir. Oda mesajı oturumdan uzun yaşar ve sonra gelen okur.
|
|
220
|
+
|
|
221
|
+
**Odaya "katılma" diye bir adım yoktur.** Oda abone olunan bir kanal değil, kaydın kendisidir. Erişim kapsamın o projeyi, talebi ya da kartı görmene izin veriyorsa odasını da okur ve yazarsın: oluşturulacak bir şey, katılma çağrısı, üyelik kaydı yoktur. Kapsam dışı bir oda boş küme değil doğrudan ret döner; yani ret "senin değil" demektir, "yok" demek değil.
|
|
222
|
+
|
|
223
|
+
**Hangi ekran hangi oda.** İnsan senin mesajını kaydın kendi ekranında görür, o yüzden odayı konuşmanın ait olduğu yere göre seç:
|
|
224
|
+
|
|
225
|
+
| roomType | roomId | Kullanicinin gordugu yer |
|
|
226
|
+
| --------- | -------------------- | ------------------------------------------------------------------------ |
|
|
227
|
+
| `request` | talep numarasi | Talep detay ekranindaki sohbet |
|
|
228
|
+
| `card` | kartin GENEL kimligi | Gorev kartinin sohbeti (`card.id`, pano ici "#3" degil) |
|
|
229
|
+
| `project` | proje kimligi | Proje sohbeti; Gantt ve k&k chart proje dugumune tiklandiginda bunu acar |
|
|
230
|
+
|
|
231
|
+
Gantt ve k&k chart üzerinde bir düğüm proje, talep ya da kart olabilir. Mesajı **düğümün temsil ettiği şeyin** odasına gönder: tek bir talebi ilgilendiren not, okuyucu grafiğe bakıyor diye proje odasına ait olmaz.
|
|
232
|
+
|
|
233
|
+
**Kanal nasıl çalışır (koda bakamadığın için burada anlatılıyor).**
|
|
234
|
+
|
|
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
|
+
|
|
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.
|
|
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
|
+
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
|
+
|
|
241
|
+
**Yeni mesajı nasıl alırsın: iki yol var, ikisini birden yapma.**
|
|
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.
|
|
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.
|
|
247
|
+
|
|
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?**
|
|
249
|
+
|
|
250
|
+
**İ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
|
+
|
|
252
|
+
**Hiçbir yolda mesaj kaybolmaz.** Olaylar `read_messages` ile aynı biçimi taşır, ayrı bir çözümleme gerekmez. Akış koparsa `read_messages(afterId = gördüğün son id)` ile tam kaldığın yerden devam edersin. Canlılık sinyalleri taşıma katmanına aittir ve olay olarak görünmez; yani boş sonuç gerçekten "kimse bir şey söylemedi" demektir.
|
|
253
|
+
|
|
254
|
+
**Oturum konuşması oturumla biter.** Yarın yeniden çalışmaya başlamak **yeni** bir konuşma açar, dünkü satırlar geri gelmez. Oda konuşması ise kalıcıdır ve her oturumdan uzun yaşar. Kalıcı olması gereken not odaya yazılır.
|
|
255
|
+
|
|
256
|
+
**Yetkiler ayrıdır.** Görmek `work-session.view-active`, yazmak `collaboration.message` ister. Koordinasyonu izleyip katılmayan bir rol meşru bir yapılandırmadır; yazma izni yoksa uç boş küme değil doğrudan yetki hatası döner ve hangi iznin gerektiğini söyler.
|
|
257
|
+
|
|
258
|
+
**Kapanmış oturuma yazılmaz.** `WORK_SESSION_ENDED` (409) tekrar denenecek bir hata değildir: kişi çalışmayı bırakmıştır. Ya kaydı oku ya kendi oturumunu aç.
|
|
259
|
+
|
|
203
260
|
### Talep aç (open a request) — `create_request` → `POST /api/requests`
|
|
204
261
|
|
|
205
262
|
1. Need `title` (min 5 chars) — if missing or too short, ask once. Never invent it.
|
|
@@ -209,7 +266,7 @@ Each maps a P0 user verb to a tool/endpoint, with the opinionated defaults from
|
|
|
209
266
|
|
|
210
267
|
### Üzerimdeki işler / talepleri listele (what's on my plate) — `list_requests` → `GET /api/requests`
|
|
211
268
|
|
|
212
|
-
- "taleplerim / on me" → `assignedTo=<my userId>`. "açık/open" → `openOnly:true`. "geciken/overdue" → `overdue:true` (deadline geçmiş + non-final auto-composed) veya `slaBreached:true` (SLA ihlali). "SLA durumu" → `slaStatus:['breached','active']`. **"dosyalı talepler / eki olan" → `hasAttachments:true`** (top-level talep eki). Deadline'a göre → `deadlineFrom`/`deadlineTo` (YYYY-MM-DD) ya da `deadlineWithinDays:N` (önümüzdeki N gün). **`overdue` ile `deadlineWithinDays` birlikte kullanılmaz** — ikisi aynı arka uç parametresine yazar, birlikte verilirse `overdue` kazanır ve diğeri sessizce yok sayılır (zaten ters soruları sorarlar: geçmiş vs. yaklaşan). Search kapsamını genişlet: `searchIn:['assignee','creator','comments',...]`. **Response boyutu:** `fields` varsayılan `'summary'` (satır başına ~1KB); `'detail'` (description/effort/counts eklenir) veya `'full'` (customFields/
|
|
269
|
+
- "taleplerim / on me" → `assignedTo=<my userId>`. "açık/open" → `openOnly:true`. "geciken/overdue" → `overdue:true` (deadline geçmiş + non-final auto-composed) veya `slaBreached:true` (SLA ihlali). "SLA durumu" → `slaStatus:['breached','active']`. **"dosyalı talepler / eki olan" → `hasAttachments:true`** (top-level talep eki). Deadline'a göre → `deadlineFrom`/`deadlineTo` (YYYY-MM-DD) ya da `deadlineWithinDays:N` (önümüzdeki N gün). **`overdue` ile `deadlineWithinDays` birlikte kullanılmaz** — ikisi aynı arka uç parametresine yazar, birlikte verilirse `overdue` kazanır ve diğeri sessizce yok sayılır (zaten ters soruları sorarlar: geçmiş vs. yaklaşan). Search kapsamını genişlet: `searchIn:['assignee','creator','comments',...]`. **Response boyutu:** `fields` varsayılan `'summary'` (satır başına ~1KB); `'detail'` (description/effort/counts eklenir) veya `'full'` (customFields/stakeholders dahil, çok büyük). Default `limit=20-50`, sort `created_at desc`. Yanıt zarfı: `data.items[]` + `data.pagination{total, limit, offset, page, totalPages}` — dizinin anahtarı `items`'tır ve ikisi de `data` altındadır (canlı ölçüldü 2026-09-05). Summarize as a short list (numara + başlık + durum), not raw rows.
|
|
213
270
|
|
|
214
271
|
### Talep detayı (show a request) — `get_request` → `GET /api/requests/:requestNumber`
|
|
215
272
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "systa-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.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
|
@@ -199,6 +199,19 @@ const logErr = (message) => process.stderr.write(`[${SERVER_NAME}] ${message}\n`
|
|
|
199
199
|
* @param {{query?:Object, body?:*}} [opts]
|
|
200
200
|
* @returns {Promise<{status:number, ok:boolean, data:*}>}
|
|
201
201
|
*/
|
|
202
|
+
/**
|
|
203
|
+
* Nested grup atfi (`{id, name}`): API'nin dondurdugu grubu aynen gecirir,
|
|
204
|
+
* yoksa `null`. MCP araclari ayni kavrami TEK sekilde ilan eder (Faz 33):
|
|
205
|
+
* `list_users` API satirini oldugu gibi verir, `resolve_user_by_name` de bu
|
|
206
|
+
* yardimci ile ayni grubu verir.
|
|
207
|
+
*/
|
|
208
|
+
function kullaniciGrubu(grup) {
|
|
209
|
+
if (!grup || typeof grup !== 'object') {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
return { id: grup.id ?? null, name: grup.name ?? null };
|
|
213
|
+
}
|
|
214
|
+
|
|
202
215
|
const systaFetch = async (method, path, opts = {}) => {
|
|
203
216
|
if (!BASE_URL) {
|
|
204
217
|
throw new Error('SYSTA_API_BASE_URL is not configured');
|
|
@@ -383,6 +396,125 @@ const systaFetchBinary = async (apiPath) => {
|
|
|
383
396
|
};
|
|
384
397
|
};
|
|
385
398
|
|
|
399
|
+
/**
|
|
400
|
+
* SSE akisini SINIRLI bir sure dinler ve toplanan olaylari dondurur.
|
|
401
|
+
*
|
|
402
|
+
* MCP araclari istek/yanit calisir; sonsuz bir akis dondurulemez. Bu yuzden
|
|
403
|
+
* dinleme "su kadar saniye dinle, ne geldiyse ver" seklindedir. Agent bir isi
|
|
404
|
+
* beklerken bunu cagirir, dondugunde ya olaylari alir ya da bos donerek "bu
|
|
405
|
+
* surede bir sey olmadi" bilgisini alir.
|
|
406
|
+
*
|
|
407
|
+
* SIFIR BAGIMLILIK: Node'un yerlesik fetch'i yanit govdesini akitir, EventSource
|
|
408
|
+
* kutuphanesi gerekmez. Zaten SSE'nin socket yerine secilme sebebi de buydu.
|
|
409
|
+
*
|
|
410
|
+
* Kalp atisi satirlari (`:` ile baslayan yorumlar) olay DEGILDIR ve atlanir;
|
|
411
|
+
* baglantinin canli oldugunu gosterirler.
|
|
412
|
+
*/
|
|
413
|
+
/**
|
|
414
|
+
* Tek bir SSE kaydini (bos satirla ayrilmis blok) olaya cevirir.
|
|
415
|
+
*
|
|
416
|
+
* Kalp atisi satirlari (`:` ile baslayan yorumlar) olay DEGILDIR ve atlanir;
|
|
417
|
+
* baglantinin canli oldugunu gosterirler. `data:` satiri yoksa kayit bir olay
|
|
418
|
+
* tasimiyordur ve `null` doner.
|
|
419
|
+
*/
|
|
420
|
+
const parseSseRecord = (raw) => {
|
|
421
|
+
let eventName = 'message';
|
|
422
|
+
const dataLines = [];
|
|
423
|
+
for (const line of raw.split('\n')) {
|
|
424
|
+
if (line.startsWith(':')) {
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (line.startsWith('event:')) {
|
|
428
|
+
eventName = line.slice(6).trim();
|
|
429
|
+
} else if (line.startsWith('data:')) {
|
|
430
|
+
dataLines.push(line.slice(5).trim());
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (dataLines.length === 0) {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
const text = dataLines.join('\n');
|
|
437
|
+
let payload;
|
|
438
|
+
try {
|
|
439
|
+
payload = JSON.parse(text);
|
|
440
|
+
} catch {
|
|
441
|
+
payload = text;
|
|
442
|
+
}
|
|
443
|
+
return { event: eventName, data: payload };
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* SSE akisini SINIRLI bir sure dinler ve toplanan olaylari dondurur.
|
|
448
|
+
*
|
|
449
|
+
* MCP araclari istek/yanit calisir; sonsuz bir akis dondurulemez. Bu yuzden
|
|
450
|
+
* dinleme "su kadar saniye dinle, ne geldiyse ver" seklindedir. Agent bir isi
|
|
451
|
+
* beklerken bunu cagirir; dondugunde ya olaylari alir ya da bos donerek "bu
|
|
452
|
+
* surede bir sey olmadi" bilgisini alir.
|
|
453
|
+
*
|
|
454
|
+
* SIFIR BAGIMLILIK: Node'un yerlesik fetch'i yanit govdesini akitir, EventSource
|
|
455
|
+
* kutuphanesi gerekmez. Zaten SSE'nin soket yerine secilme sebebi de buydu.
|
|
456
|
+
*/
|
|
457
|
+
const systaStream = async (apiPath, { seconds = 30, maxEvents = 50 } = {}) => {
|
|
458
|
+
if (!BASE_URL) {
|
|
459
|
+
throw new Error('SYSTA_API_BASE_URL is not configured');
|
|
460
|
+
}
|
|
461
|
+
if (!API_KEY) {
|
|
462
|
+
throw new Error('SYSTA_API_KEY is not configured');
|
|
463
|
+
}
|
|
464
|
+
const headers = { Authorization: `Bearer ${API_KEY}`, Accept: 'text/event-stream' };
|
|
465
|
+
if (VENDOR_CODE) {
|
|
466
|
+
headers['X-Vendor-Code'] = VENDOR_CODE;
|
|
467
|
+
}
|
|
468
|
+
const controller = new AbortController();
|
|
469
|
+
const timer = setTimeout(() => controller.abort(), Math.max(1, seconds) * 1000);
|
|
470
|
+
const events = [];
|
|
471
|
+
try {
|
|
472
|
+
const response = await fetch(new URL(BASE_URL + apiPath), {
|
|
473
|
+
method: 'GET',
|
|
474
|
+
headers,
|
|
475
|
+
signal: controller.signal,
|
|
476
|
+
});
|
|
477
|
+
if (!response.ok) {
|
|
478
|
+
const text = await response.text();
|
|
479
|
+
let data;
|
|
480
|
+
try {
|
|
481
|
+
data = text ? JSON.parse(text) : null;
|
|
482
|
+
} catch {
|
|
483
|
+
data = text;
|
|
484
|
+
}
|
|
485
|
+
return { status: response.status, ok: false, data };
|
|
486
|
+
}
|
|
487
|
+
const decoder = new TextDecoder();
|
|
488
|
+
let buffer = '';
|
|
489
|
+
for await (const chunk of response.body) {
|
|
490
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
491
|
+
// SSE kayitlari BOS SATIRLA ayrilir; yarim kalan son parca tamponda kalir.
|
|
492
|
+
let index = buffer.indexOf('\n\n');
|
|
493
|
+
while (index !== -1) {
|
|
494
|
+
const event = parseSseRecord(buffer.slice(0, index));
|
|
495
|
+
buffer = buffer.slice(index + 2);
|
|
496
|
+
if (event !== null) {
|
|
497
|
+
events.push(event);
|
|
498
|
+
}
|
|
499
|
+
if (events.length >= maxEvents) {
|
|
500
|
+
controller.abort();
|
|
501
|
+
return { status: 200, ok: true, data: { events, stoppedBy: 'maxEvents' } };
|
|
502
|
+
}
|
|
503
|
+
index = buffer.indexOf('\n\n');
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return { status: 200, ok: true, data: { events, stoppedBy: 'streamEnded' } };
|
|
507
|
+
} catch (error) {
|
|
508
|
+
// Sure dolunca akisi BIZ kesiyoruz; bu bir hata degil, beklenen son.
|
|
509
|
+
if (error.name === 'AbortError') {
|
|
510
|
+
return { status: 200, ok: true, data: { events, stoppedBy: 'timeout' } };
|
|
511
|
+
}
|
|
512
|
+
throw error;
|
|
513
|
+
} finally {
|
|
514
|
+
clearTimeout(timer);
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
|
|
386
518
|
/** Multipart yukleme — native FormData/Blob (Node 18+), alan adi backend'le ayni: 'file'. */
|
|
387
519
|
const systaUploadMultipart = async (apiPath, method, filePaths) => {
|
|
388
520
|
if (!BASE_URL) {
|
|
@@ -434,6 +566,136 @@ const capabilityProjectionPath = (projection, filters = {}) => {
|
|
|
434
566
|
return `/api-keys/me/capabilities?${query.toString()}`;
|
|
435
567
|
};
|
|
436
568
|
|
|
569
|
+
/**
|
|
570
|
+
* ═══ NESTED YAZMA GOVDESI (karar 6.2 / Faz 21 / BF-92) ═══
|
|
571
|
+
*
|
|
572
|
+
* API sozlesmesi okumada da yazmada da nested'dir: `PUT /requests/:n` govdesi
|
|
573
|
+
* `{ status: { id: 9 } }` tasir, `{ statusId: 9 }` degil. Arac girdileri (AI
|
|
574
|
+
* ajaninin verdigi args) DUZ kalir — `statusId`, `companyId` ajan icin dogal
|
|
575
|
+
* anahtarlardir ve `prepare_create_request` bu adlarla id dondurur; donusum
|
|
576
|
+
* yalniz tel govdesine yazilirken burada yapilir. Duz kabul sunucuda
|
|
577
|
+
* KAPANACAKTIR; bu yardimci olmadan gonderilen govde o zaman 400 alir.
|
|
578
|
+
*
|
|
579
|
+
* Bu paket bagimsiz yayinlanir (npm systa-mcp), arka uc `fieldGroupSpec`i
|
|
580
|
+
* import edemez; harita KOPYADIR ve `tests/mcp-server/server.test.js`
|
|
581
|
+
* spec-sync testiyle arka uca kilitlidir.
|
|
582
|
+
*/
|
|
583
|
+
// Yalniz MCP'nin yazdigi iki varligin (talep, proje) gruplari; kullanici/takim
|
|
584
|
+
// araclari eklenirse harita ve YAZMA_GRUPLARI birlikte buyur (spec-sync testi).
|
|
585
|
+
const YAZMA_YOLLARI = Object.freeze({
|
|
586
|
+
statusId: ['status', 'id'],
|
|
587
|
+
// Onay kaydinin gidecegi/geldigi durum atiflari (Faz 29 okuma sozlesmesi);
|
|
588
|
+
// FE `nestedWriteBody` ile ayni yollar.
|
|
589
|
+
pendingStatusId: ['pendingStatus', 'id'],
|
|
590
|
+
previousStatusId: ['previousStatus', 'id'],
|
|
591
|
+
categoryId: ['category', 'id'],
|
|
592
|
+
priority: ['priority', 'key'],
|
|
593
|
+
companyId: ['company', 'id'],
|
|
594
|
+
departmentId: ['department', 'id'],
|
|
595
|
+
organizationId: ['organization', 'id'],
|
|
596
|
+
subOrganizationId: ['subOrganization', 'id'],
|
|
597
|
+
projectId: ['project', 'id'],
|
|
598
|
+
parentProjectId: ['parentProject', 'id'],
|
|
599
|
+
mainProjectId: ['mainProject', 'id'],
|
|
600
|
+
formId: ['form', 'id'],
|
|
601
|
+
formVersion: ['form', 'version'],
|
|
602
|
+
addToNextReleaseList: ['release', 'addToNext'],
|
|
603
|
+
targetReleaseListId: ['release', 'targetListId'],
|
|
604
|
+
// NOT: `slaId` yazma yolu YOK. Arka uc `sla` grubunun kimligi `definitionId`
|
|
605
|
+
// (`slaDefinitionId`); `slaId` yalnizca ES ANLAMLI eski addir. Harita
|
|
606
|
+
// `slaId -> sla.id` ilan ediyordu: hicbir arac bu argumani kabul etmiyordu
|
|
607
|
+
// (olu yol) ve kabul etseydi arka ucun tanimadigi bir grup uretecekti.
|
|
608
|
+
// Spec-sync kapisi bunu yakaliyordu (HEAD'de KIRMIZI, 2026-09-05).
|
|
609
|
+
// Faz 22 — surum, efor, SLA tanimi, onay
|
|
610
|
+
requestId: ['request', 'id'],
|
|
611
|
+
activityTypeId: ['activityType', 'id'],
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
/** Varlik basina nested yazilan gruplar — arka uc ENTITY_GROUPS ile ayni. */
|
|
615
|
+
const YAZMA_GRUPLARI = Object.freeze({
|
|
616
|
+
// SIRA ve UYELIK arka uctaki `ENTITY_GROUPS.request_entity` ile BIREBIR
|
|
617
|
+
// aynidir (spec-sync kapisi). `team` buradaydi ama talep yazma sozlesmesinde
|
|
618
|
+
// YOKTU: hicbir arac `teamId` argumani almiyordu ve alsaydi arka ucun
|
|
619
|
+
// tanimadigi bir grup uretecekti (olu yol, 2026-09-05). `subOrganization`
|
|
620
|
+
// (mig 980) ise sozlesmede VARDI, haritada YOKTU.
|
|
621
|
+
request: [
|
|
622
|
+
'status',
|
|
623
|
+
'category',
|
|
624
|
+
'priority',
|
|
625
|
+
'company',
|
|
626
|
+
'department',
|
|
627
|
+
'organization',
|
|
628
|
+
'subOrganization',
|
|
629
|
+
'project',
|
|
630
|
+
'parentProject',
|
|
631
|
+
'mainProject',
|
|
632
|
+
'form',
|
|
633
|
+
'release',
|
|
634
|
+
'sla',
|
|
635
|
+
],
|
|
636
|
+
project: [
|
|
637
|
+
'company',
|
|
638
|
+
'department',
|
|
639
|
+
'organization',
|
|
640
|
+
'subOrganization',
|
|
641
|
+
'parentProject',
|
|
642
|
+
'mainProject',
|
|
643
|
+
],
|
|
644
|
+
// Faz 22 — arac yazan aileler (okuma sozlesmesi nested olanlar)
|
|
645
|
+
release: ['company', 'category', 'department', 'organization', 'status', 'request'],
|
|
646
|
+
effort: ['category', 'project', 'request', 'activityType'],
|
|
647
|
+
slaDefinition: ['form', 'company', 'department', 'organization', 'subOrganization'],
|
|
648
|
+
approval: [
|
|
649
|
+
'request',
|
|
650
|
+
'status',
|
|
651
|
+
'category',
|
|
652
|
+
'priority',
|
|
653
|
+
'company',
|
|
654
|
+
'department',
|
|
655
|
+
'organization',
|
|
656
|
+
'subOrganization',
|
|
657
|
+
'project',
|
|
658
|
+
// Onay kaydinin bekleyen/onceki durumu (Faz 29): okuma sozlesmesinde grup,
|
|
659
|
+
// yazma haritasinda da ayni kapsam.
|
|
660
|
+
'pendingStatus',
|
|
661
|
+
'previousStatus',
|
|
662
|
+
],
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
const COKLU_ALANLI_GRUPLAR = new Set(
|
|
666
|
+
Object.values(YAZMA_YOLLARI)
|
|
667
|
+
.map(([grup]) => grup)
|
|
668
|
+
.filter((grup, i, dizi) => dizi.indexOf(grup) !== i),
|
|
669
|
+
);
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Duz anahtarli govdeyi varligin kapsamindaki gruplar icin nested'a cevirir.
|
|
673
|
+
* `null` = baglantiyi kaldir (tek alanli grupta grup null), `undefined` girmez.
|
|
674
|
+
* Kapsam disi anahtarlar (oz alanlar, `assignedTo`, `title`) aynen gecer.
|
|
675
|
+
*/
|
|
676
|
+
const toNestedWriteBody = (varlik, duz) => {
|
|
677
|
+
const kapsam = YAZMA_GRUPLARI[varlik] || [];
|
|
678
|
+
const sonuc = {};
|
|
679
|
+
const gruplar = {};
|
|
680
|
+
for (const [anahtar, deger] of Object.entries(duz || {})) {
|
|
681
|
+
const yol = YAZMA_YOLLARI[anahtar];
|
|
682
|
+
if (!yol || !kapsam.includes(yol[0])) {
|
|
683
|
+
sonuc[anahtar] = deger;
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
if (deger === undefined) {
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
const [grup, alan] = yol;
|
|
690
|
+
gruplar[grup] = { ...(gruplar[grup] || {}), [alan]: deger };
|
|
691
|
+
}
|
|
692
|
+
for (const [grup, nesne] of Object.entries(gruplar)) {
|
|
693
|
+
const hepsiNull = Object.values(nesne).every((v) => v === null);
|
|
694
|
+
sonuc[grup] = hepsiNull && !COKLU_ALANLI_GRUPLAR.has(grup) ? null : nesne;
|
|
695
|
+
}
|
|
696
|
+
return sonuc;
|
|
697
|
+
};
|
|
698
|
+
|
|
437
699
|
const RESPONSE_TRANSPORTS = new Set([
|
|
438
700
|
'json',
|
|
439
701
|
'binary',
|
|
@@ -873,7 +1135,11 @@ const resolveStatusIdByKeyForRequest = async (requestNumber, statusKey) => {
|
|
|
873
1135
|
() => null,
|
|
874
1136
|
);
|
|
875
1137
|
const reqData = reqRes?.data?.data?.request ?? reqRes?.data?.data ?? null;
|
|
876
|
-
|
|
1138
|
+
// Talep detayi `form: {id, key, name, version}` grubunu doner (Faz 3+);
|
|
1139
|
+
// duz `formId` artik YOK. Eski okuma her zaman null donuyor ve durumlar
|
|
1140
|
+
// form suzgeci OLMADAN cekiliyordu -> yanlis durum kumesi (BF-55).
|
|
1141
|
+
const formGrupId = reqData?.form?.id;
|
|
1142
|
+
const reqFormId = typeof formGrupId === 'number' ? formGrupId : null;
|
|
877
1143
|
const statusesRes = await systaFetch('GET', '/statuses', {
|
|
878
1144
|
query: reqFormId != null ? { formId: reqFormId } : undefined,
|
|
879
1145
|
});
|
|
@@ -1094,7 +1360,7 @@ const MODULE_GATE_NOTE =
|
|
|
1094
1360
|
const TOOLS = [
|
|
1095
1361
|
{
|
|
1096
1362
|
name: 'list_requests',
|
|
1097
|
-
description: `List/search SysTa requests (talepler) with pagination and rich filters: status, assignee, company, category, department, priority, free-text, OVERDUE / SLA / DEADLINE. Common intents: 'uzerimdeki acik isler / my open work' => assignedTo=<me> + openOnly:true (excludes final statuses). 'geciken isler / overdue' => overdue:true (deadline gecmis + non-final) OR slaBreached:true (SLA ihlali). 'bu ay kapatilan' => finalOnly:true + completedFrom/completedTo (NOT YET SUPPORTED — use systa_api_call). search covers title/requestNumber/description/externalRef by default (NOT assignee/creator); use searchIn to expand. Multiple statuses: statusId can be an array [1,3,5]. Returns only requests the API key scope permits. Response envelope: data.
|
|
1363
|
+
description: `List/search SysTa requests (talepler) with pagination and rich filters: status, assignee, company, category, department, priority, free-text, OVERDUE / SLA / DEADLINE. Common intents: 'uzerimdeki acik isler / my open work' => assignedTo=<me> + openOnly:true (excludes final statuses). 'geciken isler / overdue' => overdue:true (deadline gecmis + non-final) OR slaBreached:true (SLA ihlali). 'bu ay kapatilan' => finalOnly:true + completedFrom/completedTo (NOT YET SUPPORTED — use systa_api_call). search covers title/requestNumber/description/externalRef by default (NOT assignee/creator); use searchIn to expand. Multiple statuses: statusId can be an array [1,3,5]. Returns only requests the API key scope permits. Response envelope: data.items[] + data.pagination{total,limit,offset,page,totalPages} — the array key is 'items' and both live under 'data'. Measured live 2026-09-05 against GET /api/requests: data roots are exactly ['items','pagination']. RESPONSE SIZE: fields controls how much each row carries -- default 'summary' returns lightweight rows (~1KB each: id, title, status{id,name,color}, priority{key,name,color}, company{...}, department{...}, assignedTo, deadline, isOverdue, sla, commentCount, fileCount). NOTE: identity fields are NESTED GROUPS in responses (status.name, priority.key, company.id) — but QUERY/FILTER keys stay FLAT (statusId, companyId, statusName in Query Builder). Use fields:'detail' when you need description/effort/counts, or fields:'full' for everything incl. customFields/stakeholders (LARGE — S-A2 kanit: 44 talep tam moduyla ~300KB).${MASKING_NOTE}${EMPTY_SCOPE_NOTE}`,
|
|
1098
1364
|
inputSchema: {
|
|
1099
1365
|
type: 'object',
|
|
1100
1366
|
additionalProperties: false,
|
|
@@ -1199,7 +1465,7 @@ const TOOLS = [
|
|
|
1199
1465
|
type: 'string',
|
|
1200
1466
|
enum: ['summary', 'detail', 'full'],
|
|
1201
1467
|
description:
|
|
1202
|
-
"Response projection profile: 'summary' (default, lightweight ~1KB/row: id/
|
|
1468
|
+
"Response projection profile: 'summary' (default, lightweight ~1KB/row: id/title/status{id,name,color}/priority{key,name,color}/company/department/assignedTo/deadline/isOverdue/sla/commentCount/fileCount), 'detail' (adds description/effort/all counts), 'full' (everything incl. customFields/stakeholders — LARGE). Identity fields are NESTED GROUPS; filter keys stay FLAT.",
|
|
1203
1469
|
},
|
|
1204
1470
|
},
|
|
1205
1471
|
},
|
|
@@ -1211,7 +1477,7 @@ const TOOLS = [
|
|
|
1211
1477
|
{
|
|
1212
1478
|
name: 'list_users',
|
|
1213
1479
|
description:
|
|
1214
|
-
'List/search SysTa users (kullanicilar) with pagination and filters (search, companyId, departmentId). Wraps GET /api/users?search=... Prefer resolve_user_by_name when you have a single name and want the id -- this tool is for browsing/multiple hits. Returns {id, fullName, email,
|
|
1480
|
+
'List/search SysTa users (kullanicilar) with pagination and filters (search, companyId, departmentId). Wraps GET /api/users?search=... Prefer resolve_user_by_name when you have a single name and want the id -- this tool is for browsing/multiple hits. Returns {id, fullName, email, company{id,code,name,type}, department{id,code,name}} per user — identity fields are NESTED GROUPS (company.name, NOT companyName). Filter keys stay FLAT (companyId, departmentId).',
|
|
1215
1481
|
inputSchema: {
|
|
1216
1482
|
type: 'object',
|
|
1217
1483
|
additionalProperties: false,
|
|
@@ -1245,7 +1511,7 @@ const TOOLS = [
|
|
|
1245
1511
|
{
|
|
1246
1512
|
name: 'resolve_user_by_name',
|
|
1247
1513
|
description:
|
|
1248
|
-
"Resolve a person's name (isim -> userId) for assignments and filters. Returns {userId, fullName, ambiguity} when a UNIQUE match is found; when MULTIPLE users match (e.g. 3 'Ahmet' users) returns {ambiguity:true, candidates:[{id, fullName,
|
|
1514
|
+
"Resolve a person's name (isim -> userId) for assignments and filters. Returns {userId, fullName, company{id,name}, department{id,name}, ambiguity} (same nested shape as list_users) when a UNIQUE match is found; when MULTIPLE users match (e.g. 3 'Ahmet' users) returns {ambiguity:true, candidates:[{id, fullName, email, company{id,name}, department{id,name}}], hint:'ASK the user which one'} -- DO NOT auto-pick the first candidate. Use userId with assign_request / assignedTo. When 'me/bana' is meant, use list_capabilities user.id instead (no resolve needed).",
|
|
1249
1515
|
inputSchema: {
|
|
1250
1516
|
type: 'object',
|
|
1251
1517
|
additionalProperties: false,
|
|
@@ -1302,8 +1568,11 @@ const TOOLS = [
|
|
|
1302
1568
|
ambiguity: false,
|
|
1303
1569
|
userId: u.id,
|
|
1304
1570
|
fullName: u.fullName,
|
|
1305
|
-
|
|
1306
|
-
|
|
1571
|
+
// `list_users` ile AYNI sekil: API'nin nested gruplari aynen
|
|
1572
|
+
// gecirilir (Faz 33). Duz `companyName` ilan etmek ayni kavrami
|
|
1573
|
+
// iki aracta iki sekilde ogretiyordu.
|
|
1574
|
+
company: kullaniciGrubu(u.company),
|
|
1575
|
+
department: kullaniciGrubu(u.department),
|
|
1307
1576
|
},
|
|
1308
1577
|
},
|
|
1309
1578
|
};
|
|
@@ -1321,8 +1590,8 @@ const TOOLS = [
|
|
|
1321
1590
|
id: u.id,
|
|
1322
1591
|
fullName: u.fullName,
|
|
1323
1592
|
email: u.email || null,
|
|
1324
|
-
|
|
1325
|
-
|
|
1593
|
+
company: kullaniciGrubu(u.company),
|
|
1594
|
+
department: kullaniciGrubu(u.department),
|
|
1326
1595
|
})),
|
|
1327
1596
|
},
|
|
1328
1597
|
},
|
|
@@ -1367,7 +1636,11 @@ const TOOLS = [
|
|
|
1367
1636
|
},
|
|
1368
1637
|
},
|
|
1369
1638
|
handler: async (args) => {
|
|
1370
|
-
|
|
1639
|
+
// `updates` tek nesne (tum taleplere ayni degisiklik); nested gider.
|
|
1640
|
+
const body = {
|
|
1641
|
+
requestIds: args.requestIds,
|
|
1642
|
+
updates: toNestedWriteBody('request', args.updates),
|
|
1643
|
+
};
|
|
1371
1644
|
if (args.reason) {
|
|
1372
1645
|
body.reason = args.reason;
|
|
1373
1646
|
}
|
|
@@ -1605,7 +1878,7 @@ const TOOLS = [
|
|
|
1605
1878
|
},
|
|
1606
1879
|
{
|
|
1607
1880
|
name: 'create_request',
|
|
1608
|
-
description: `Create a SysTa request (talep). Requires the "request.create" scope. Returns the created request under data.request. PROACTIVE (recommended): call prepare_create_request(companyId) FIRST -- it returns the active form's required fields AND valid ids (statuses/categories/organizations) so you fill them up front and avoid the 400 roundtrip. The required set is DYNAMIC and PER-COMPANY: title/companyId/statusId are the baseline (+ at least one assignment: assignedTo OR assignedTeams), but the request form that applies to THIS companyId may require more system fields (e.g. categoryId, organizationId). This vendor may run several active request forms at once; statusId/categoryId are FORM-ISOLATED and an id from another company's form is REJECTED, so resolve them via prepare_create_request(companyId) rather than reusing ids. If you skip prepare_, on a missing field the API returns 400 VALIDATION_FAILED naming it via details[].fieldKey -- add it and retry. For fields beyond this tool, use systa_api_call POST /requests. ONEMLI (talep 166 #60): verdigin \`description\` talebin ILK YORUMU (commentNo 1) olarak materyalize edilir; ZENGIN icerik istiyorsan (tablo/kod blogu/bicimli metin) \`description\` yerine systa_api_call POST /requests ile \`descriptionJson\` alanina TipTap doc gonder (tablo dahil) -- bu da comment #1 olur. Yani tabloyu talep ACARKEN descriptionJson icinde ver; AYRICA ayri bir yorum EKLEME (yoksa cift yorum olusur). Icerigi yalnizca ayri yorumda istiyorsan description bos birak.${RULE_ENGINE_NOTE}`,
|
|
1881
|
+
description: `Create a SysTa request (talep). Requires the "request.create" scope. Returns the created request under data.request. PROACTIVE (recommended): call prepare_create_request(companyId) FIRST -- it returns the active form's required fields AND valid ids (statuses/categories/organizations) so you fill them up front and avoid the 400 roundtrip. The required set is DYNAMIC and PER-COMPANY: title/companyId/statusId are the baseline (+ at least one assignment: assignedTo OR assignedTeams), but the request form that applies to THIS companyId may require more system fields (e.g. categoryId, organizationId). This vendor may run several active request forms at once; statusId/categoryId are FORM-ISOLATED and an id from another company's form is REJECTED, so resolve them via prepare_create_request(companyId) rather than reusing ids. If you skip prepare_, on a missing field the API returns 400 VALIDATION_FAILED naming it via details[].fieldKey -- add it and retry. For fields beyond this tool, use systa_api_call POST /requests with a NESTED body ({ status:{id}, company:{id}, category:{id}, priority:{key} }) -- this tool converts its flat args for you; the raw endpoint does not. ONEMLI (talep 166 #60): verdigin \`description\` talebin ILK YORUMU (commentNo 1) olarak materyalize edilir; ZENGIN icerik istiyorsan (tablo/kod blogu/bicimli metin) \`description\` yerine systa_api_call POST /requests ile \`descriptionJson\` alanina TipTap doc gonder (tablo dahil) -- bu da comment #1 olur. Yani tabloyu talep ACARKEN descriptionJson icinde ver; AYRICA ayri bir yorum EKLEME (yoksa cift yorum olusur). Icerigi yalnizca ayri yorumda istiyorsan description bos birak.${RULE_ENGINE_NOTE}`,
|
|
1609
1882
|
inputSchema: {
|
|
1610
1883
|
type: 'object',
|
|
1611
1884
|
additionalProperties: false,
|
|
@@ -1639,6 +1912,11 @@ const TOOLS = [
|
|
|
1639
1912
|
description:
|
|
1640
1913
|
'Organization-unit id (optional; may be required by the active form; must belong to companyId)',
|
|
1641
1914
|
},
|
|
1915
|
+
subOrganizationId: {
|
|
1916
|
+
type: 'integer',
|
|
1917
|
+
description:
|
|
1918
|
+
'Sub-organization-unit id (optional; must be a DIRECT child of organizationId -- the org tree is single-level).',
|
|
1919
|
+
},
|
|
1642
1920
|
departmentId: { type: 'integer', description: 'Department id (optional)' },
|
|
1643
1921
|
projectId: { type: 'integer', description: 'Link to a project (optional)' },
|
|
1644
1922
|
priority: {
|
|
@@ -1698,7 +1976,7 @@ const TOOLS = [
|
|
|
1698
1976
|
body[key] = args[key];
|
|
1699
1977
|
}
|
|
1700
1978
|
}
|
|
1701
|
-
return systaFetch('POST', '/requests', { body });
|
|
1979
|
+
return systaFetch('POST', '/requests', { body: toNestedWriteBody('request', body) });
|
|
1702
1980
|
},
|
|
1703
1981
|
},
|
|
1704
1982
|
{
|
|
@@ -1717,6 +1995,12 @@ const TOOLS = [
|
|
|
1717
1995
|
},
|
|
1718
1996
|
handler: async (args) => {
|
|
1719
1997
|
const companyId = args.companyId;
|
|
1998
|
+
// Lookup satirlari KENDI adlarini tasir (`statusName` bir durum listesinde
|
|
1999
|
+
// durumun kendi adidir — oz-referans, DUZ kalir). Canli olculdu
|
|
2000
|
+
// (2026-09-01): /statuses `statusName`, /categories `categoryName`,
|
|
2001
|
+
// /organizations `unitName`, /departments `departmentName`. Nested
|
|
2002
|
+
// `x.status?.name` dallari OLU KODDU — bu liste atif degil varlik listesi;
|
|
2003
|
+
// shim karar 1.2 geregi kaldirildi (BF-57).
|
|
1720
2004
|
const nameOf = (x) =>
|
|
1721
2005
|
x.name ??
|
|
1722
2006
|
x.statusName ??
|
|
@@ -1905,6 +2189,380 @@ The number is the request number for "#", and the card's GLOBAL id for "##" (NOT
|
|
|
1905
2189
|
});
|
|
1906
2190
|
},
|
|
1907
2191
|
},
|
|
2192
|
+
{
|
|
2193
|
+
name: 'start_work_session',
|
|
2194
|
+
description: `Claim a record before you touch it: opens YOUR work session on one request or one task card.
|
|
2195
|
+
|
|
2196
|
+
WHY THIS EXISTS. SysTa shows a live "working on it" indicator to every human and every agent looking at that record. Without a session your edits are invisible to the people around you, and two agents silently rewrite each other's work. Starting a session is the cheapest collision prevention SysTa has.
|
|
2197
|
+
|
|
2198
|
+
THE COORDINATION LOOP, in order:
|
|
2199
|
+
1. list_active_work_sessions -> is somebody already on this record?
|
|
2200
|
+
2. if yes -> send_work_session_message and agree who takes what
|
|
2201
|
+
3. start_work_session -> claim it, with a note saying WHERE you are
|
|
2202
|
+
4. work; call send_work_session_heartbeat while you work (see below)
|
|
2203
|
+
5. end_work_session -> release the claim when you stop
|
|
2204
|
+
|
|
2205
|
+
TARGET. Give EXACTLY ONE of requestId or cardId. Both or neither is refused with EXACTLY_ONE_TARGET. cardId is the card's GLOBAL id (card.id from get_request_board), not the per-board "#3" sequence.
|
|
2206
|
+
|
|
2207
|
+
THE NOTE IS THE POINT. description is free text (max 2000 chars) and it is what other agents actually read before deciding to wait or proceed. Write the file or the area, not the intention: "src/models/request/requestCrudModel.js, create path" beats "working on request stuff".
|
|
2208
|
+
|
|
2209
|
+
LIFETIME. A session stays live until you end it or it goes silent for 15 minutes. Silence closes it automatically, and the auto-close records the LAST SIGNAL time as the end, not the moment of closing — so an abandoned session does not inflate anybody's effort.
|
|
2210
|
+
|
|
2211
|
+
Requires "work-session.manage". Reading other people's sessions is a separate, weaker privilege ("work-session.view-active").`,
|
|
2212
|
+
inputSchema: {
|
|
2213
|
+
type: 'object',
|
|
2214
|
+
additionalProperties: false,
|
|
2215
|
+
properties: {
|
|
2216
|
+
requestId: {
|
|
2217
|
+
type: 'integer',
|
|
2218
|
+
description: 'Request number. Give this OR cardId, never both.',
|
|
2219
|
+
},
|
|
2220
|
+
cardId: {
|
|
2221
|
+
type: 'integer',
|
|
2222
|
+
description: 'Card GLOBAL id from get_request_board. Give this OR requestId, never both.',
|
|
2223
|
+
},
|
|
2224
|
+
description: {
|
|
2225
|
+
type: 'string',
|
|
2226
|
+
description:
|
|
2227
|
+
'What you are doing, ideally the file or area. Max 2000 characters. Other agents read this before deciding to wait.',
|
|
2228
|
+
},
|
|
2229
|
+
},
|
|
2230
|
+
},
|
|
2231
|
+
handler: (args) => {
|
|
2232
|
+
const body = {};
|
|
2233
|
+
if (args.requestId !== undefined) {
|
|
2234
|
+
body.requestId = args.requestId;
|
|
2235
|
+
}
|
|
2236
|
+
if (args.cardId !== undefined) {
|
|
2237
|
+
body.cardId = args.cardId;
|
|
2238
|
+
}
|
|
2239
|
+
if (args.description !== undefined) {
|
|
2240
|
+
body.description = args.description;
|
|
2241
|
+
}
|
|
2242
|
+
return systaFetch('POST', '/work-sessions', { body });
|
|
2243
|
+
},
|
|
2244
|
+
},
|
|
2245
|
+
{
|
|
2246
|
+
name: 'send_work_session_heartbeat',
|
|
2247
|
+
description: `Tell SysTa you are still working. Refreshes your session's last-signal time.
|
|
2248
|
+
|
|
2249
|
+
WHEN TO CALL. A session with no signal for 15 minutes is closed automatically and your claim disappears while you are still editing. MCP calls are not a heartbeat by themselves, so on any stretch of work longer than about ten minutes — a long build, a test run, a big refactor — call this between steps. A cheap call now beats losing the claim and colliding later.
|
|
2250
|
+
|
|
2251
|
+
WHAT IT DOES NOT DO. It does not extend effort, change status or touch the record. It only says "still here".
|
|
2252
|
+
|
|
2253
|
+
sessionId comes from start_work_session or list_my_work_sessions. Ending an already-ended session is not retryable: if you get WORK_SESSION_ENDED (409) the claim is gone — start a new session instead of retrying the heartbeat.
|
|
2254
|
+
|
|
2255
|
+
Requires "work-session.manage".`,
|
|
2256
|
+
inputSchema: {
|
|
2257
|
+
type: 'object',
|
|
2258
|
+
additionalProperties: false,
|
|
2259
|
+
required: ['sessionId'],
|
|
2260
|
+
properties: {
|
|
2261
|
+
sessionId: { type: 'integer', description: 'Your open session id.' },
|
|
2262
|
+
},
|
|
2263
|
+
},
|
|
2264
|
+
handler: (args) =>
|
|
2265
|
+
systaFetch('POST', `/work-sessions/${encodeURIComponent(args.sessionId)}/heartbeat`),
|
|
2266
|
+
},
|
|
2267
|
+
{
|
|
2268
|
+
name: 'end_work_session',
|
|
2269
|
+
description: `Release your claim: closes YOUR work session on a record.
|
|
2270
|
+
|
|
2271
|
+
CALL THIS WHEN YOU STOP, not when the task is finished — pausing for an hour is also stopping. A live session means "I am in this file right now"; leaving it open while you are elsewhere makes the indicator lie and makes the next agent wait for nothing.
|
|
2272
|
+
|
|
2273
|
+
endedAt is optional and almost never needed: omit it and the server stamps the moment of the call. Pass it only to record a stop that already happened, as an ISO-8601 timestamp with offset.
|
|
2274
|
+
|
|
2275
|
+
Ending a session does NOT delete its conversation — read_messages(sessionId) still returns what was said. But a closed session accepts no new messages: send_work_session_message returns WORK_SESSION_ENDED (409).
|
|
2276
|
+
|
|
2277
|
+
Requires "work-session.manage".`,
|
|
2278
|
+
inputSchema: {
|
|
2279
|
+
type: 'object',
|
|
2280
|
+
additionalProperties: false,
|
|
2281
|
+
required: ['sessionId'],
|
|
2282
|
+
properties: {
|
|
2283
|
+
sessionId: { type: 'integer', description: 'Your open session id.' },
|
|
2284
|
+
endedAt: {
|
|
2285
|
+
type: 'string',
|
|
2286
|
+
description: 'Optional ISO-8601 timestamp with offset. Omit to stamp now.',
|
|
2287
|
+
},
|
|
2288
|
+
},
|
|
2289
|
+
},
|
|
2290
|
+
handler: (args) => {
|
|
2291
|
+
const body = {};
|
|
2292
|
+
if (args.endedAt !== undefined) {
|
|
2293
|
+
body.endedAt = args.endedAt;
|
|
2294
|
+
}
|
|
2295
|
+
return systaFetch('POST', `/work-sessions/${encodeURIComponent(args.sessionId)}/end`, {
|
|
2296
|
+
body,
|
|
2297
|
+
});
|
|
2298
|
+
},
|
|
2299
|
+
},
|
|
2300
|
+
{
|
|
2301
|
+
name: 'update_work_session_note',
|
|
2302
|
+
description: `Rewrite the note on your own open session — the text other agents read before deciding whether to wait for you.
|
|
2303
|
+
|
|
2304
|
+
WHEN TO USE. You moved. You claimed the model layer, you are now in the controller; the stale note sends the next agent into the file you just entered. Updating the note is how a long session stays honest.
|
|
2305
|
+
|
|
2306
|
+
Pass description: null to clear the note entirely (the session stays open). An empty string is not the way to clear it.
|
|
2307
|
+
|
|
2308
|
+
Requires "work-session.manage".`,
|
|
2309
|
+
inputSchema: {
|
|
2310
|
+
type: 'object',
|
|
2311
|
+
additionalProperties: false,
|
|
2312
|
+
required: ['sessionId', 'description'],
|
|
2313
|
+
properties: {
|
|
2314
|
+
sessionId: { type: 'integer', description: 'Your open session id.' },
|
|
2315
|
+
description: {
|
|
2316
|
+
type: ['string', 'null'],
|
|
2317
|
+
description: 'New note, max 2000 characters. null clears it.',
|
|
2318
|
+
},
|
|
2319
|
+
},
|
|
2320
|
+
},
|
|
2321
|
+
handler: (args) =>
|
|
2322
|
+
systaFetch('PATCH', `/work-sessions/${encodeURIComponent(args.sessionId)}`, {
|
|
2323
|
+
body: { description: args.description },
|
|
2324
|
+
}),
|
|
2325
|
+
},
|
|
2326
|
+
{
|
|
2327
|
+
name: 'list_my_work_sessions',
|
|
2328
|
+
description: `Your OWN open sessions. Use it to find a sessionId you forgot, or to check you are not holding a claim you already walked away from.
|
|
2329
|
+
|
|
2330
|
+
Typical uses: you started a session, did a long piece of work, and need the id for send_work_session_heartbeat or end_work_session; or you are starting fresh and want to close whatever you left open earlier.
|
|
2331
|
+
|
|
2332
|
+
This is the "mine" view. For everybody else's live claims use list_active_work_sessions.
|
|
2333
|
+
|
|
2334
|
+
Requires "work-session.manage".`,
|
|
2335
|
+
inputSchema: { type: 'object', additionalProperties: false, properties: {} },
|
|
2336
|
+
handler: () => systaFetch('GET', '/work-sessions/active'),
|
|
2337
|
+
},
|
|
2338
|
+
{
|
|
2339
|
+
name: 'list_work_session_history',
|
|
2340
|
+
description: `Past sessions on one record, newest first — who worked on this before, when, and what note they left.
|
|
2341
|
+
|
|
2342
|
+
Give EXACTLY ONE of requestId or cardId, same rule as start_work_session.
|
|
2343
|
+
|
|
2344
|
+
WHEN TO USE. Before picking up an unfamiliar record: the last note often says where the previous person stopped. Also for effort questions ("how much sitting did this card take") — each row carries its start and end.
|
|
2345
|
+
|
|
2346
|
+
PAGING. Results run newest to oldest, so the cursor moves BACKWARD: pass beforeId = the id of the oldest row you have, and you get the ones before it. limit defaults to 20, max 100.
|
|
2347
|
+
|
|
2348
|
+
Requires "work-session.view-active".`,
|
|
2349
|
+
inputSchema: {
|
|
2350
|
+
type: 'object',
|
|
2351
|
+
additionalProperties: false,
|
|
2352
|
+
properties: {
|
|
2353
|
+
requestId: { type: 'integer', description: 'Request number. Give this OR cardId.' },
|
|
2354
|
+
cardId: { type: 'integer', description: 'Card GLOBAL id. Give this OR requestId.' },
|
|
2355
|
+
limit: { type: 'integer', description: 'Page size, default 20, max 100.' },
|
|
2356
|
+
beforeId: {
|
|
2357
|
+
type: 'integer',
|
|
2358
|
+
description: 'Oldest session id you already have; returns the ones before it.',
|
|
2359
|
+
},
|
|
2360
|
+
},
|
|
2361
|
+
},
|
|
2362
|
+
handler: (args) =>
|
|
2363
|
+
systaFetch('GET', '/work-sessions/history', {
|
|
2364
|
+
query: {
|
|
2365
|
+
requestId: args.requestId,
|
|
2366
|
+
cardId: args.cardId,
|
|
2367
|
+
limit: args.limit,
|
|
2368
|
+
beforeId: args.beforeId,
|
|
2369
|
+
},
|
|
2370
|
+
}),
|
|
2371
|
+
},
|
|
2372
|
+
{
|
|
2373
|
+
name: 'list_active_work_sessions',
|
|
2374
|
+
description: `Who is working on what RIGHT NOW — everybody's live claims, not just yours. Call this BEFORE you start editing a request or a task card. It is step one of the coordination loop:
|
|
2375
|
+
|
|
2376
|
+
1. list_active_work_sessions -> is somebody already on this record?
|
|
2377
|
+
2. if yes -> send_work_session_message and agree who takes what
|
|
2378
|
+
3. start_work_session -> claim it, with a note saying WHERE you are
|
|
2379
|
+
4. send_work_session_heartbeat -> while you work, on anything longer than ~10 minutes
|
|
2380
|
+
5. end_work_session -> release the claim when you stop
|
|
2381
|
+
|
|
2382
|
+
Each item identifies the person, the target (request or card), when they started, their last signal, and the free-text note they left ("I am in src/models/foo.js"). That note is the most useful field for avoiding collisions; read it before deciding.
|
|
2383
|
+
|
|
2384
|
+
A session stays listed until it is stopped or goes silent for 15 minutes, so a listed session is a live claim, not history. For finished sessions on one record use list_work_session_history; for your own open claims use list_my_work_sessions.
|
|
2385
|
+
|
|
2386
|
+
FINDING SOMEBODY IS NOT A STOP SIGN. It means talk first. Two agents on the same request but different files is normal and fine — what is not fine is starting silently.
|
|
2387
|
+
|
|
2388
|
+
Requires "work-session.view-active". You only see sessions inside your access scope; sessions on records you cannot see are filtered out rather than refused.`,
|
|
2389
|
+
inputSchema: { type: 'object', additionalProperties: false, properties: {} },
|
|
2390
|
+
handler: () => systaFetch('GET', '/work-sessions/active/all'),
|
|
2391
|
+
},
|
|
2392
|
+
{
|
|
2393
|
+
name: 'send_work_session_message',
|
|
2394
|
+
description: `Write to somebody's OPEN work session — the way two agents avoid stepping on each other.
|
|
2395
|
+
|
|
2396
|
+
WHEN TO USE. You called list_active_work_sessions (or get_request_board) and found someone already working on the record you were about to touch. Do not silently start beside them: say what you need. Typical lines that actually prevent conflicts:
|
|
2397
|
+
* "I am editing src/models/foo.js — leaving the controller to you"
|
|
2398
|
+
* "Holding db/migrations/1042 until you land yours"
|
|
2399
|
+
* "My API contract is {POST /api/x -> {id}} — you can build against it"
|
|
2400
|
+
|
|
2401
|
+
Only OPEN sessions accept messages. A session that has ended returns WORK_SESSION_ENDED (409); that is not a retryable error — the person stopped working, so read the record instead, leave a lasting note with send_room_message, or claim it yourself with start_work_session.
|
|
2402
|
+
|
|
2403
|
+
WAITING FOR THE ANSWER. A session message does not notify you back. If you cannot continue without the reply, wait inside listen_room on that record's room; if you can keep working, come back later with read_messages(sessionId, afterId).
|
|
2404
|
+
|
|
2405
|
+
Reading needs "work-session.view-active"; WRITING needs "collaboration.message". Seeing coordination and joining it are separate privileges.
|
|
2406
|
+
|
|
2407
|
+
Body is plain text, at most 2000 characters. Trailing and leading spaces are trimmed; an empty body is refused.`,
|
|
2408
|
+
inputSchema: {
|
|
2409
|
+
type: 'object',
|
|
2410
|
+
additionalProperties: false,
|
|
2411
|
+
required: ['sessionId', 'body'],
|
|
2412
|
+
properties: {
|
|
2413
|
+
sessionId: {
|
|
2414
|
+
type: 'integer',
|
|
2415
|
+
description: 'Open work session id — read it from list_active_work_sessions.',
|
|
2416
|
+
},
|
|
2417
|
+
body: { type: 'string', description: 'Plain text, 1..2000 characters.' },
|
|
2418
|
+
},
|
|
2419
|
+
},
|
|
2420
|
+
handler: (args) =>
|
|
2421
|
+
systaFetch('POST', `/work-sessions/${encodeURIComponent(args.sessionId)}/messages`, {
|
|
2422
|
+
body: { body: args.body },
|
|
2423
|
+
}),
|
|
2424
|
+
},
|
|
2425
|
+
{
|
|
2426
|
+
name: 'send_room_message',
|
|
2427
|
+
description: `Write to a ROOM: a project, a request or a task card. Unlike a session message this outlives any single work session — use it when the note should stay for whoever comes next, or when nobody is working right now.
|
|
2428
|
+
|
|
2429
|
+
THERE IS NO "JOIN". A room is not a channel you subscribe to; it is the record itself. If your access scope already lets you see that project, request or card, you can read and write its room immediately. Nothing to create, nothing to join, no membership call. Out-of-scope rooms are refused outright rather than returned empty, so a refusal means "not yours", not "not there".
|
|
2430
|
+
|
|
2431
|
+
WHICH ROOM MATCHES WHICH SCREEN. A human reading your message sees it on the record's own surface, so pick the room by where the conversation belongs:
|
|
2432
|
+
* roomType "request" + the request NUMBER -> the request's chat, visible on the request detail screen
|
|
2433
|
+
* roomType "card" + the card's GLOBAL id -> the task card's chat (card.id from get_request_board, NOT the per-board "#3" sequence)
|
|
2434
|
+
* roomType "project" + the project id -> the project-level chat, which is what the Gantt view and the k&k chart open when somebody clicks the project node
|
|
2435
|
+
|
|
2436
|
+
On the Gantt and the k&k chart a node can be a project, a request or a card. Send to the room of the thing the node represents: a message about one request does not belong in the project room just because the reader is looking at a chart.
|
|
2437
|
+
|
|
2438
|
+
ROOM OR SESSION. The message is about the record -> room. The message is about somebody's current sitting -> session. "The migration numbering restarts at 1042" belongs to the room; "I am in that file right now" belongs to the session.
|
|
2439
|
+
|
|
2440
|
+
Requires "collaboration.message". Reading is a weaker privilege than writing: a role may watch coordination without joining it.
|
|
2441
|
+
|
|
2442
|
+
Body is plain text, at most 2000 characters.`,
|
|
2443
|
+
inputSchema: {
|
|
2444
|
+
type: 'object',
|
|
2445
|
+
additionalProperties: false,
|
|
2446
|
+
required: ['roomType', 'roomId', 'body'],
|
|
2447
|
+
properties: {
|
|
2448
|
+
roomType: { type: 'string', enum: ['project', 'request', 'card'] },
|
|
2449
|
+
roomId: { type: 'integer', description: 'Project id, request number, or card GLOBAL id.' },
|
|
2450
|
+
body: { type: 'string', description: 'Plain text, 1..2000 characters.' },
|
|
2451
|
+
},
|
|
2452
|
+
},
|
|
2453
|
+
handler: (args) =>
|
|
2454
|
+
systaFetch(
|
|
2455
|
+
'POST',
|
|
2456
|
+
`/rooms/${encodeURIComponent(args.roomType)}/${encodeURIComponent(args.roomId)}/messages`,
|
|
2457
|
+
{ body: { body: args.body } },
|
|
2458
|
+
),
|
|
2459
|
+
},
|
|
2460
|
+
{
|
|
2461
|
+
name: 'read_messages',
|
|
2462
|
+
description: `Read a conversation, or ask "what arrived since I last looked".
|
|
2463
|
+
|
|
2464
|
+
Give EITHER sessionId (one work session's conversation) OR roomType+roomId (a room's conversation). Giving both is refused rather than guessed. A session conversation ends with that session: starting work again tomorrow opens a NEW conversation, so yesterday's lines do not reappear. A room conversation is permanent and survives every session.
|
|
2465
|
+
|
|
2466
|
+
CURSOR. Pass afterId = the id of the last message you already read; you get only what came after. Do not use timestamps as a cursor — the API renders milliseconds while the column stores microseconds, so a timestamp cursor returns the same row twice (measured 2026-09-08). The response carries pagination.nextAfterId for the next call and pagination.hasMore.
|
|
2467
|
+
|
|
2468
|
+
HOW TO GET NEW MESSAGES — pick one, do not do both:
|
|
2469
|
+
* You are WAITING right now (you asked a question, you need the answer before you continue) -> listen_room. It blocks for a bounded time and returns what arrived. No polling loop, no wasted calls.
|
|
2470
|
+
* You are WORKING and will check back later (you just want to know what happened while you were busy) -> read_messages with afterId. One call per check-in, not a loop. Between check-ins do your actual work.
|
|
2471
|
+
|
|
2472
|
+
There is no push channel to an MCP client and no background subscription: nothing wakes you up on its own. Either you wait inside listen_room, or you come back and ask with afterId. A tight poll loop is the wrong answer to both.
|
|
2473
|
+
|
|
2474
|
+
FIRST CALL: read once with no cursor to get the recent history, remember pagination.nextAfterId, and use that from then on.`,
|
|
2475
|
+
inputSchema: {
|
|
2476
|
+
type: 'object',
|
|
2477
|
+
additionalProperties: false,
|
|
2478
|
+
properties: {
|
|
2479
|
+
sessionId: { type: 'integer', description: 'Work session id (session conversation).' },
|
|
2480
|
+
roomType: { type: 'string', enum: ['project', 'request', 'card'] },
|
|
2481
|
+
roomId: { type: 'integer', description: 'Project id, request number, or card GLOBAL id.' },
|
|
2482
|
+
afterId: {
|
|
2483
|
+
type: 'integer',
|
|
2484
|
+
description: 'Last message id you already read. Omit for the first page.',
|
|
2485
|
+
},
|
|
2486
|
+
limit: { type: 'integer', description: 'Page size, default 50, max 200.' },
|
|
2487
|
+
},
|
|
2488
|
+
},
|
|
2489
|
+
handler: (args) => {
|
|
2490
|
+
const query = { afterId: args.afterId, limit: args.limit };
|
|
2491
|
+
if (args.sessionId) {
|
|
2492
|
+
// Iki hedef birden verilirse hangisinin kastedildigi belirsizdir;
|
|
2493
|
+
// sessizce birini secmek yanlis konusmayi okutur.
|
|
2494
|
+
if (args.roomType || args.roomId) {
|
|
2495
|
+
throw new Error('Give either sessionId or roomType+roomId, not both');
|
|
2496
|
+
}
|
|
2497
|
+
return systaFetch('GET', `/work-sessions/${encodeURIComponent(args.sessionId)}/messages`, {
|
|
2498
|
+
query,
|
|
2499
|
+
});
|
|
2500
|
+
}
|
|
2501
|
+
if (!args.roomType || !args.roomId) {
|
|
2502
|
+
throw new Error('Give either sessionId, or both roomType and roomId');
|
|
2503
|
+
}
|
|
2504
|
+
return systaFetch(
|
|
2505
|
+
'GET',
|
|
2506
|
+
`/rooms/${encodeURIComponent(args.roomType)}/${encodeURIComponent(args.roomId)}/messages`,
|
|
2507
|
+
{ query },
|
|
2508
|
+
);
|
|
2509
|
+
},
|
|
2510
|
+
},
|
|
2511
|
+
{
|
|
2512
|
+
name: 'listen_room',
|
|
2513
|
+
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
|
+
|
|
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.
|
|
2516
|
+
|
|
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.
|
|
2522
|
+
|
|
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.
|
|
2524
|
+
|
|
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.
|
|
2526
|
+
|
|
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.
|
|
2528
|
+
|
|
2529
|
+
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
|
+
* 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.
|
|
2531
|
+
* The event carries the MESSAGE ITSELF — same shape as read_messages, sender and text included. You do NOT need a second API call to fetch the content after being notified. The only time you call read_messages is when the stream drops mid-wait.
|
|
2532
|
+
* You hear it even when no human is in the room. The agent channel is deliberately kept separate from the "is anybody watching" optimisation on the server, so working alone does not make you deaf.
|
|
2533
|
+
|
|
2534
|
+
NOTHING IS LOST EITHER WAY. Events carry the same message shape as read_messages, so there is nothing new to parse. If the stream drops mid-wait, resume with read_messages(afterId = last id you saw) and continue from exactly there. Heartbeats are transport-level keepalives and never appear as events, so an empty result genuinely means "nobody said anything".
|
|
2535
|
+
|
|
2536
|
+
Requires "work-session.view-active". You only reach rooms your access scope already allows.`,
|
|
2537
|
+
inputSchema: {
|
|
2538
|
+
type: 'object',
|
|
2539
|
+
additionalProperties: false,
|
|
2540
|
+
required: ['roomType', 'roomId'],
|
|
2541
|
+
properties: {
|
|
2542
|
+
roomType: { type: 'string', enum: ['project', 'request', 'card'] },
|
|
2543
|
+
roomId: { type: 'integer', description: 'Project id, request number, or card GLOBAL id.' },
|
|
2544
|
+
seconds: {
|
|
2545
|
+
type: 'integer',
|
|
2546
|
+
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.',
|
|
2548
|
+
},
|
|
2549
|
+
maxEvents: {
|
|
2550
|
+
type: 'integer',
|
|
2551
|
+
description: 'Return early once this many events arrived, default 50.',
|
|
2552
|
+
},
|
|
2553
|
+
},
|
|
2554
|
+
},
|
|
2555
|
+
handler: (args) =>
|
|
2556
|
+
systaStream(
|
|
2557
|
+
`/rooms/${encodeURIComponent(args.roomType)}/${encodeURIComponent(args.roomId)}/stream`,
|
|
2558
|
+
{
|
|
2559
|
+
// Tavan bir SAAT. Sunucu akisi suresiz acik tutar (25 sn'de bir
|
|
2560
|
+
// keepalive), yani sinir sunucunun sabri degil ajanin turudur.
|
|
2561
|
+
seconds: Math.min(Number(args.seconds) || 30, 3600),
|
|
2562
|
+
maxEvents: Math.min(Number(args.maxEvents) || 50, 200),
|
|
2563
|
+
},
|
|
2564
|
+
),
|
|
2565
|
+
},
|
|
1908
2566
|
{
|
|
1909
2567
|
name: 'list_allowed_transitions',
|
|
1910
2568
|
description:
|
|
@@ -1959,11 +2617,15 @@ The number is the request number for "#", and the card's GLOBAL id for "##" (NOT
|
|
|
1959
2617
|
`/requests/${encodeURIComponent(args.requestNumber)}/allowed-transitions`,
|
|
1960
2618
|
);
|
|
1961
2619
|
const allowed = allowedRes?.data?.data?.allowedTransitions || [];
|
|
2620
|
+
// Gecis satiri `toStatusKey` tasir (canli: `{fromStatusId, toStatusId,
|
|
2621
|
+
// toStatusName, toStatusKey, toStatusColor}`); `statusKey` HIC yoktu, bu
|
|
2622
|
+
// dal hicbir zaman eslesmiyor ve yavas cozumleme yoluna dusuyordu (BF-56).
|
|
1962
2623
|
const hit = allowed.find(
|
|
1963
|
-
(t) => String(t.
|
|
2624
|
+
(t) => String(t.toStatusKey || '').toLowerCase() === String(args.statusKey).toLowerCase(),
|
|
1964
2625
|
);
|
|
1965
2626
|
if (hit) {
|
|
1966
|
-
|
|
2627
|
+
// Gecis satirinin hedefi `toStatusId`dir; `id`/`statusId` yoktu (BF-56).
|
|
2628
|
+
statusId = hit.toStatusId ?? hit.id ?? hit.statusId;
|
|
1967
2629
|
}
|
|
1968
2630
|
if (statusId == null) {
|
|
1969
2631
|
// Yedek: TALEBIN KENDI formuna scope'lu statu listesi (global liste
|
|
@@ -1982,7 +2644,7 @@ The number is the request number for "#", and the card's GLOBAL id for "##" (NOT
|
|
|
1982
2644
|
);
|
|
1983
2645
|
}
|
|
1984
2646
|
const res = await systaFetch('PUT', `/requests/${encodeURIComponent(args.requestNumber)}`, {
|
|
1985
|
-
body: { statusId },
|
|
2647
|
+
body: toNestedWriteBody('request', { statusId }),
|
|
1986
2648
|
});
|
|
1987
2649
|
if (args.comment && String(args.comment).trim()) {
|
|
1988
2650
|
const doc = {
|
|
@@ -2049,7 +2711,9 @@ The number is the request number for "#", and the card's GLOBAL id for "##" (NOT
|
|
|
2049
2711
|
body[k] = v;
|
|
2050
2712
|
}
|
|
2051
2713
|
}
|
|
2052
|
-
return systaFetch('PUT', `/requests/${encodeURIComponent(args.requestNumber)}`, {
|
|
2714
|
+
return systaFetch('PUT', `/requests/${encodeURIComponent(args.requestNumber)}`, {
|
|
2715
|
+
body: toNestedWriteBody('request', body),
|
|
2716
|
+
});
|
|
2053
2717
|
},
|
|
2054
2718
|
},
|
|
2055
2719
|
{
|
|
@@ -2077,7 +2741,9 @@ The number is the request number for "#", and the card's GLOBAL id for "##" (NOT
|
|
|
2077
2741
|
if (Array.isArray(args.assignedTeams)) {
|
|
2078
2742
|
body.assignedTeams = args.assignedTeams;
|
|
2079
2743
|
}
|
|
2080
|
-
return systaFetch('PUT', `/requests/${encodeURIComponent(args.requestNumber)}`, {
|
|
2744
|
+
return systaFetch('PUT', `/requests/${encodeURIComponent(args.requestNumber)}`, {
|
|
2745
|
+
body: toNestedWriteBody('request', body),
|
|
2746
|
+
});
|
|
2081
2747
|
},
|
|
2082
2748
|
},
|
|
2083
2749
|
{
|
|
@@ -2320,7 +2986,7 @@ The number is the request number for "#", and the card's GLOBAL id for "##" (NOT
|
|
|
2320
2986
|
if (args.requestNote) {
|
|
2321
2987
|
body.requestNote = args.requestNote;
|
|
2322
2988
|
}
|
|
2323
|
-
return systaFetch('POST', '/approvals/batch', { body });
|
|
2989
|
+
return systaFetch('POST', '/approvals/batch', { body: toNestedWriteBody('approval', body) });
|
|
2324
2990
|
},
|
|
2325
2991
|
},
|
|
2326
2992
|
{
|
|
@@ -2400,7 +3066,7 @@ The number is the request number for "#", and the card's GLOBAL id for "##" (NOT
|
|
|
2400
3066
|
body[k] = v;
|
|
2401
3067
|
}
|
|
2402
3068
|
}
|
|
2403
|
-
return systaFetch('POST', '/projects', { body });
|
|
3069
|
+
return systaFetch('POST', '/projects', { body: toNestedWriteBody('project', body) });
|
|
2404
3070
|
},
|
|
2405
3071
|
},
|
|
2406
3072
|
{
|
|
@@ -2469,7 +3135,9 @@ The number is the request number for "#", and the card's GLOBAL id for "##" (NOT
|
|
|
2469
3135
|
body[k] = v;
|
|
2470
3136
|
}
|
|
2471
3137
|
}
|
|
2472
|
-
return systaFetch('PUT', `/projects/${encodeURIComponent(args.id)}`, {
|
|
3138
|
+
return systaFetch('PUT', `/projects/${encodeURIComponent(args.id)}`, {
|
|
3139
|
+
body: toNestedWriteBody('project', body),
|
|
3140
|
+
});
|
|
2473
3141
|
},
|
|
2474
3142
|
},
|
|
2475
3143
|
{
|
|
@@ -3011,7 +3679,7 @@ The number is the request number for "#", and the card's GLOBAL id for "##" (NOT
|
|
|
3011
3679
|
body[k] = v;
|
|
3012
3680
|
}
|
|
3013
3681
|
}
|
|
3014
|
-
return systaFetch('POST', '/releases/lists', { body });
|
|
3682
|
+
return systaFetch('POST', '/releases/lists', { body: toNestedWriteBody('release', body) });
|
|
3015
3683
|
},
|
|
3016
3684
|
},
|
|
3017
3685
|
{
|
|
@@ -3052,7 +3720,9 @@ The number is the request number for "#", and the card's GLOBAL id for "##" (NOT
|
|
|
3052
3720
|
},
|
|
3053
3721
|
handler: (args) => {
|
|
3054
3722
|
if (Array.isArray(args.items) && args.items.length) {
|
|
3055
|
-
return systaFetch('POST', '/releases/items', {
|
|
3723
|
+
return systaFetch('POST', '/releases/items', {
|
|
3724
|
+
body: args.items.map((oge) => toNestedWriteBody('release', oge)),
|
|
3725
|
+
});
|
|
3056
3726
|
}
|
|
3057
3727
|
const body = {};
|
|
3058
3728
|
for (const [k, v] of Object.entries(args)) {
|
|
@@ -3060,7 +3730,7 @@ The number is the request number for "#", and the card's GLOBAL id for "##" (NOT
|
|
|
3060
3730
|
body[k] = v;
|
|
3061
3731
|
}
|
|
3062
3732
|
}
|
|
3063
|
-
return systaFetch('POST', '/releases/items', { body });
|
|
3733
|
+
return systaFetch('POST', '/releases/items', { body: toNestedWriteBody('release', body) });
|
|
3064
3734
|
},
|
|
3065
3735
|
},
|
|
3066
3736
|
{
|
|
@@ -3312,7 +3982,7 @@ The number is the request number for "#", and the card's GLOBAL id for "##" (NOT
|
|
|
3312
3982
|
{
|
|
3313
3983
|
name: 'systa_api_call',
|
|
3314
3984
|
description:
|
|
3315
|
-
'Generic SysTa REST API call. Use for any endpoint not covered by a specific tool (create/update/comment, etc.). The API key scope is enforced server-side: out-of-scope calls return 403. path is relative to the API base (e.g. "/requests/42/comments"). You may paste a path verbatim from list_capabilities/describe_endpoint even though those include the "/api" prefix — a leading "/api" is auto-stripped (the base URL already ends in /api), so "/api/requests" and "/requests" both work.',
|
|
3985
|
+
'Generic SysTa REST API call. Use for any endpoint not covered by a specific tool (create/update/comment, etc.). The API key scope is enforced server-side: out-of-scope calls return 403. path is relative to the API base (e.g. "/requests/42/comments"). WRITE BODIES ARE NESTED (POST/PUT/PATCH on entities): send identity references as groups exactly as describe_endpoint(fields) shows them -- { status: { id: 9 }, company: { id: 12 }, priority: { key: "high" }, project: null } -- NOT flat statusId/companyId; flat keys are a deprecated transitional form and will be rejected. Query/filter keys on GET stay FLAT (statusId, companyId). Named tools (create_request, update_request, change_request_status, create_project...) do this conversion for you. You may paste a path verbatim from list_capabilities/describe_endpoint even though those include the "/api" prefix — a leading "/api" is auto-stripped (the base URL already ends in /api), so "/api/requests" and "/requests" both work.',
|
|
3316
3986
|
inputSchema: {
|
|
3317
3987
|
type: 'object',
|
|
3318
3988
|
additionalProperties: false,
|
|
@@ -3664,7 +4334,7 @@ The number is the request number for "#", and the card's GLOBAL id for "##" (NOT
|
|
|
3664
4334
|
body[k] = args[k];
|
|
3665
4335
|
}
|
|
3666
4336
|
}
|
|
3667
|
-
return systaFetch('POST', '/effort/entries', { body });
|
|
4337
|
+
return systaFetch('POST', '/effort/entries', { body: toNestedWriteBody('effort', body) });
|
|
3668
4338
|
},
|
|
3669
4339
|
},
|
|
3670
4340
|
{
|
|
@@ -4018,7 +4688,9 @@ The number is the request number for "#", and the card's GLOBAL id for "##" (NOT
|
|
|
4018
4688
|
},
|
|
4019
4689
|
});
|
|
4020
4690
|
}
|
|
4021
|
-
return systaFetch('PATCH', `/effort/entries/${encodeURIComponent(args.entryId)}`, {
|
|
4691
|
+
return systaFetch('PATCH', `/effort/entries/${encodeURIComponent(args.entryId)}`, {
|
|
4692
|
+
body: toNestedWriteBody('effort', body),
|
|
4693
|
+
});
|
|
4022
4694
|
},
|
|
4023
4695
|
},
|
|
4024
4696
|
{
|
|
@@ -4779,4 +5451,8 @@ module.exports = {
|
|
|
4779
5451
|
sanitizeFileName,
|
|
4780
5452
|
mimeForFileName,
|
|
4781
5453
|
resolveSavePath,
|
|
5454
|
+
// Nested yazma govdesi (Faz 21 / BF-92) — spec-sync testi icin export edilir
|
|
5455
|
+
toNestedWriteBody,
|
|
5456
|
+
YAZMA_YOLLARI,
|
|
5457
|
+
YAZMA_GRUPLARI,
|
|
4782
5458
|
};
|