systa-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +163 -0
- package/SKILL.md +187 -0
- package/package.json +34 -0
- package/server.js +2226 -0
package/server.js
ADDED
|
@@ -0,0 +1,2226 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* SysTa MCP Server (SysTa CLI — Faz 4)
|
|
7
|
+
*
|
|
8
|
+
* Bir Model Context Protocol (MCP) stdio sunucusu. Claude gibi AI ajanlarinin
|
|
9
|
+
* SysTa REST API'sini scoped bir API anahtariyla "arac" olarak kullanmasini
|
|
10
|
+
* saglar. Native Node + JSON-RPC 2.0 (stdio, newline-delimited) — SUPPLY-CHAIN
|
|
11
|
+
* yuzeyini sifirda tutmak icin HICBIR npm bagimliligi yok (Node 18+ global fetch).
|
|
12
|
+
*
|
|
13
|
+
* Yetki modeli: Tum scope/izin zorlamasi SUNUCU TARAFINDADIR. MCP yalnizca
|
|
14
|
+
* Authorization: Bearer <key> + X-Vendor-Code basliklariyla istegi proxy'ler;
|
|
15
|
+
* anahtarin kapsami disindaki cagrilar backend tarafindan 403 ile reddedilir.
|
|
16
|
+
*
|
|
17
|
+
* Konfigurasyon (env):
|
|
18
|
+
* SYSTA_API_BASE_URL Orn: http://vizyoneks.localhost:3000/api
|
|
19
|
+
* SYSTA_API_KEY sk_live_... — anahtarin KENDISI vendor'i belirler (backend key'den cozer)
|
|
20
|
+
* SYSTA_VENDOR_CODE OPSIYONEL: X-Vendor-Code header'i. Verilirse anahtarin vendor'iyla AYNI
|
|
21
|
+
* olmali (farkliysa 403). Bos birak -> backend vendor'i key'den turetir.
|
|
22
|
+
*
|
|
23
|
+
* @module mcp-server/server
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const fs = require('node:fs');
|
|
27
|
+
const os = require('node:os');
|
|
28
|
+
const path = require('node:path');
|
|
29
|
+
|
|
30
|
+
const PROTOCOL_VERSION = '2024-11-05';
|
|
31
|
+
const SERVER_NAME = 'systa-mcp';
|
|
32
|
+
const SERVER_VERSION = '1.0.0';
|
|
33
|
+
|
|
34
|
+
// Server-seviye rehber — her oturumda AI ajanin context'ine girer (kisa tutulur).
|
|
35
|
+
// Ajana SysTa'yi nasil kesfedip kullanacagini ogretir; detay lazy araclardadir.
|
|
36
|
+
const INSTRUCTIONS = [
|
|
37
|
+
'SysTa (Talep Takip Sistemi) REST API — scoped bir API anahtariyla baglisin.',
|
|
38
|
+
'',
|
|
39
|
+
'KESIF (onemli): Bu anahtarin GERCEKTE neler yapabilecegi, sahip oldugu izinlerle',
|
|
40
|
+
'sinirlidir. Gordugun ARAÇ LISTESI de bu anahtarin kapsamina gore filtrelenmistir —',
|
|
41
|
+
'kapsam disi araclar listede yer almaz; listede gorundugu halde kapsam disi kalan bir',
|
|
42
|
+
'cagri yine de sunucu tarafinda 403 doner. Bir isi yapmadan ONCE:',
|
|
43
|
+
' 1) `list_capabilities` cagir — anahtarin erisebildigi modul/endpoint katalogunu',
|
|
44
|
+
' (scope-filtreli) gorursun. Yetkisiz endpoint listede GORUNMEZ. Yanit AYRICA SysTa',
|
|
45
|
+
' platform OZETI (overview), TR GLOSSARY ve her modulun ACIKLAMASINI (description/',
|
|
46
|
+
' concepts) icerir — kullanicinin dogal dilini (talep/gorev/efor/pano/durum/atanan...)',
|
|
47
|
+
' bunlarla dogru modul ve endpoint ile eslestir.',
|
|
48
|
+
' 2) `describe_module(module)` (opsiyonel) — bir modulun (orn. request/kanban/plan) amacini,',
|
|
49
|
+
' kavramlarini ve cagirabilecegin endpointleri topluca gorursun.',
|
|
50
|
+
' 3) `describe_endpoint(path, method)` cagir — o endpoint için alan tiplerini (fields),',
|
|
51
|
+
' guvenlik sinifini ve aciklamayi al; sonra cagri govdesini buna gore kur.',
|
|
52
|
+
'',
|
|
53
|
+
'GUVENLIK SINIFI (safetyClass): read_only (guvenli okuma) | idempotent (tekrarlanabilir)',
|
|
54
|
+
' | mutating (durum degistirir) | destructive (geri-alinamaz — dikkatli ol, gerekirse onayla).',
|
|
55
|
+
'',
|
|
56
|
+
'SCOPE: anahtar izinleri permission_key namespace iledir (orn request.read). Wildcard',
|
|
57
|
+
" '*' / 'domain.*' GUVENLI varsayilanla YIKICI suffix'leri (.delete/.purge/.wipe/.destroy/",
|
|
58
|
+
' .truncate/.revoke/.deactivate/.remove) KAPSAMAZ — silme yalniz acik izinle verilir.',
|
|
59
|
+
' Kapsam disi cagri sunucu tarafinda 403 doner. API anahtari KENDISI yeni anahtar',
|
|
60
|
+
' uretemez/yonetemez (anahtar yonetimi insan oturumu gerektirir -> 403). Bir endpoint',
|
|
61
|
+
' katalogda YOKSA: ya gercekten yok ya da bu anahtarin kapsami disinda — varlik',
|
|
62
|
+
' varsayma, emin degilsen kullaniciya/anahtar sahibine sor.',
|
|
63
|
+
'',
|
|
64
|
+
'ARACLAR: list_requests (zengin filtre: status/assignee/company/category/department/priority) /',
|
|
65
|
+
' get_request / list_projects / create_request / add_request_comment / change_request_status',
|
|
66
|
+
' (durum degistir/tamamla/kapat + opsiyonel yorum) ozel araclardir.',
|
|
67
|
+
' "talep ac" niyetinde ONCE prepare_create_request(companyId) cagir — aktif formun zorunlu',
|
|
68
|
+
' alanlarini + gecerli id secenekleri (status/kategori/organizasyon) doner, 400 roundtrip onlenir;',
|
|
69
|
+
' kanban (get_request_board/manage_request_card/move_request_card), onay (request_approval/',
|
|
70
|
+
' respond_to_approval/list_pending_approvals), proje (create_project/get_project/update_project)',
|
|
71
|
+
' ve dosya ekleri (download_request_file / upload_file_to) ozel araclardir — `systa_api_call` JSON-only oldugundan',
|
|
72
|
+
' binary tasiyamaz; diger her endpoint icin `systa_api_call` (method+path) kullan — path ve',
|
|
73
|
+
' govdeyi describe_endpoint ciktisindan kur.',
|
|
74
|
+
].join('\n');
|
|
75
|
+
|
|
76
|
+
const BASE_URL = (process.env.SYSTA_API_BASE_URL || '').replace(/\/+$/, '');
|
|
77
|
+
const API_KEY = process.env.SYSTA_API_KEY || '';
|
|
78
|
+
const VENDOR_CODE = process.env.SYSTA_VENDOR_CODE || '';
|
|
79
|
+
|
|
80
|
+
const ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
81
|
+
|
|
82
|
+
// Diagnostics MUST go to stderr — stdout is the JSON-RPC channel.
|
|
83
|
+
const logErr = (message) => process.stderr.write(`[${SERVER_NAME}] ${message}\n`);
|
|
84
|
+
|
|
85
|
+
// ── HTTP proxy to SysTa ──────────────────────────────────────────────────────
|
|
86
|
+
/**
|
|
87
|
+
* Proxy a request to the SysTa REST API with the configured API key + vendor.
|
|
88
|
+
* @param {string} method
|
|
89
|
+
* @param {string} path - Path relative to SYSTA_API_BASE_URL, must start with '/'
|
|
90
|
+
* @param {{query?:Object, body?:*}} [opts]
|
|
91
|
+
* @returns {Promise<{status:number, ok:boolean, data:*}>}
|
|
92
|
+
*/
|
|
93
|
+
const systaFetch = async (method, path, opts = {}) => {
|
|
94
|
+
if (!BASE_URL) {
|
|
95
|
+
throw new Error('SYSTA_API_BASE_URL is not configured');
|
|
96
|
+
}
|
|
97
|
+
if (!API_KEY) {
|
|
98
|
+
throw new Error('SYSTA_API_KEY is not configured');
|
|
99
|
+
}
|
|
100
|
+
const url = new URL(BASE_URL + path);
|
|
101
|
+
if (opts.query) {
|
|
102
|
+
for (const [key, value] of Object.entries(opts.query)) {
|
|
103
|
+
if (value !== undefined && value !== null && value !== '') {
|
|
104
|
+
url.searchParams.set(key, String(value));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const headers = {
|
|
109
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
110
|
+
Accept: 'application/json',
|
|
111
|
+
};
|
|
112
|
+
if (VENDOR_CODE) {
|
|
113
|
+
headers['X-Vendor-Code'] = VENDOR_CODE;
|
|
114
|
+
}
|
|
115
|
+
const init = { method, headers };
|
|
116
|
+
if (opts.body !== undefined && opts.body !== null) {
|
|
117
|
+
headers['Content-Type'] = 'application/json';
|
|
118
|
+
init.body = JSON.stringify(opts.body);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const response = await fetch(url, init);
|
|
122
|
+
const text = await response.text();
|
|
123
|
+
let data;
|
|
124
|
+
try {
|
|
125
|
+
data = text ? JSON.parse(text) : null;
|
|
126
|
+
} catch {
|
|
127
|
+
data = text;
|
|
128
|
+
}
|
|
129
|
+
return { status: response.status, ok: response.ok, data };
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// ── Dosya araclari yardimcilari ──────────────────────────────────────────────
|
|
133
|
+
// Backend fileSecurityMiddleware ALLOWED_FILE_TYPES allowlist'inin aynasi —
|
|
134
|
+
// multipart part Content-Type'i uzantiyla eslesmezse sunucu yuklemeyi reddeder.
|
|
135
|
+
const EXT_MIME = {
|
|
136
|
+
pdf: 'application/pdf',
|
|
137
|
+
doc: 'application/msword',
|
|
138
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
139
|
+
xls: 'application/vnd.ms-excel',
|
|
140
|
+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
141
|
+
ppt: 'application/vnd.ms-powerpoint',
|
|
142
|
+
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
143
|
+
png: 'image/png',
|
|
144
|
+
jpg: 'image/jpeg',
|
|
145
|
+
jpeg: 'image/jpeg',
|
|
146
|
+
gif: 'image/gif',
|
|
147
|
+
webp: 'image/webp',
|
|
148
|
+
json: 'application/json',
|
|
149
|
+
xml: 'application/xml',
|
|
150
|
+
txt: 'text/plain',
|
|
151
|
+
csv: 'text/csv',
|
|
152
|
+
log: 'text/plain',
|
|
153
|
+
yaml: 'text/yaml',
|
|
154
|
+
yml: 'text/yaml',
|
|
155
|
+
zip: 'application/zip',
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const mimeForFileName = (fileName) => {
|
|
159
|
+
const ext = path
|
|
160
|
+
.extname(String(fileName || ''))
|
|
161
|
+
.slice(1)
|
|
162
|
+
.toLowerCase();
|
|
163
|
+
return EXT_MIME[ext] || 'application/octet-stream';
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/** Content-Disposition basligindan dosya adini cozer (RFC 5987 filename* oncelikli). */
|
|
167
|
+
const parseContentDispositionFilename = (header) => {
|
|
168
|
+
if (!header) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
const star = /filename\*\s*=\s*utf-8''([^;]+)/i.exec(header);
|
|
172
|
+
if (star) {
|
|
173
|
+
try {
|
|
174
|
+
return decodeURIComponent(star[1].trim());
|
|
175
|
+
} catch {
|
|
176
|
+
// bozuk encoding — duz filename= varyantina dus
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const plain = /filename\s*=\s*"([^"]+)"/.exec(header) || /filename\s*=\s*([^;]+)/.exec(header);
|
|
180
|
+
return plain ? plain[1].trim() : null;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/** Sunucudan gelen dosya adini path bilesenlerinden arindirir (path traversal koruma). */
|
|
184
|
+
const sanitizeFileName = (name, fallback) => {
|
|
185
|
+
// Hem '/' hem '\' separator sayilir (POSIX path.basename backslash'i ayirmaz).
|
|
186
|
+
const segments = String(name || '')
|
|
187
|
+
.replace(/\0/g, '')
|
|
188
|
+
.split(/[\\/]/);
|
|
189
|
+
const base = segments[segments.length - 1].trim();
|
|
190
|
+
if (!base || base === '.' || base === '..') {
|
|
191
|
+
return fallback;
|
|
192
|
+
}
|
|
193
|
+
if (base.length > 255) {
|
|
194
|
+
const ext = path.extname(base);
|
|
195
|
+
return base.slice(0, 255 - ext.length) + ext;
|
|
196
|
+
}
|
|
197
|
+
return base;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/** Hedef yerel yolu cozer: yoksa temp dizin, mevcut dizinse icine, degilse tam yol. */
|
|
201
|
+
const resolveSavePath = (fileName, savePath) => {
|
|
202
|
+
if (!savePath) {
|
|
203
|
+
const dir = path.join(os.tmpdir(), 'systa-mcp-downloads');
|
|
204
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
205
|
+
return path.join(dir, fileName);
|
|
206
|
+
}
|
|
207
|
+
const resolved = path.resolve(String(savePath));
|
|
208
|
+
if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
|
|
209
|
+
return path.join(resolved, fileName);
|
|
210
|
+
}
|
|
211
|
+
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
|
212
|
+
return resolved;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// RAM korumasi — tipik ek dosyalarinin cok ustunde bir tavan.
|
|
216
|
+
const MAX_DOWNLOAD_BYTES = 500 * 1024 * 1024;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Binary indirme: download endpoint'i ya dosyayi stream eder ya S3'e redirect eder
|
|
220
|
+
* (fetch redirect'i takip eder; undici cross-origin'de Authorization'i dusurur —
|
|
221
|
+
* presigned URL zaten auth istemez). Hata yanitlari (4xx/5xx) JSON envelope'tur.
|
|
222
|
+
*/
|
|
223
|
+
const systaFetchBinary = async (apiPath) => {
|
|
224
|
+
if (!BASE_URL) {
|
|
225
|
+
throw new Error('SYSTA_API_BASE_URL is not configured');
|
|
226
|
+
}
|
|
227
|
+
if (!API_KEY) {
|
|
228
|
+
throw new Error('SYSTA_API_KEY is not configured');
|
|
229
|
+
}
|
|
230
|
+
const headers = { Authorization: `Bearer ${API_KEY}` };
|
|
231
|
+
if (VENDOR_CODE) {
|
|
232
|
+
headers['X-Vendor-Code'] = VENDOR_CODE;
|
|
233
|
+
}
|
|
234
|
+
const response = await fetch(new URL(BASE_URL + apiPath), { method: 'GET', headers });
|
|
235
|
+
if (!response.ok) {
|
|
236
|
+
const text = await response.text();
|
|
237
|
+
let data;
|
|
238
|
+
try {
|
|
239
|
+
data = text ? JSON.parse(text) : null;
|
|
240
|
+
} catch {
|
|
241
|
+
data = text;
|
|
242
|
+
}
|
|
243
|
+
return { status: response.status, ok: false, data };
|
|
244
|
+
}
|
|
245
|
+
const length = Number(response.headers.get('content-length') || 0);
|
|
246
|
+
if (length > MAX_DOWNLOAD_BYTES) {
|
|
247
|
+
return {
|
|
248
|
+
status: response.status,
|
|
249
|
+
ok: false,
|
|
250
|
+
data: { error: `File too large for MCP download (${length} bytes)` },
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
254
|
+
return {
|
|
255
|
+
status: response.status,
|
|
256
|
+
ok: true,
|
|
257
|
+
buffer,
|
|
258
|
+
mimeType: response.headers.get('content-type') || 'application/octet-stream',
|
|
259
|
+
fileName: parseContentDispositionFilename(response.headers.get('content-disposition')),
|
|
260
|
+
};
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
/** Multipart yukleme — native FormData/Blob (Node 18+), alan adi backend'le ayni: 'file'. */
|
|
264
|
+
const systaUploadMultipart = async (apiPath, method, filePaths) => {
|
|
265
|
+
if (!BASE_URL) {
|
|
266
|
+
throw new Error('SYSTA_API_BASE_URL is not configured');
|
|
267
|
+
}
|
|
268
|
+
if (!API_KEY) {
|
|
269
|
+
throw new Error('SYSTA_API_KEY is not configured');
|
|
270
|
+
}
|
|
271
|
+
const form = new FormData();
|
|
272
|
+
for (const localPath of filePaths) {
|
|
273
|
+
const resolved = path.resolve(String(localPath));
|
|
274
|
+
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
|
|
275
|
+
throw new Error(`Local file not found: ${resolved}`);
|
|
276
|
+
}
|
|
277
|
+
const fileName = path.basename(resolved);
|
|
278
|
+
const buffer = fs.readFileSync(resolved);
|
|
279
|
+
form.append('file', new Blob([buffer], { type: mimeForFileName(fileName) }), fileName);
|
|
280
|
+
}
|
|
281
|
+
const headers = { Authorization: `Bearer ${API_KEY}`, Accept: 'application/json' };
|
|
282
|
+
if (VENDOR_CODE) {
|
|
283
|
+
headers['X-Vendor-Code'] = VENDOR_CODE;
|
|
284
|
+
}
|
|
285
|
+
// Content-Type'i fetch kendisi kurar (multipart boundary dahil) — elle set etme.
|
|
286
|
+
const response = await fetch(new URL(BASE_URL + apiPath), { method, headers, body: form });
|
|
287
|
+
const text = await response.text();
|
|
288
|
+
let data;
|
|
289
|
+
try {
|
|
290
|
+
data = text ? JSON.parse(text) : null;
|
|
291
|
+
} catch {
|
|
292
|
+
data = text;
|
|
293
|
+
}
|
|
294
|
+
return { status: response.status, ok: response.ok, data };
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// ── Capability catalog helpers ───────────────────────────────────────────────
|
|
298
|
+
/**
|
|
299
|
+
* Unwrap the SysTa API envelope ({success, data}) to the capabilities payload.
|
|
300
|
+
*/
|
|
301
|
+
const unwrapCapabilities = (res) =>
|
|
302
|
+
res && res.data && res.data.data ? res.data.data : res && res.data;
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Compact view of the capability catalog — drops field_schema/detail/scenario so
|
|
306
|
+
* the per-turn payload stays small (lazy: use describe_endpoint for full detail).
|
|
307
|
+
*/
|
|
308
|
+
const shapeCompact = (caps) => ({
|
|
309
|
+
sessionType: caps.sessionType,
|
|
310
|
+
// who-am-I: bu anahtarin sahibi olan kullanici. 'bana ata' / 'ben' -> user.id.
|
|
311
|
+
...(caps.user ? { user: caps.user } : {}),
|
|
312
|
+
scopes: caps.scopes,
|
|
313
|
+
isSuperAdmin: caps.isSuperAdmin,
|
|
314
|
+
// Kavramsal oryantasyon (ai_orientation_catalog) — kod-kor ajan icin SysTa'yi
|
|
315
|
+
// anlama katmani: platform ozeti + TR glossary + modul aciklamalari.
|
|
316
|
+
...(caps.overview ? { overview: caps.overview } : {}),
|
|
317
|
+
...(caps.glossary ? { glossary: caps.glossary } : {}),
|
|
318
|
+
moduleCount: caps.moduleCount,
|
|
319
|
+
endpointCount: caps.endpointCount,
|
|
320
|
+
modules: (caps.modules || []).map((m) => ({
|
|
321
|
+
module: m.module,
|
|
322
|
+
...(m.description ? { description: m.description } : {}),
|
|
323
|
+
...(m.concepts ? { concepts: m.concepts } : {}),
|
|
324
|
+
endpoints: (m.endpoints || []).map((e) => ({
|
|
325
|
+
path: e.path,
|
|
326
|
+
summary: e.summary,
|
|
327
|
+
methods: (e.methods || []).map((mm) => ({
|
|
328
|
+
method: mm.method,
|
|
329
|
+
safetyClass: mm.safetyClass,
|
|
330
|
+
requiredPermission: mm.requiredPermission,
|
|
331
|
+
hasFields: Boolean(mm.fields),
|
|
332
|
+
})),
|
|
333
|
+
})),
|
|
334
|
+
})),
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// ── Tool definitions ─────────────────────────────────────────────────────────
|
|
338
|
+
const TOOLS = [
|
|
339
|
+
{
|
|
340
|
+
name: 'list_requests',
|
|
341
|
+
description:
|
|
342
|
+
'List/search SysTa requests (talepler) with pagination and rich filters: status, assignee, company, category, department, priority, free-text. "uzerimdeki isler / my open work" => assignedTo=<me> (resolve via list_capabilities user.id) + non-final statuses. Returns only requests the API key scope permits. For array/range filters beyond these, use systa_api_call GET /requests.',
|
|
343
|
+
inputSchema: {
|
|
344
|
+
type: 'object',
|
|
345
|
+
additionalProperties: false,
|
|
346
|
+
properties: {
|
|
347
|
+
limit: {
|
|
348
|
+
type: 'integer',
|
|
349
|
+
minimum: 1,
|
|
350
|
+
maximum: 100,
|
|
351
|
+
description: 'Page size (1-100, default 20)',
|
|
352
|
+
},
|
|
353
|
+
offset: { type: 'integer', minimum: 0, description: 'Pagination offset' },
|
|
354
|
+
search: { type: 'string', description: 'Free-text search' },
|
|
355
|
+
statusId: { type: 'integer', description: 'Filter by status id' },
|
|
356
|
+
assignedTo: {
|
|
357
|
+
type: 'integer',
|
|
358
|
+
description: 'Filter by assignee user id (use own id for "my work")',
|
|
359
|
+
},
|
|
360
|
+
companyId: { type: 'integer', description: 'Filter by company id' },
|
|
361
|
+
categoryId: { type: 'integer', description: 'Filter by category id' },
|
|
362
|
+
departmentId: { type: 'integer', description: 'Filter by department id' },
|
|
363
|
+
priority: { type: 'string', description: 'Filter by priority key/level' },
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
handler: (args) =>
|
|
367
|
+
systaFetch('GET', '/requests', {
|
|
368
|
+
query: {
|
|
369
|
+
limit: args.limit ?? 20,
|
|
370
|
+
offset: args.offset,
|
|
371
|
+
search: args.search,
|
|
372
|
+
statusId: args.statusId,
|
|
373
|
+
assignedTo: args.assignedTo,
|
|
374
|
+
companyId: args.companyId,
|
|
375
|
+
categoryId: args.categoryId,
|
|
376
|
+
departmentId: args.departmentId,
|
|
377
|
+
priority: args.priority,
|
|
378
|
+
},
|
|
379
|
+
}),
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
name: 'get_request',
|
|
383
|
+
description: 'Get a single SysTa request (talep) by its numeric id, including its details.',
|
|
384
|
+
inputSchema: {
|
|
385
|
+
type: 'object',
|
|
386
|
+
additionalProperties: false,
|
|
387
|
+
required: ['id'],
|
|
388
|
+
properties: { id: { type: 'integer', description: 'Request id' } },
|
|
389
|
+
},
|
|
390
|
+
handler: (args) => systaFetch('GET', `/requests/${encodeURIComponent(args.id)}`),
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
name: 'list_projects',
|
|
394
|
+
description: 'List SysTa projects (projeler). Returns only projects the API key scope permits.',
|
|
395
|
+
inputSchema: {
|
|
396
|
+
type: 'object',
|
|
397
|
+
additionalProperties: false,
|
|
398
|
+
properties: {
|
|
399
|
+
limit: {
|
|
400
|
+
type: 'integer',
|
|
401
|
+
minimum: 1,
|
|
402
|
+
maximum: 100,
|
|
403
|
+
description: 'Page size (1-100, default 20)',
|
|
404
|
+
},
|
|
405
|
+
offset: { type: 'integer', minimum: 0, description: 'Pagination offset' },
|
|
406
|
+
},
|
|
407
|
+
},
|
|
408
|
+
handler: (args) =>
|
|
409
|
+
systaFetch('GET', '/projects', { query: { limit: args.limit ?? 20, offset: args.offset } }),
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
name: 'create_request',
|
|
413
|
+
description:
|
|
414
|
+
'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: title/companyId/statusId are the baseline (+assignedTo for assignment), but this vendor\'s ACTIVE REQUEST FORM may require more system fields (e.g. categoryId, organizationId). 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.',
|
|
415
|
+
inputSchema: {
|
|
416
|
+
type: 'object',
|
|
417
|
+
additionalProperties: false,
|
|
418
|
+
required: ['title', 'companyId', 'statusId', 'assignedTo'],
|
|
419
|
+
properties: {
|
|
420
|
+
title: { type: 'string', description: 'Request title' },
|
|
421
|
+
description: { type: 'string', description: 'Request description (plain text)' },
|
|
422
|
+
companyId: { type: 'integer', description: 'Target company id' },
|
|
423
|
+
statusId: { type: 'integer', description: 'Initial status id' },
|
|
424
|
+
assignedTo: {
|
|
425
|
+
type: 'integer',
|
|
426
|
+
description: 'Assignee user id (an assignment is required by SysTa)',
|
|
427
|
+
},
|
|
428
|
+
categoryId: {
|
|
429
|
+
type: 'integer',
|
|
430
|
+
description: 'Category id (optional, but the active request form may require it)',
|
|
431
|
+
},
|
|
432
|
+
organizationId: {
|
|
433
|
+
type: 'integer',
|
|
434
|
+
description:
|
|
435
|
+
'Organization-unit id (optional; may be required by the active form; must belong to companyId)',
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
handler: (args) =>
|
|
440
|
+
systaFetch('POST', '/requests', {
|
|
441
|
+
body: {
|
|
442
|
+
title: args.title,
|
|
443
|
+
description: args.description ?? '',
|
|
444
|
+
companyId: args.companyId,
|
|
445
|
+
statusId: args.statusId,
|
|
446
|
+
assignedTo: args.assignedTo,
|
|
447
|
+
...(args.categoryId != null ? { categoryId: args.categoryId } : {}),
|
|
448
|
+
...(args.organizationId != null ? { organizationId: args.organizationId } : {}),
|
|
449
|
+
},
|
|
450
|
+
}),
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
name: 'prepare_create_request',
|
|
454
|
+
description:
|
|
455
|
+
'Proactively fetch everything create_request needs for a company: the ACTIVE request form\'s required system fields PLUS valid id options (statuses, categories, organizations, departments). Call this FIRST whenever the user wants to "talep ac / open a request" so you fill categoryId/organizationId/statusId up front and avoid the 400 VALIDATION_FAILED roundtrip. Returns requiredFields[] and options{statusId,categoryId,organizationId,departmentId}. statusId options carry isFinal (true => a "tamamla/kapat" status). companyId is required (org/department options are scoped to it). A missing option list means that lookup is out of this key scope -- ask the user or use systa_api_call.',
|
|
456
|
+
inputSchema: {
|
|
457
|
+
type: 'object',
|
|
458
|
+
additionalProperties: false,
|
|
459
|
+
required: ['companyId'],
|
|
460
|
+
properties: {
|
|
461
|
+
companyId: {
|
|
462
|
+
type: 'integer',
|
|
463
|
+
description: 'Target company id for the request (scopes organization/department options)',
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
},
|
|
467
|
+
handler: async (args) => {
|
|
468
|
+
const companyId = args.companyId;
|
|
469
|
+
const nameOf = (x) =>
|
|
470
|
+
x.name ??
|
|
471
|
+
x.statusName ??
|
|
472
|
+
x.categoryName ??
|
|
473
|
+
x.unitName ??
|
|
474
|
+
x.organizationName ??
|
|
475
|
+
x.departmentName ??
|
|
476
|
+
x.companyName ??
|
|
477
|
+
x.label ??
|
|
478
|
+
null;
|
|
479
|
+
const listOf = (res) => {
|
|
480
|
+
if (!res) {
|
|
481
|
+
return [];
|
|
482
|
+
}
|
|
483
|
+
const d = res.data && res.data.data !== undefined ? res.data.data : res.data;
|
|
484
|
+
if (Array.isArray(d)) {
|
|
485
|
+
return d;
|
|
486
|
+
}
|
|
487
|
+
if (d && Array.isArray(d.items)) {
|
|
488
|
+
return d.items;
|
|
489
|
+
}
|
|
490
|
+
for (const k of ['statuses', 'categories', 'organizations', 'departments']) {
|
|
491
|
+
if (d && Array.isArray(d[k])) {
|
|
492
|
+
return d[k];
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return [];
|
|
496
|
+
};
|
|
497
|
+
const compact = (res, extra) =>
|
|
498
|
+
listOf(res)
|
|
499
|
+
.slice(0, 80)
|
|
500
|
+
.map((x) => {
|
|
501
|
+
const o = { id: x.id, name: nameOf(x) };
|
|
502
|
+
if (extra) {
|
|
503
|
+
extra(o, x);
|
|
504
|
+
}
|
|
505
|
+
return o;
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
// 1) Active request form -> required system fields
|
|
509
|
+
let requiredFields = [];
|
|
510
|
+
try {
|
|
511
|
+
const form = await systaFetch('GET', '/forms/request/active');
|
|
512
|
+
const fields = [];
|
|
513
|
+
(function walk(o) {
|
|
514
|
+
if (Array.isArray(o)) {
|
|
515
|
+
o.forEach(walk);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
if (o && typeof o === 'object') {
|
|
519
|
+
if (o.fieldKey) {
|
|
520
|
+
fields.push(o);
|
|
521
|
+
}
|
|
522
|
+
for (const v of Object.values(o)) {
|
|
523
|
+
walk(v);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
})((form.data && form.data.data) || form.data);
|
|
527
|
+
requiredFields = fields
|
|
528
|
+
.filter((f) => f.isRequired)
|
|
529
|
+
.map((f) => ({
|
|
530
|
+
fieldKey: f.fieldKey,
|
|
531
|
+
type: f.fieldType || f.type || null,
|
|
532
|
+
isSystem: f.isSystem === true,
|
|
533
|
+
}));
|
|
534
|
+
} catch {
|
|
535
|
+
// non-fatal — baseline fallback below
|
|
536
|
+
}
|
|
537
|
+
if (requiredFields.length === 0) {
|
|
538
|
+
requiredFields = ['title', 'companyId', 'statusId', 'assignedTo'].map((k) => ({
|
|
539
|
+
fieldKey: k,
|
|
540
|
+
isSystem: true,
|
|
541
|
+
}));
|
|
542
|
+
}
|
|
543
|
+
const need = new Set(requiredFields.map((f) => f.fieldKey));
|
|
544
|
+
|
|
545
|
+
// 2) Id options for required lookup fields (best-effort, scope-aware)
|
|
546
|
+
const enc = encodeURIComponent(companyId);
|
|
547
|
+
const [statuses, categories, organizations, departments] = await Promise.all([
|
|
548
|
+
need.has('statusId') ? systaFetch('GET', '/statuses').catch(() => null) : null,
|
|
549
|
+
need.has('categoryId') ? systaFetch('GET', '/categories').catch(() => null) : null,
|
|
550
|
+
need.has('organizationId')
|
|
551
|
+
? systaFetch('GET', `/organizations?companyId=${enc}`).catch(() => null)
|
|
552
|
+
: null,
|
|
553
|
+
need.has('departmentId')
|
|
554
|
+
? systaFetch('GET', `/departments?companyId=${enc}`).catch(() => null)
|
|
555
|
+
: null,
|
|
556
|
+
]);
|
|
557
|
+
const options = {};
|
|
558
|
+
if (statuses) {
|
|
559
|
+
options.statusId = compact(statuses, (o, x) => {
|
|
560
|
+
if (x.isFinal != null) {
|
|
561
|
+
o.isFinal = x.isFinal;
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
if (categories) {
|
|
566
|
+
options.categoryId = compact(categories);
|
|
567
|
+
}
|
|
568
|
+
if (organizations) {
|
|
569
|
+
options.organizationId = compact(organizations);
|
|
570
|
+
}
|
|
571
|
+
if (departments) {
|
|
572
|
+
options.departmentId = compact(departments);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return {
|
|
576
|
+
status: 200,
|
|
577
|
+
ok: true,
|
|
578
|
+
data: {
|
|
579
|
+
companyId,
|
|
580
|
+
requiredFields,
|
|
581
|
+
options,
|
|
582
|
+
note: 'Fill create_request with these required fields; resolve each id from options[fieldKey]. statusId.isFinal=true => a tamamla/kapat status. A missing option list = out of this key scope; ask the user or use systa_api_call.',
|
|
583
|
+
},
|
|
584
|
+
};
|
|
585
|
+
},
|
|
586
|
+
},
|
|
587
|
+
{
|
|
588
|
+
name: 'add_request_comment',
|
|
589
|
+
description:
|
|
590
|
+
'Add a PLAIN-TEXT comment to a SysTa request (talebe yorum ekle). Requires the API key to hold BOTH "request.comment.create" and "request.tabs.comments.view" scopes. The text is wrapped into the rich-text (TipTap) format automatically. For RICH content that renders for the end-user — tables, code blocks, @user mentions — or to attach files, do NOT use this tool: call describe_endpoint("/api/requests/:requestNumber/comments","POST") for the TipTap node contract, then systa_api_call POST /requests/{n}/comments with a full commentTextJson document (and fileIds:[...] for attachments; files appear in both the comment and the Files tab).',
|
|
591
|
+
inputSchema: {
|
|
592
|
+
type: 'object',
|
|
593
|
+
additionalProperties: false,
|
|
594
|
+
required: ['requestNumber', 'text'],
|
|
595
|
+
properties: {
|
|
596
|
+
requestNumber: { type: 'integer', description: 'Request id/number to comment on' },
|
|
597
|
+
text: { type: 'string', description: 'Comment text (plain)' },
|
|
598
|
+
},
|
|
599
|
+
},
|
|
600
|
+
handler: (args) => {
|
|
601
|
+
const content = String(args.text || '');
|
|
602
|
+
const doc = content
|
|
603
|
+
? {
|
|
604
|
+
type: 'doc',
|
|
605
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: content }] }],
|
|
606
|
+
}
|
|
607
|
+
: { type: 'doc', content: [{ type: 'paragraph' }] };
|
|
608
|
+
return systaFetch('POST', `/requests/${encodeURIComponent(args.requestNumber)}/comments`, {
|
|
609
|
+
body: { commentTextJson: doc },
|
|
610
|
+
});
|
|
611
|
+
},
|
|
612
|
+
},
|
|
613
|
+
{
|
|
614
|
+
name: 'change_request_status',
|
|
615
|
+
description:
|
|
616
|
+
'Move a SysTa request to a target status (durum degistir / tamamla / kapat / yeniden ac), optionally with a closing comment. Resolve the target statusId by NAME first (list_capabilities or systa_api_call GET /statuses) -- do NOT guess the id. "tamamla/kapat" => a final status; "yeniden ac" => a non-final one. A given comment is posted right after the transition (plain text, auto-wrapped to TipTap). A status change may trigger a workflow gate/approval -- if the API returns such an error, read it and act, do not retry blindly.',
|
|
617
|
+
inputSchema: {
|
|
618
|
+
type: 'object',
|
|
619
|
+
additionalProperties: false,
|
|
620
|
+
required: ['requestNumber', 'statusId'],
|
|
621
|
+
properties: {
|
|
622
|
+
requestNumber: { type: 'integer', description: 'Request number to transition' },
|
|
623
|
+
statusId: { type: 'integer', description: 'Target status id (resolve by name first)' },
|
|
624
|
+
comment: {
|
|
625
|
+
type: 'string',
|
|
626
|
+
description: 'Optional closing/transition comment (plain text)',
|
|
627
|
+
},
|
|
628
|
+
},
|
|
629
|
+
},
|
|
630
|
+
handler: async (args) => {
|
|
631
|
+
const res = await systaFetch('PUT', `/requests/${encodeURIComponent(args.requestNumber)}`, {
|
|
632
|
+
body: { statusId: args.statusId },
|
|
633
|
+
});
|
|
634
|
+
if (args.comment && String(args.comment).trim()) {
|
|
635
|
+
const doc = {
|
|
636
|
+
type: 'doc',
|
|
637
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: String(args.comment) }] }],
|
|
638
|
+
};
|
|
639
|
+
await systaFetch('POST', `/requests/${encodeURIComponent(args.requestNumber)}/comments`, {
|
|
640
|
+
body: { commentTextJson: doc },
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
return res;
|
|
644
|
+
},
|
|
645
|
+
},
|
|
646
|
+
{
|
|
647
|
+
name: 'update_request',
|
|
648
|
+
description:
|
|
649
|
+
'Update fields of an existing SysTa request (talebi guncelle/duzenle) -- PARTIAL: only the fields you pass change. For STATUS use change_request_status, for ASSIGNEE use assign_request, for COMMENTS use add_request_comment (focused intents). Use this for title/description/category/department/priority/deadline/project edits. Resolve categoryId/departmentId/projectId by name first.',
|
|
650
|
+
inputSchema: {
|
|
651
|
+
type: 'object',
|
|
652
|
+
additionalProperties: false,
|
|
653
|
+
required: ['requestNumber'],
|
|
654
|
+
properties: {
|
|
655
|
+
requestNumber: { type: 'integer', description: 'Request number to update' },
|
|
656
|
+
title: { type: 'string', description: 'New title (min 5 chars)' },
|
|
657
|
+
description: { type: 'string', description: 'New description (plain text)' },
|
|
658
|
+
categoryId: { type: 'integer', description: 'Category id (resolve by name)' },
|
|
659
|
+
departmentId: { type: 'integer', description: 'Department id' },
|
|
660
|
+
projectId: { type: 'integer', description: 'Link to project id' },
|
|
661
|
+
priority: { type: 'string', description: 'Priority key/level' },
|
|
662
|
+
deadline: { type: 'string', description: 'Deadline (ISO date)' },
|
|
663
|
+
},
|
|
664
|
+
},
|
|
665
|
+
handler: (args) => {
|
|
666
|
+
const body = {};
|
|
667
|
+
for (const [k, v] of Object.entries(args)) {
|
|
668
|
+
if (k !== 'requestNumber' && v !== undefined && v !== null) {
|
|
669
|
+
body[k] = v;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
return systaFetch('PUT', `/requests/${encodeURIComponent(args.requestNumber)}`, { body });
|
|
673
|
+
},
|
|
674
|
+
},
|
|
675
|
+
{
|
|
676
|
+
name: 'assign_request',
|
|
677
|
+
description:
|
|
678
|
+
'Assign a SysTa request (talebi ata) to a user or teams -- "bana ata / X kullanicisina ata / su ekibe ata". For "bana/me" use your own user id (list_capabilities -> user.id). Resolve a person\'s name to a user id first. Wraps PUT /requests/:n with assignedTo / assignedTeams.',
|
|
679
|
+
inputSchema: {
|
|
680
|
+
type: 'object',
|
|
681
|
+
additionalProperties: false,
|
|
682
|
+
required: ['requestNumber'],
|
|
683
|
+
properties: {
|
|
684
|
+
requestNumber: { type: 'integer', description: 'Request number to assign' },
|
|
685
|
+
assignedTo: { type: 'integer', description: 'Assignee user id ("me" => your own id)' },
|
|
686
|
+
assignedTeams: {
|
|
687
|
+
type: 'array',
|
|
688
|
+
items: { type: 'integer' },
|
|
689
|
+
description: 'Team ids (optional, for team assignment)',
|
|
690
|
+
},
|
|
691
|
+
},
|
|
692
|
+
},
|
|
693
|
+
handler: (args) => {
|
|
694
|
+
const body = {};
|
|
695
|
+
if (args.assignedTo != null) {
|
|
696
|
+
body.assignedTo = args.assignedTo;
|
|
697
|
+
}
|
|
698
|
+
if (Array.isArray(args.assignedTeams)) {
|
|
699
|
+
body.assignedTeams = args.assignedTeams;
|
|
700
|
+
}
|
|
701
|
+
return systaFetch('PUT', `/requests/${encodeURIComponent(args.requestNumber)}`, { body });
|
|
702
|
+
},
|
|
703
|
+
},
|
|
704
|
+
{
|
|
705
|
+
name: 'get_request_board',
|
|
706
|
+
description:
|
|
707
|
+
"Read the kanban task board (gorev panosu) of a SysTa request: its columns and the cards in each. READ-only -- to add/edit a card use manage_request_card, to move/reorder use move_request_card; for the request's own fields/comments use get_request/add_request_comment. requestId is the request number.",
|
|
708
|
+
inputSchema: {
|
|
709
|
+
type: 'object',
|
|
710
|
+
additionalProperties: false,
|
|
711
|
+
required: ['requestId'],
|
|
712
|
+
properties: {
|
|
713
|
+
requestId: { type: 'integer', description: 'Request number whose board to read' },
|
|
714
|
+
assignedToMe: { type: 'boolean', description: 'Only cards assigned to me' },
|
|
715
|
+
unassigned: {
|
|
716
|
+
type: 'boolean',
|
|
717
|
+
description: 'Only unassigned cards (not with assignedToMe)',
|
|
718
|
+
},
|
|
719
|
+
priority: { type: 'string', description: 'Filter by card priority' },
|
|
720
|
+
includeArchived: { type: 'boolean', description: 'Include archived cards (default false)' },
|
|
721
|
+
},
|
|
722
|
+
},
|
|
723
|
+
handler: (args) =>
|
|
724
|
+
systaFetch('GET', `/requests/${encodeURIComponent(args.requestId)}/kanban`, {
|
|
725
|
+
query: {
|
|
726
|
+
assignedToMe: args.assignedToMe,
|
|
727
|
+
unassigned: args.unassigned,
|
|
728
|
+
priority: args.priority,
|
|
729
|
+
includeArchived: args.includeArchived,
|
|
730
|
+
},
|
|
731
|
+
}),
|
|
732
|
+
},
|
|
733
|
+
{
|
|
734
|
+
name: 'manage_request_card',
|
|
735
|
+
description:
|
|
736
|
+
'Create a card in a board column (requestId + columnId + cardTitle) OR update an existing card (requestId + cardId, partial fields). Does NOT move/reorder cards -- use move_request_card; read ids first with get_request_board. Resolve columnId and assignee ids before calling.',
|
|
737
|
+
inputSchema: {
|
|
738
|
+
type: 'object',
|
|
739
|
+
additionalProperties: false,
|
|
740
|
+
required: ['requestId'],
|
|
741
|
+
properties: {
|
|
742
|
+
requestId: { type: 'integer', description: 'Request number (board owner)' },
|
|
743
|
+
cardId: {
|
|
744
|
+
type: 'integer',
|
|
745
|
+
description: 'Existing card id -> UPDATE mode (omit to CREATE)',
|
|
746
|
+
},
|
|
747
|
+
columnId: { type: 'integer', description: 'Target column id (REQUIRED for create)' },
|
|
748
|
+
cardTitle: { type: 'string', description: 'Card title 3-1000 chars (REQUIRED for create)' },
|
|
749
|
+
cardDescription: { type: 'string' },
|
|
750
|
+
assignedUsers: {
|
|
751
|
+
type: 'array',
|
|
752
|
+
items: { type: 'integer' },
|
|
753
|
+
description: 'Assignee user ids',
|
|
754
|
+
},
|
|
755
|
+
assignedTeams: { type: 'array', items: { type: 'integer' } },
|
|
756
|
+
priority: { type: 'string', description: 'low|normal|high|urgent|critical' },
|
|
757
|
+
tags: { type: 'array', items: { type: 'string' } },
|
|
758
|
+
deadline: { type: 'string', description: 'ISO-8601 datetime' },
|
|
759
|
+
cardColor: { type: 'string', description: '#RRGGBB hex' },
|
|
760
|
+
isCompleted: { type: 'boolean' },
|
|
761
|
+
},
|
|
762
|
+
},
|
|
763
|
+
handler: (args) => {
|
|
764
|
+
const body = {};
|
|
765
|
+
for (const [k, v] of Object.entries(args)) {
|
|
766
|
+
if (k !== 'requestId' && k !== 'cardId' && v !== undefined && v !== null) {
|
|
767
|
+
body[k] = v;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
if (args.cardId != null) {
|
|
771
|
+
return systaFetch(
|
|
772
|
+
'PUT',
|
|
773
|
+
`/requests/${encodeURIComponent(args.requestId)}/kanban/cards/${encodeURIComponent(args.cardId)}`,
|
|
774
|
+
{ body },
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
return systaFetch('POST', `/requests/${encodeURIComponent(args.requestId)}/kanban/cards`, {
|
|
778
|
+
body,
|
|
779
|
+
});
|
|
780
|
+
},
|
|
781
|
+
},
|
|
782
|
+
{
|
|
783
|
+
name: 'move_request_card',
|
|
784
|
+
description:
|
|
785
|
+
'Move a card to a target column and/or reorder it (drag-drop). Use ONLY for column/position changes -- edit fields with manage_request_card, read ids with get_request_board. To reorder within the same column, set targetColumnId to the current column and pass beforeCardId/afterCardId.',
|
|
786
|
+
inputSchema: {
|
|
787
|
+
type: 'object',
|
|
788
|
+
additionalProperties: false,
|
|
789
|
+
required: ['requestId', 'cardId', 'targetColumnId'],
|
|
790
|
+
properties: {
|
|
791
|
+
requestId: { type: 'integer' },
|
|
792
|
+
cardId: { type: 'integer', description: 'Card to move (from get_request_board)' },
|
|
793
|
+
targetColumnId: { type: 'integer', description: 'Destination column id' },
|
|
794
|
+
beforeCardId: { type: 'integer', description: 'Place before this sibling (optional)' },
|
|
795
|
+
afterCardId: { type: 'integer', description: 'Place after this sibling (optional)' },
|
|
796
|
+
},
|
|
797
|
+
},
|
|
798
|
+
handler: (args) =>
|
|
799
|
+
systaFetch('POST', `/requests/${encodeURIComponent(args.requestId)}/kanban/cards/move`, {
|
|
800
|
+
body: {
|
|
801
|
+
cardId: args.cardId,
|
|
802
|
+
targetColumnId: args.targetColumnId,
|
|
803
|
+
...(args.beforeCardId != null ? { beforeCardId: args.beforeCardId } : {}),
|
|
804
|
+
...(args.afterCardId != null ? { afterCardId: args.afterCardId } : {}),
|
|
805
|
+
},
|
|
806
|
+
}),
|
|
807
|
+
},
|
|
808
|
+
{
|
|
809
|
+
name: 'request_approval',
|
|
810
|
+
description:
|
|
811
|
+
'Start an approval flow on a request (onaya sun): send an approval request to one or more approvers. Resolve approvalTypeId by name first (systa_api_call GET /approvals/types) and approver user ids by name. strategy: any_one (default) | all_required | quorum (with quorumCount). To RESPOND to an approval use respond_to_approval.',
|
|
812
|
+
inputSchema: {
|
|
813
|
+
type: 'object',
|
|
814
|
+
additionalProperties: false,
|
|
815
|
+
required: ['requestId', 'approvalTypeId', 'approverUserIds'],
|
|
816
|
+
properties: {
|
|
817
|
+
requestId: { type: 'integer', description: 'requests_table.id of the request' },
|
|
818
|
+
approvalTypeId: { type: 'integer', description: 'Approval type id (resolve by name)' },
|
|
819
|
+
approverUserIds: {
|
|
820
|
+
type: 'array',
|
|
821
|
+
items: { type: 'integer' },
|
|
822
|
+
minItems: 1,
|
|
823
|
+
description: 'Approver user ids',
|
|
824
|
+
},
|
|
825
|
+
strategy: { type: 'string', enum: ['any_one', 'all_required', 'quorum'] },
|
|
826
|
+
quorumCount: { type: 'integer', description: 'Required when strategy=quorum' },
|
|
827
|
+
requestNote: { type: 'string' },
|
|
828
|
+
},
|
|
829
|
+
},
|
|
830
|
+
handler: (args) => {
|
|
831
|
+
const body = {
|
|
832
|
+
requestId: args.requestId,
|
|
833
|
+
approvalTypeId: args.approvalTypeId,
|
|
834
|
+
approverUserIds: args.approverUserIds,
|
|
835
|
+
};
|
|
836
|
+
if (args.strategy) {
|
|
837
|
+
body.strategy = args.strategy;
|
|
838
|
+
}
|
|
839
|
+
if (args.quorumCount != null) {
|
|
840
|
+
body.quorumCount = args.quorumCount;
|
|
841
|
+
}
|
|
842
|
+
if (args.requestNote) {
|
|
843
|
+
body.requestNote = args.requestNote;
|
|
844
|
+
}
|
|
845
|
+
return systaFetch('POST', '/approvals/batch', { body });
|
|
846
|
+
},
|
|
847
|
+
},
|
|
848
|
+
{
|
|
849
|
+
name: 'respond_to_approval',
|
|
850
|
+
description:
|
|
851
|
+
'Respond to an approval assigned to you (onayla / reddet). id is the approval RESPONSE id (the approvalId/responseId from list_pending_approvals). decision must be "approved" or "rejected"; for a rejection add rejectionReasonText. To START an approval use request_approval.',
|
|
852
|
+
inputSchema: {
|
|
853
|
+
type: 'object',
|
|
854
|
+
additionalProperties: false,
|
|
855
|
+
required: ['id', 'decision'],
|
|
856
|
+
properties: {
|
|
857
|
+
id: { type: 'integer', description: 'Approval response id (from list_pending_approvals)' },
|
|
858
|
+
decision: { type: 'string', enum: ['approved', 'rejected'] },
|
|
859
|
+
responseNote: { type: 'string' },
|
|
860
|
+
rejectionReasonText: { type: 'string', description: 'Reason when rejecting' },
|
|
861
|
+
rejectionReasonCode: { type: 'string' },
|
|
862
|
+
},
|
|
863
|
+
},
|
|
864
|
+
handler: (args) => {
|
|
865
|
+
const body = { decision: args.decision };
|
|
866
|
+
for (const k of ['responseNote', 'rejectionReasonText', 'rejectionReasonCode']) {
|
|
867
|
+
if (args[k] != null) {
|
|
868
|
+
body[k] = args[k];
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
return systaFetch('POST', `/approvals/responses/${encodeURIComponent(args.id)}/respond`, {
|
|
872
|
+
body,
|
|
873
|
+
});
|
|
874
|
+
},
|
|
875
|
+
},
|
|
876
|
+
{
|
|
877
|
+
name: 'list_pending_approvals',
|
|
878
|
+
description:
|
|
879
|
+
'List approvals waiting on you (uzerimde bekleyen onaylar) -- "bana atanan, yanitlamadigim onaylar". Each row carries the approvalId/responseId you pass to respond_to_approval.',
|
|
880
|
+
inputSchema: {
|
|
881
|
+
type: 'object',
|
|
882
|
+
additionalProperties: false,
|
|
883
|
+
properties: {
|
|
884
|
+
includeSnoozed: { type: 'boolean' },
|
|
885
|
+
limit: { type: 'integer' },
|
|
886
|
+
offset: { type: 'integer' },
|
|
887
|
+
},
|
|
888
|
+
},
|
|
889
|
+
handler: (args) =>
|
|
890
|
+
systaFetch('GET', '/approvals/all-my-pending', {
|
|
891
|
+
query: {
|
|
892
|
+
includeSnoozed: args.includeSnoozed ? 'true' : undefined,
|
|
893
|
+
limit: args.limit,
|
|
894
|
+
offset: args.offset,
|
|
895
|
+
},
|
|
896
|
+
}),
|
|
897
|
+
},
|
|
898
|
+
{
|
|
899
|
+
name: 'create_project',
|
|
900
|
+
description:
|
|
901
|
+
'Create a SysTa project (proje), or a sub-project under parentProjectId. Only projectName is strictly required; companyId/department/organization/dates are optional but recommended. Resolve company/department ids by name first. For request task cards use manage_request_card, not this.',
|
|
902
|
+
inputSchema: {
|
|
903
|
+
type: 'object',
|
|
904
|
+
additionalProperties: false,
|
|
905
|
+
required: ['projectName'],
|
|
906
|
+
properties: {
|
|
907
|
+
projectName: { type: 'string', description: 'Project name' },
|
|
908
|
+
projectCode: { type: 'string' },
|
|
909
|
+
companyId: { type: 'integer' },
|
|
910
|
+
departmentId: { type: 'integer' },
|
|
911
|
+
organizationId: { type: 'integer' },
|
|
912
|
+
parentProjectId: { type: 'integer', description: 'Parent project id for a sub-project' },
|
|
913
|
+
description: { type: 'string' },
|
|
914
|
+
startDate: { type: 'string', description: 'ISO date' },
|
|
915
|
+
endDate: { type: 'string', description: 'ISO date' },
|
|
916
|
+
deadline: { type: 'string', description: 'ISO date' },
|
|
917
|
+
},
|
|
918
|
+
},
|
|
919
|
+
handler: (args) => {
|
|
920
|
+
const body = {};
|
|
921
|
+
for (const [k, v] of Object.entries(args)) {
|
|
922
|
+
if (v !== undefined && v !== null) {
|
|
923
|
+
body[k] = v;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
return systaFetch('POST', '/projects', { body });
|
|
927
|
+
},
|
|
928
|
+
},
|
|
929
|
+
{
|
|
930
|
+
name: 'get_project',
|
|
931
|
+
description: 'Get one SysTa project (proje) by its numeric id, including sub-projects.',
|
|
932
|
+
inputSchema: {
|
|
933
|
+
type: 'object',
|
|
934
|
+
additionalProperties: false,
|
|
935
|
+
required: ['id'],
|
|
936
|
+
properties: { id: { type: 'integer', description: 'Project id' } },
|
|
937
|
+
},
|
|
938
|
+
handler: (args) => systaFetch('GET', `/projects/${encodeURIComponent(args.id)}`),
|
|
939
|
+
},
|
|
940
|
+
{
|
|
941
|
+
name: 'update_project',
|
|
942
|
+
description:
|
|
943
|
+
'Update fields of an existing SysTa project (partial -- only the fields you send change). Resolve referenced ids by name first.',
|
|
944
|
+
inputSchema: {
|
|
945
|
+
type: 'object',
|
|
946
|
+
additionalProperties: false,
|
|
947
|
+
required: ['id'],
|
|
948
|
+
properties: {
|
|
949
|
+
id: { type: 'integer', description: 'Project id to update' },
|
|
950
|
+
projectName: { type: 'string' },
|
|
951
|
+
projectCode: { type: 'string' },
|
|
952
|
+
companyId: { type: 'integer' },
|
|
953
|
+
departmentId: { type: 'integer' },
|
|
954
|
+
organizationId: { type: 'integer' },
|
|
955
|
+
parentProjectId: { type: 'integer' },
|
|
956
|
+
description: { type: 'string' },
|
|
957
|
+
startDate: { type: 'string' },
|
|
958
|
+
endDate: { type: 'string' },
|
|
959
|
+
deadline: { type: 'string' },
|
|
960
|
+
},
|
|
961
|
+
},
|
|
962
|
+
handler: (args) => {
|
|
963
|
+
const body = {};
|
|
964
|
+
for (const [k, v] of Object.entries(args)) {
|
|
965
|
+
if (k !== 'id' && v !== undefined && v !== null) {
|
|
966
|
+
body[k] = v;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
return systaFetch('PUT', `/projects/${encodeURIComponent(args.id)}`, { body });
|
|
970
|
+
},
|
|
971
|
+
},
|
|
972
|
+
{
|
|
973
|
+
name: 'get_project_health',
|
|
974
|
+
description:
|
|
975
|
+
"Read a project's health, risk or SLA status (aspect) -- 'bu proje ne durumda, riskli mi, SLA tutuyor mu'. Read-only diagnostic for ONE project; for time-series charts use get_project_analytics, for many projects loop via list_projects.",
|
|
976
|
+
inputSchema: {
|
|
977
|
+
type: 'object',
|
|
978
|
+
additionalProperties: false,
|
|
979
|
+
required: ['projectId', 'aspect'],
|
|
980
|
+
properties: {
|
|
981
|
+
projectId: { type: 'integer' },
|
|
982
|
+
aspect: { type: 'string', enum: ['health', 'risk', 'sla'] },
|
|
983
|
+
period: { type: 'integer', description: 'Days 1-90 (health only, default 30)' },
|
|
984
|
+
includeHierarchy: { type: 'boolean', description: 'Include sub-projects' },
|
|
985
|
+
},
|
|
986
|
+
},
|
|
987
|
+
handler: (args) => {
|
|
988
|
+
const suffix = { health: '/health-score', risk: '/risk-score', sla: '/sla-status' }[
|
|
989
|
+
args.aspect
|
|
990
|
+
];
|
|
991
|
+
return systaFetch(
|
|
992
|
+
'GET',
|
|
993
|
+
`/projects/${encodeURIComponent(args.projectId)}/dashboard${suffix}`,
|
|
994
|
+
{
|
|
995
|
+
query: {
|
|
996
|
+
period: args.period,
|
|
997
|
+
includeHierarchy: args.includeHierarchy ? 'true' : undefined,
|
|
998
|
+
},
|
|
999
|
+
},
|
|
1000
|
+
);
|
|
1001
|
+
},
|
|
1002
|
+
},
|
|
1003
|
+
{
|
|
1004
|
+
name: 'get_project_analytics',
|
|
1005
|
+
description:
|
|
1006
|
+
"Read a project's time-series chart data (burndown/velocity/cumulative_flow/activity_heatmap/cycle_time) via chartType. Read-only chart feed; for a scalar health/risk score use get_project_health.",
|
|
1007
|
+
inputSchema: {
|
|
1008
|
+
type: 'object',
|
|
1009
|
+
additionalProperties: false,
|
|
1010
|
+
required: ['projectId', 'chartType'],
|
|
1011
|
+
properties: {
|
|
1012
|
+
projectId: { type: 'integer' },
|
|
1013
|
+
chartType: {
|
|
1014
|
+
type: 'string',
|
|
1015
|
+
enum: ['burndown', 'velocity', 'cumulative_flow', 'activity_heatmap', 'cycle_time'],
|
|
1016
|
+
},
|
|
1017
|
+
startDate: { type: 'string', description: 'YYYY-MM-DD' },
|
|
1018
|
+
endDate: { type: 'string', description: 'YYYY-MM-DD' },
|
|
1019
|
+
period: { type: 'string', description: "velocity: 'weekly'|'monthly'" },
|
|
1020
|
+
granularity: { type: 'string', description: "cumulative_flow: 'daily'|'weekly'" },
|
|
1021
|
+
months: { type: 'integer', description: 'activity_heatmap' },
|
|
1022
|
+
metric: { type: 'string', description: 'burndown metric' },
|
|
1023
|
+
count: { type: 'integer', description: 'velocity count' },
|
|
1024
|
+
includeHierarchy: { type: 'boolean' },
|
|
1025
|
+
},
|
|
1026
|
+
},
|
|
1027
|
+
handler: (args) => {
|
|
1028
|
+
const suffix = {
|
|
1029
|
+
burndown: '/burndown',
|
|
1030
|
+
velocity: '/velocity',
|
|
1031
|
+
cumulative_flow: '/cumulative-flow',
|
|
1032
|
+
activity_heatmap: '/activity-heatmap',
|
|
1033
|
+
cycle_time: '/cycle-time',
|
|
1034
|
+
}[args.chartType];
|
|
1035
|
+
return systaFetch(
|
|
1036
|
+
'GET',
|
|
1037
|
+
`/projects/${encodeURIComponent(args.projectId)}/dashboard${suffix}`,
|
|
1038
|
+
{
|
|
1039
|
+
query: {
|
|
1040
|
+
startDate: args.startDate,
|
|
1041
|
+
endDate: args.endDate,
|
|
1042
|
+
period: args.period,
|
|
1043
|
+
granularity: args.granularity,
|
|
1044
|
+
months: args.months,
|
|
1045
|
+
metric: args.metric,
|
|
1046
|
+
count: args.count,
|
|
1047
|
+
includeHierarchy: args.includeHierarchy ? 'true' : undefined,
|
|
1048
|
+
},
|
|
1049
|
+
},
|
|
1050
|
+
);
|
|
1051
|
+
},
|
|
1052
|
+
},
|
|
1053
|
+
{
|
|
1054
|
+
name: 'list_milestones',
|
|
1055
|
+
description:
|
|
1056
|
+
"List a project's milestones, or scope='summary' for a status-count summary. Read-only; create with create_milestone, complete with achieve_milestone.",
|
|
1057
|
+
inputSchema: {
|
|
1058
|
+
type: 'object',
|
|
1059
|
+
additionalProperties: false,
|
|
1060
|
+
required: ['projectId'],
|
|
1061
|
+
properties: {
|
|
1062
|
+
projectId: { type: 'integer' },
|
|
1063
|
+
scope: { type: 'string', enum: ['list', 'summary'] },
|
|
1064
|
+
},
|
|
1065
|
+
},
|
|
1066
|
+
handler: (args) =>
|
|
1067
|
+
systaFetch(
|
|
1068
|
+
'GET',
|
|
1069
|
+
`/projects/${encodeURIComponent(args.projectId)}/milestones${args.scope === 'summary' ? '/summary' : ''}`,
|
|
1070
|
+
),
|
|
1071
|
+
},
|
|
1072
|
+
{
|
|
1073
|
+
name: 'create_milestone',
|
|
1074
|
+
description:
|
|
1075
|
+
"Create a milestone on a project (kilometre tasi). NOT idempotent -- duplicate names create duplicates, so check list_milestones first. triggerConfig is required for non-'manual' milestoneType (call describe_endpoint for the per-type shape).",
|
|
1076
|
+
inputSchema: {
|
|
1077
|
+
type: 'object',
|
|
1078
|
+
additionalProperties: false,
|
|
1079
|
+
required: ['projectId', 'milestoneName', 'milestoneType'],
|
|
1080
|
+
properties: {
|
|
1081
|
+
projectId: { type: 'integer' },
|
|
1082
|
+
milestoneName: { type: 'string' },
|
|
1083
|
+
milestoneType: {
|
|
1084
|
+
type: 'string',
|
|
1085
|
+
enum: [
|
|
1086
|
+
'manual',
|
|
1087
|
+
'date_threshold',
|
|
1088
|
+
'percentage',
|
|
1089
|
+
'task_completion',
|
|
1090
|
+
'phase_gate',
|
|
1091
|
+
'kpi_metric',
|
|
1092
|
+
'composite',
|
|
1093
|
+
],
|
|
1094
|
+
},
|
|
1095
|
+
description: { type: 'string' },
|
|
1096
|
+
status: { type: 'string' },
|
|
1097
|
+
priority: { type: 'string' },
|
|
1098
|
+
targetDate: { type: 'string', description: 'ISO datetime with offset' },
|
|
1099
|
+
triggerConfig: {
|
|
1100
|
+
type: 'object',
|
|
1101
|
+
additionalProperties: true,
|
|
1102
|
+
description: 'Required for non-manual types; shape depends on milestoneType',
|
|
1103
|
+
},
|
|
1104
|
+
displayOrder: { type: 'integer' },
|
|
1105
|
+
color: { type: 'string' },
|
|
1106
|
+
icon: { type: 'string' },
|
|
1107
|
+
showOnGantt: { type: 'boolean' },
|
|
1108
|
+
},
|
|
1109
|
+
},
|
|
1110
|
+
handler: (args) => {
|
|
1111
|
+
const body = {};
|
|
1112
|
+
for (const [k, v] of Object.entries(args)) {
|
|
1113
|
+
if (k !== 'projectId' && v !== undefined && v !== null) {
|
|
1114
|
+
body[k] = v;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
return systaFetch('POST', `/projects/${encodeURIComponent(args.projectId)}/milestones`, {
|
|
1118
|
+
body,
|
|
1119
|
+
});
|
|
1120
|
+
},
|
|
1121
|
+
},
|
|
1122
|
+
{
|
|
1123
|
+
name: 'achieve_milestone',
|
|
1124
|
+
description:
|
|
1125
|
+
'Mark a milestone as achieved (kilometre tasini tamamla). No body. The backend checks dependencies + a valid transition; resolve the milestone id via list_milestones first.',
|
|
1126
|
+
inputSchema: {
|
|
1127
|
+
type: 'object',
|
|
1128
|
+
additionalProperties: false,
|
|
1129
|
+
required: ['projectId', 'id'],
|
|
1130
|
+
properties: {
|
|
1131
|
+
projectId: { type: 'integer' },
|
|
1132
|
+
id: { type: 'integer', description: 'Milestone id' },
|
|
1133
|
+
},
|
|
1134
|
+
},
|
|
1135
|
+
handler: (args) =>
|
|
1136
|
+
systaFetch(
|
|
1137
|
+
'PATCH',
|
|
1138
|
+
`/projects/${encodeURIComponent(args.projectId)}/milestones/${encodeURIComponent(args.id)}/achieve`,
|
|
1139
|
+
),
|
|
1140
|
+
},
|
|
1141
|
+
{
|
|
1142
|
+
name: 'manage_project_stakeholders',
|
|
1143
|
+
description:
|
|
1144
|
+
"Manage project stakeholders (paydas): action 'list' | 'add' | 'add_bulk' | 'remove'. Resolve userId/userIds by name first (ids, not names).",
|
|
1145
|
+
inputSchema: {
|
|
1146
|
+
type: 'object',
|
|
1147
|
+
additionalProperties: false,
|
|
1148
|
+
required: ['action', 'projectId'],
|
|
1149
|
+
properties: {
|
|
1150
|
+
action: { type: 'string', enum: ['list', 'add', 'add_bulk', 'remove'] },
|
|
1151
|
+
projectId: { type: 'integer' },
|
|
1152
|
+
userId: { type: 'integer', description: 'add/remove' },
|
|
1153
|
+
userIds: { type: 'array', items: { type: 'integer' }, description: 'add_bulk' },
|
|
1154
|
+
role: { type: 'string' },
|
|
1155
|
+
notes: { type: 'string' },
|
|
1156
|
+
includeHierarchy: { type: 'boolean', description: 'list' },
|
|
1157
|
+
},
|
|
1158
|
+
},
|
|
1159
|
+
handler: (args) => {
|
|
1160
|
+
const pid = encodeURIComponent(args.projectId);
|
|
1161
|
+
if (args.action === 'list') {
|
|
1162
|
+
return systaFetch('GET', `/stakeholders/projects/${pid}`, {
|
|
1163
|
+
query: { includeHierarchy: args.includeHierarchy ? 'true' : undefined },
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
if (args.action === 'add') {
|
|
1167
|
+
return systaFetch('POST', '/stakeholders', {
|
|
1168
|
+
body: {
|
|
1169
|
+
projectId: args.projectId,
|
|
1170
|
+
userId: args.userId,
|
|
1171
|
+
...(args.role ? { role: args.role } : {}),
|
|
1172
|
+
...(args.notes ? { notes: args.notes } : {}),
|
|
1173
|
+
},
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
if (args.action === 'add_bulk') {
|
|
1177
|
+
return systaFetch('POST', '/stakeholders/bulk', {
|
|
1178
|
+
body: {
|
|
1179
|
+
projectId: args.projectId,
|
|
1180
|
+
userIds: args.userIds,
|
|
1181
|
+
...(args.role ? { role: args.role } : {}),
|
|
1182
|
+
...(args.notes ? { notes: args.notes } : {}),
|
|
1183
|
+
},
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
return systaFetch(
|
|
1187
|
+
'DELETE',
|
|
1188
|
+
`/stakeholders/projects/${pid}/users/${encodeURIComponent(args.userId)}`,
|
|
1189
|
+
);
|
|
1190
|
+
},
|
|
1191
|
+
},
|
|
1192
|
+
{
|
|
1193
|
+
name: 'manage_project_notes',
|
|
1194
|
+
description:
|
|
1195
|
+
"Manage project notes and tags: action 'list'|'create'|'delete' (notes) and 'list_tags'|'add_tag'|'remove_tag' (tags). projectId is always in the path.",
|
|
1196
|
+
inputSchema: {
|
|
1197
|
+
type: 'object',
|
|
1198
|
+
additionalProperties: false,
|
|
1199
|
+
required: ['action', 'projectId'],
|
|
1200
|
+
properties: {
|
|
1201
|
+
action: {
|
|
1202
|
+
type: 'string',
|
|
1203
|
+
enum: ['list', 'create', 'delete', 'list_tags', 'add_tag', 'remove_tag'],
|
|
1204
|
+
},
|
|
1205
|
+
projectId: { type: 'integer' },
|
|
1206
|
+
noteContent: { type: 'string', description: 'create (1-10000)' },
|
|
1207
|
+
noteId: { type: 'integer', description: 'delete' },
|
|
1208
|
+
tagId: { type: 'integer', description: 'add_tag/remove_tag' },
|
|
1209
|
+
tagName: { type: 'string', description: 'add_tag (tagId OR tagName)' },
|
|
1210
|
+
},
|
|
1211
|
+
},
|
|
1212
|
+
handler: (args) => {
|
|
1213
|
+
const pid = encodeURIComponent(args.projectId);
|
|
1214
|
+
switch (args.action) {
|
|
1215
|
+
case 'list':
|
|
1216
|
+
return systaFetch('GET', `/projects/${pid}/notes`);
|
|
1217
|
+
case 'create':
|
|
1218
|
+
return systaFetch('POST', `/projects/${pid}/notes`, {
|
|
1219
|
+
body: { noteContent: args.noteContent },
|
|
1220
|
+
});
|
|
1221
|
+
case 'delete':
|
|
1222
|
+
return systaFetch('DELETE', `/projects/${pid}/notes/${encodeURIComponent(args.noteId)}`);
|
|
1223
|
+
case 'list_tags':
|
|
1224
|
+
return systaFetch('GET', `/projects/${pid}/tags`);
|
|
1225
|
+
case 'add_tag':
|
|
1226
|
+
return systaFetch('POST', `/projects/${pid}/tags`, {
|
|
1227
|
+
body: {
|
|
1228
|
+
...(args.tagId != null ? { tagId: args.tagId } : {}),
|
|
1229
|
+
...(args.tagName ? { tagName: args.tagName } : {}),
|
|
1230
|
+
},
|
|
1231
|
+
});
|
|
1232
|
+
default:
|
|
1233
|
+
return systaFetch('DELETE', `/projects/${pid}/tags/${encodeURIComponent(args.tagId)}`);
|
|
1234
|
+
}
|
|
1235
|
+
},
|
|
1236
|
+
},
|
|
1237
|
+
{
|
|
1238
|
+
name: 'create_plan_baseline',
|
|
1239
|
+
description:
|
|
1240
|
+
'Create a plan baseline/snapshot for a plan mode (POST /plan/modes/:modeId/baselines). Omit boards -- the server snapshots the mode live state (built for AI clients with no board state). Returns baselineId for submit_plan_for_approval.',
|
|
1241
|
+
inputSchema: {
|
|
1242
|
+
type: 'object',
|
|
1243
|
+
additionalProperties: false,
|
|
1244
|
+
required: ['modeId', 'baselineName'],
|
|
1245
|
+
properties: {
|
|
1246
|
+
modeId: { type: 'integer' },
|
|
1247
|
+
baselineName: { type: 'string' },
|
|
1248
|
+
boards: {
|
|
1249
|
+
type: 'array',
|
|
1250
|
+
items: { type: 'object', additionalProperties: true },
|
|
1251
|
+
description: 'Optional explicit board snapshot; usually omit',
|
|
1252
|
+
},
|
|
1253
|
+
},
|
|
1254
|
+
},
|
|
1255
|
+
handler: (args) => {
|
|
1256
|
+
const body = { baselineName: args.baselineName };
|
|
1257
|
+
if (Array.isArray(args.boards) && args.boards.length) {
|
|
1258
|
+
body.boards = args.boards;
|
|
1259
|
+
}
|
|
1260
|
+
return systaFetch('POST', `/plan/modes/${encodeURIComponent(args.modeId)}/baselines`, {
|
|
1261
|
+
body,
|
|
1262
|
+
});
|
|
1263
|
+
},
|
|
1264
|
+
},
|
|
1265
|
+
{
|
|
1266
|
+
name: 'submit_plan_for_approval',
|
|
1267
|
+
description:
|
|
1268
|
+
'Submit a plan baseline for approval (POST /plan/approvals/submit). strategy: any_one (default)|all_required|quorum (+quorumCount). deadline is ISO-8601 WITH offset. baselineId from create_plan_baseline; resolve approverUserIds by name.',
|
|
1269
|
+
inputSchema: {
|
|
1270
|
+
type: 'object',
|
|
1271
|
+
additionalProperties: false,
|
|
1272
|
+
required: ['baselineId', 'approverUserIds'],
|
|
1273
|
+
properties: {
|
|
1274
|
+
baselineId: { type: 'integer' },
|
|
1275
|
+
approverUserIds: { type: 'array', items: { type: 'integer' }, minItems: 1 },
|
|
1276
|
+
strategy: { type: 'string', enum: ['any_one', 'all_required', 'quorum'] },
|
|
1277
|
+
quorumCount: { type: 'integer' },
|
|
1278
|
+
requestNote: { type: 'string' },
|
|
1279
|
+
deadline: { type: 'string', description: 'ISO-8601 with offset' },
|
|
1280
|
+
lockedFields: { type: 'array', items: { type: 'string' } },
|
|
1281
|
+
periodStart: { type: 'string', description: 'YYYY-MM-DD' },
|
|
1282
|
+
periodEnd: { type: 'string', description: 'YYYY-MM-DD' },
|
|
1283
|
+
},
|
|
1284
|
+
},
|
|
1285
|
+
handler: (args) => {
|
|
1286
|
+
const body = { baselineId: args.baselineId, approverUserIds: args.approverUserIds };
|
|
1287
|
+
for (const k of [
|
|
1288
|
+
'strategy',
|
|
1289
|
+
'quorumCount',
|
|
1290
|
+
'requestNote',
|
|
1291
|
+
'deadline',
|
|
1292
|
+
'lockedFields',
|
|
1293
|
+
'periodStart',
|
|
1294
|
+
'periodEnd',
|
|
1295
|
+
]) {
|
|
1296
|
+
if (args[k] != null) {
|
|
1297
|
+
body[k] = args[k];
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
return systaFetch('POST', '/plan/approvals/submit', { body });
|
|
1301
|
+
},
|
|
1302
|
+
},
|
|
1303
|
+
{
|
|
1304
|
+
name: 'respond_to_plan_approval',
|
|
1305
|
+
description:
|
|
1306
|
+
"Respond to a plan approval: action 'respond' (decision approved/rejected on a RESPONSE id), 'revision' (request changes with a note on a RESPONSE id), or 'cancel' (requester cancels an APPROVAL REQUEST id). id meaning depends on action.",
|
|
1307
|
+
inputSchema: {
|
|
1308
|
+
type: 'object',
|
|
1309
|
+
additionalProperties: false,
|
|
1310
|
+
required: ['action', 'id'],
|
|
1311
|
+
properties: {
|
|
1312
|
+
action: { type: 'string', enum: ['respond', 'revision', 'cancel'] },
|
|
1313
|
+
id: {
|
|
1314
|
+
type: 'integer',
|
|
1315
|
+
description: 'response id (respond/revision) or approvalRequest id (cancel)',
|
|
1316
|
+
},
|
|
1317
|
+
decision: { type: 'string', enum: ['approved', 'rejected'], description: 'respond' },
|
|
1318
|
+
responseNote: { type: 'string' },
|
|
1319
|
+
rejectionReason: { type: 'string' },
|
|
1320
|
+
},
|
|
1321
|
+
},
|
|
1322
|
+
handler: (args) => {
|
|
1323
|
+
const id = encodeURIComponent(args.id);
|
|
1324
|
+
if (args.action === 'respond') {
|
|
1325
|
+
const body = { decision: args.decision };
|
|
1326
|
+
if (args.responseNote != null) {
|
|
1327
|
+
body.responseNote = args.responseNote;
|
|
1328
|
+
}
|
|
1329
|
+
if (args.rejectionReason != null) {
|
|
1330
|
+
body.rejectionReason = args.rejectionReason;
|
|
1331
|
+
}
|
|
1332
|
+
return systaFetch('POST', `/plan/approvals/responses/${id}/respond`, { body });
|
|
1333
|
+
}
|
|
1334
|
+
if (args.action === 'revision') {
|
|
1335
|
+
return systaFetch('POST', `/plan/approvals/responses/${id}/revision`, {
|
|
1336
|
+
body: { responseNote: args.responseNote },
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
return systaFetch('POST', `/plan/approvals/${id}/cancel`);
|
|
1340
|
+
},
|
|
1341
|
+
},
|
|
1342
|
+
{
|
|
1343
|
+
name: 'get_plan_critical_path',
|
|
1344
|
+
description:
|
|
1345
|
+
'Get the critical path (nodes, durations, slack) for a set of requests. requestIds come from list_requests or plan board cards.',
|
|
1346
|
+
inputSchema: {
|
|
1347
|
+
type: 'object',
|
|
1348
|
+
additionalProperties: false,
|
|
1349
|
+
required: ['requestIds'],
|
|
1350
|
+
properties: {
|
|
1351
|
+
requestIds: { type: 'array', items: { type: 'integer' }, description: 'Request ids' },
|
|
1352
|
+
},
|
|
1353
|
+
},
|
|
1354
|
+
handler: (args) => {
|
|
1355
|
+
const ids = Array.isArray(args.requestIds)
|
|
1356
|
+
? args.requestIds.join(',')
|
|
1357
|
+
: String(args.requestIds);
|
|
1358
|
+
return systaFetch('GET', '/plan/critical-path', { query: { requestIds: ids } });
|
|
1359
|
+
},
|
|
1360
|
+
},
|
|
1361
|
+
{
|
|
1362
|
+
name: 'distribute_plan_schedule',
|
|
1363
|
+
description:
|
|
1364
|
+
'Compute a proposed schedule distribution across plan boards (POST /plan/distribute). READ-ONLY despite POST -- returns proposed per-board assignments, persists NOTHING. Usually call with no args (server uses the mode boards).',
|
|
1365
|
+
inputSchema: {
|
|
1366
|
+
type: 'object',
|
|
1367
|
+
additionalProperties: false,
|
|
1368
|
+
properties: {
|
|
1369
|
+
boards: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
1370
|
+
options: { type: 'object', additionalProperties: true },
|
|
1371
|
+
modeId: { type: 'integer' },
|
|
1372
|
+
fields: { type: 'string', description: 'Query projection' },
|
|
1373
|
+
},
|
|
1374
|
+
},
|
|
1375
|
+
handler: (args) => {
|
|
1376
|
+
const body = {};
|
|
1377
|
+
for (const k of ['boards', 'options', 'modeId']) {
|
|
1378
|
+
if (args[k] != null) {
|
|
1379
|
+
body[k] = args[k];
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
return systaFetch('POST', '/plan/distribute', {
|
|
1383
|
+
body,
|
|
1384
|
+
query: args.fields ? { fields: args.fields } : undefined,
|
|
1385
|
+
});
|
|
1386
|
+
},
|
|
1387
|
+
},
|
|
1388
|
+
{
|
|
1389
|
+
name: 'create_release_list',
|
|
1390
|
+
description:
|
|
1391
|
+
"Create a release list (surum listesi) for a company on a deployment date. releaseType 'standard' (default) | 'emergency' (+emergencyJustification). companyId must be a resolved numeric id.",
|
|
1392
|
+
inputSchema: {
|
|
1393
|
+
type: 'object',
|
|
1394
|
+
additionalProperties: false,
|
|
1395
|
+
required: ['companyId', 'deploymentDate'],
|
|
1396
|
+
properties: {
|
|
1397
|
+
companyId: { type: 'integer' },
|
|
1398
|
+
deploymentDate: { type: 'string', description: 'ISO date/datetime' },
|
|
1399
|
+
plannedStartTime: { type: 'string', description: 'HH:mm' },
|
|
1400
|
+
requireAllStakeholderApprovals: { type: 'boolean' },
|
|
1401
|
+
releaseType: { type: 'string', enum: ['standard', 'emergency'] },
|
|
1402
|
+
emergencyJustification: { type: 'string' },
|
|
1403
|
+
},
|
|
1404
|
+
},
|
|
1405
|
+
handler: (args) => {
|
|
1406
|
+
const body = {};
|
|
1407
|
+
for (const [k, v] of Object.entries(args)) {
|
|
1408
|
+
if (v !== undefined && v !== null) {
|
|
1409
|
+
body[k] = v;
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
return systaFetch('POST', '/releases/lists', { body });
|
|
1413
|
+
},
|
|
1414
|
+
},
|
|
1415
|
+
{
|
|
1416
|
+
name: 'add_release_item',
|
|
1417
|
+
description:
|
|
1418
|
+
"Add a request to a release list as a release item (POST /releases/items). Pass 'items' (array) for a batch, or a single listId+requestId. Resolve developer/analyst/stakeholder ids by name.",
|
|
1419
|
+
inputSchema: {
|
|
1420
|
+
type: 'object',
|
|
1421
|
+
additionalProperties: false,
|
|
1422
|
+
required: ['listId', 'requestId'],
|
|
1423
|
+
properties: {
|
|
1424
|
+
listId: { type: 'integer' },
|
|
1425
|
+
requestId: { type: 'integer' },
|
|
1426
|
+
plannedProdDate: { type: 'string', description: 'ISO date' },
|
|
1427
|
+
developerIds: { type: 'array', items: { type: 'integer' } },
|
|
1428
|
+
analystIds: { type: 'array', items: { type: 'integer' } },
|
|
1429
|
+
stakeholderIds: { type: 'array', items: { type: 'integer' } },
|
|
1430
|
+
stakeholderRoles: { type: 'object', additionalProperties: true },
|
|
1431
|
+
items: {
|
|
1432
|
+
type: 'array',
|
|
1433
|
+
items: { type: 'object', additionalProperties: true },
|
|
1434
|
+
description: 'Batch: array of full item objects (overrides the single fields)',
|
|
1435
|
+
},
|
|
1436
|
+
},
|
|
1437
|
+
},
|
|
1438
|
+
handler: (args) => {
|
|
1439
|
+
if (Array.isArray(args.items) && args.items.length) {
|
|
1440
|
+
return systaFetch('POST', '/releases/items', { body: args.items });
|
|
1441
|
+
}
|
|
1442
|
+
const body = {};
|
|
1443
|
+
for (const [k, v] of Object.entries(args)) {
|
|
1444
|
+
if (k !== 'items' && v !== undefined && v !== null) {
|
|
1445
|
+
body[k] = v;
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
return systaFetch('POST', '/releases/items', { body });
|
|
1449
|
+
},
|
|
1450
|
+
},
|
|
1451
|
+
{
|
|
1452
|
+
name: 'advance_release_workflow',
|
|
1453
|
+
description:
|
|
1454
|
+
"Advance a release list deployment workflow one ordered step (close_servers -> start_db_migration -> start_server_update -> open_servers -> complete; or fail/reset). Idempotent: re-issuing the current step is safe. Only 'fail' takes errorMessage. Admin manual-override is intentionally excluded.",
|
|
1455
|
+
inputSchema: {
|
|
1456
|
+
type: 'object',
|
|
1457
|
+
additionalProperties: false,
|
|
1458
|
+
required: ['listId', 'step'],
|
|
1459
|
+
properties: {
|
|
1460
|
+
listId: { type: 'integer' },
|
|
1461
|
+
step: {
|
|
1462
|
+
type: 'string',
|
|
1463
|
+
enum: [
|
|
1464
|
+
'close_servers',
|
|
1465
|
+
'start_db_migration',
|
|
1466
|
+
'start_server_update',
|
|
1467
|
+
'open_servers',
|
|
1468
|
+
'complete',
|
|
1469
|
+
'fail',
|
|
1470
|
+
'reset',
|
|
1471
|
+
],
|
|
1472
|
+
},
|
|
1473
|
+
errorMessage: { type: 'string', description: "only for step='fail'" },
|
|
1474
|
+
},
|
|
1475
|
+
},
|
|
1476
|
+
handler: (args) => {
|
|
1477
|
+
const map = {
|
|
1478
|
+
close_servers: 'close-servers',
|
|
1479
|
+
start_db_migration: 'start-db-migration',
|
|
1480
|
+
start_server_update: 'start-server-update',
|
|
1481
|
+
open_servers: 'open-servers',
|
|
1482
|
+
complete: 'complete',
|
|
1483
|
+
fail: 'fail',
|
|
1484
|
+
reset: 'reset',
|
|
1485
|
+
};
|
|
1486
|
+
const action = map[args.step];
|
|
1487
|
+
return systaFetch(
|
|
1488
|
+
'PUT',
|
|
1489
|
+
`/releases/lists/${encodeURIComponent(args.listId)}/workflow/${action}`,
|
|
1490
|
+
args.step === 'fail' ? { body: { errorMessage: args.errorMessage } } : {},
|
|
1491
|
+
);
|
|
1492
|
+
},
|
|
1493
|
+
},
|
|
1494
|
+
{
|
|
1495
|
+
name: 'querybuilder_ai_generate_query',
|
|
1496
|
+
description:
|
|
1497
|
+
'Generate a report query config from natural language (POST /query-builder/ai/generate). Returns a queryConfig you feed to querybuilder_execute_query (mode=preview). It only DRAFTS a query -- no result rows.',
|
|
1498
|
+
inputSchema: {
|
|
1499
|
+
type: 'object',
|
|
1500
|
+
additionalProperties: false,
|
|
1501
|
+
required: ['text', 'reportType'],
|
|
1502
|
+
properties: {
|
|
1503
|
+
text: { type: 'string', description: '3-500 chars natural-language request' },
|
|
1504
|
+
reportType: {
|
|
1505
|
+
type: 'string',
|
|
1506
|
+
enum: ['request', 'sla', 'user', 'organization', 'project', 'lifecycle', 'subform'],
|
|
1507
|
+
},
|
|
1508
|
+
language: { type: 'string' },
|
|
1509
|
+
model: { type: 'string' },
|
|
1510
|
+
},
|
|
1511
|
+
},
|
|
1512
|
+
handler: (args) => {
|
|
1513
|
+
const body = {
|
|
1514
|
+
text: args.text,
|
|
1515
|
+
reportType: args.reportType,
|
|
1516
|
+
language: args.language ?? 'tr',
|
|
1517
|
+
};
|
|
1518
|
+
if (args.model) {
|
|
1519
|
+
body.model = args.model;
|
|
1520
|
+
}
|
|
1521
|
+
return systaFetch('POST', '/query-builder/ai/generate', { body });
|
|
1522
|
+
},
|
|
1523
|
+
},
|
|
1524
|
+
{
|
|
1525
|
+
name: 'querybuilder_execute_query',
|
|
1526
|
+
description:
|
|
1527
|
+
"Run a report query: mode 'preview' (25 rows, validate first), 'execute' (full rows, limit/offset), or 'export' (xlsx/csv). queryConfig.fields is required. Always preview before execute. Rate-limited.",
|
|
1528
|
+
inputSchema: {
|
|
1529
|
+
type: 'object',
|
|
1530
|
+
additionalProperties: false,
|
|
1531
|
+
required: ['mode', 'reportType', 'queryConfig'],
|
|
1532
|
+
properties: {
|
|
1533
|
+
mode: { type: 'string', enum: ['preview', 'execute', 'export'] },
|
|
1534
|
+
reportType: {
|
|
1535
|
+
type: 'string',
|
|
1536
|
+
enum: ['request', 'sla', 'user', 'organization', 'project', 'lifecycle', 'subform'],
|
|
1537
|
+
},
|
|
1538
|
+
queryConfig: {
|
|
1539
|
+
type: 'object',
|
|
1540
|
+
additionalProperties: true,
|
|
1541
|
+
description: '{fields:[],rules?,orderBy?,groupBy?,aggregations?,having?}',
|
|
1542
|
+
},
|
|
1543
|
+
limit: { type: 'integer' },
|
|
1544
|
+
offset: { type: 'integer' },
|
|
1545
|
+
parameterValues: { type: 'object', additionalProperties: true },
|
|
1546
|
+
format: { type: 'string', enum: ['xlsx', 'csv'], description: 'required for export' },
|
|
1547
|
+
conditionalFormats: {
|
|
1548
|
+
type: 'array',
|
|
1549
|
+
items: { type: 'object', additionalProperties: true },
|
|
1550
|
+
},
|
|
1551
|
+
},
|
|
1552
|
+
},
|
|
1553
|
+
handler: (args) => {
|
|
1554
|
+
const base = { reportType: args.reportType, queryConfig: args.queryConfig };
|
|
1555
|
+
if (args.mode === 'preview') {
|
|
1556
|
+
return systaFetch('POST', '/query-builder/preview', { body: base });
|
|
1557
|
+
}
|
|
1558
|
+
if (args.mode === 'execute') {
|
|
1559
|
+
const body = { ...base };
|
|
1560
|
+
for (const k of ['limit', 'offset', 'parameterValues']) {
|
|
1561
|
+
if (args[k] != null) {
|
|
1562
|
+
body[k] = args[k];
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
return systaFetch('POST', '/query-builder/execute', { body });
|
|
1566
|
+
}
|
|
1567
|
+
const body = { ...base, format: args.format };
|
|
1568
|
+
for (const k of ['limit', 'conditionalFormats']) {
|
|
1569
|
+
if (args[k] != null) {
|
|
1570
|
+
body[k] = args[k];
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
return systaFetch('POST', '/query-builder/export', { body });
|
|
1574
|
+
},
|
|
1575
|
+
},
|
|
1576
|
+
{
|
|
1577
|
+
name: 'querybuilder_describe_fields',
|
|
1578
|
+
description:
|
|
1579
|
+
"Discover queryable fields for a reportType: mode 'fields' (metadata/fieldKeys), 'options' (filter value options), or 'suggest' (q search). Never invent field names -- read mode='fields' first.",
|
|
1580
|
+
inputSchema: {
|
|
1581
|
+
type: 'object',
|
|
1582
|
+
additionalProperties: false,
|
|
1583
|
+
required: ['mode', 'reportType'],
|
|
1584
|
+
properties: {
|
|
1585
|
+
mode: { type: 'string', enum: ['fields', 'options', 'suggest'] },
|
|
1586
|
+
reportType: {
|
|
1587
|
+
type: 'string',
|
|
1588
|
+
enum: ['request', 'sla', 'user', 'organization', 'project', 'lifecycle', 'subform'],
|
|
1589
|
+
},
|
|
1590
|
+
q: { type: 'string', description: "required for mode='suggest'" },
|
|
1591
|
+
},
|
|
1592
|
+
},
|
|
1593
|
+
handler: (args) => {
|
|
1594
|
+
const path = {
|
|
1595
|
+
fields: '/query-builder/metadata',
|
|
1596
|
+
options: '/query-builder/field-options',
|
|
1597
|
+
suggest: '/query-builder/suggest-fields',
|
|
1598
|
+
}[args.mode];
|
|
1599
|
+
return systaFetch('GET', path, {
|
|
1600
|
+
query: { reportType: args.reportType, ...(args.mode === 'suggest' ? { q: args.q } : {}) },
|
|
1601
|
+
});
|
|
1602
|
+
},
|
|
1603
|
+
},
|
|
1604
|
+
{
|
|
1605
|
+
name: 'querybuilder_saved_query',
|
|
1606
|
+
description:
|
|
1607
|
+
"Saved report queries: mode 'list' (mine+shared), 'save' (name+reportType+queryConfig), or 'run' (id +parameterValues). update/delete/clone/share are intentionally excluded (use systa_api_call). Validate config with querybuilder_execute_query mode=preview before saving.",
|
|
1608
|
+
inputSchema: {
|
|
1609
|
+
type: 'object',
|
|
1610
|
+
additionalProperties: false,
|
|
1611
|
+
required: ['mode'],
|
|
1612
|
+
properties: {
|
|
1613
|
+
mode: { type: 'string', enum: ['list', 'save', 'run'] },
|
|
1614
|
+
id: { type: 'integer', description: 'run' },
|
|
1615
|
+
name: { type: 'string' },
|
|
1616
|
+
description: { type: 'string' },
|
|
1617
|
+
reportType: {
|
|
1618
|
+
type: 'string',
|
|
1619
|
+
enum: ['request', 'sla', 'user', 'organization', 'project', 'lifecycle', 'subform'],
|
|
1620
|
+
},
|
|
1621
|
+
queryConfig: { type: 'object', additionalProperties: true },
|
|
1622
|
+
isShared: { type: 'boolean' },
|
|
1623
|
+
parameters: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
1624
|
+
categoryId: { type: 'integer' },
|
|
1625
|
+
parameterValues: { type: 'object', additionalProperties: true, description: 'run' },
|
|
1626
|
+
},
|
|
1627
|
+
},
|
|
1628
|
+
handler: (args) => {
|
|
1629
|
+
if (args.mode === 'list') {
|
|
1630
|
+
return systaFetch('GET', '/query-builder/saved');
|
|
1631
|
+
}
|
|
1632
|
+
if (args.mode === 'save') {
|
|
1633
|
+
const body = {
|
|
1634
|
+
name: args.name,
|
|
1635
|
+
reportType: args.reportType,
|
|
1636
|
+
queryConfig: args.queryConfig,
|
|
1637
|
+
};
|
|
1638
|
+
for (const k of ['description', 'isShared', 'parameters', 'categoryId']) {
|
|
1639
|
+
if (args[k] != null) {
|
|
1640
|
+
body[k] = args[k];
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
return systaFetch('POST', '/query-builder/saved', { body });
|
|
1644
|
+
}
|
|
1645
|
+
return systaFetch(
|
|
1646
|
+
'POST',
|
|
1647
|
+
`/query-builder/saved/${encodeURIComponent(args.id)}/execute`,
|
|
1648
|
+
args.parameterValues != null ? { body: { parameterValues: args.parameterValues } } : {},
|
|
1649
|
+
);
|
|
1650
|
+
},
|
|
1651
|
+
},
|
|
1652
|
+
{
|
|
1653
|
+
name: 'querybuilder_schedule_report',
|
|
1654
|
+
description:
|
|
1655
|
+
'Schedule a saved query to run on a cadence (POST /query-builder/schedules). queryId is a saved query id (resolve via querybuilder_saved_query). frequency daily/weekly/monthly + timeOfDay HH:MM. Does NOT run it now -- use querybuilder_execute_query for that.',
|
|
1656
|
+
inputSchema: {
|
|
1657
|
+
type: 'object',
|
|
1658
|
+
additionalProperties: false,
|
|
1659
|
+
required: ['queryId', 'scheduleName', 'frequency', 'timeOfDay'],
|
|
1660
|
+
properties: {
|
|
1661
|
+
queryId: { type: 'integer' },
|
|
1662
|
+
scheduleName: { type: 'string' },
|
|
1663
|
+
frequency: { type: 'string', enum: ['daily', 'weekly', 'monthly'] },
|
|
1664
|
+
timeOfDay: { type: 'string', description: 'HH:MM' },
|
|
1665
|
+
dayOfWeek: { type: 'integer', description: '0-6 (weekly)' },
|
|
1666
|
+
dayOfMonth: { type: 'integer', description: '1-31 (monthly)' },
|
|
1667
|
+
timezone: { type: 'string' },
|
|
1668
|
+
exportFormat: { type: 'string', enum: ['xlsx', 'csv'] },
|
|
1669
|
+
recipients: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
1670
|
+
},
|
|
1671
|
+
},
|
|
1672
|
+
handler: (args) => {
|
|
1673
|
+
const body = {
|
|
1674
|
+
queryId: args.queryId,
|
|
1675
|
+
scheduleName: args.scheduleName,
|
|
1676
|
+
frequency: args.frequency,
|
|
1677
|
+
timeOfDay: args.timeOfDay,
|
|
1678
|
+
};
|
|
1679
|
+
for (const k of ['dayOfWeek', 'dayOfMonth', 'timezone', 'exportFormat', 'recipients']) {
|
|
1680
|
+
if (args[k] != null) {
|
|
1681
|
+
body[k] = args[k];
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
return systaFetch('POST', '/query-builder/schedules', { body });
|
|
1685
|
+
},
|
|
1686
|
+
},
|
|
1687
|
+
{
|
|
1688
|
+
name: 'systa_api_call',
|
|
1689
|
+
description:
|
|
1690
|
+
'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").',
|
|
1691
|
+
inputSchema: {
|
|
1692
|
+
type: 'object',
|
|
1693
|
+
additionalProperties: false,
|
|
1694
|
+
required: ['method', 'path'],
|
|
1695
|
+
properties: {
|
|
1696
|
+
method: { type: 'string', enum: ALLOWED_METHODS, description: 'HTTP method' },
|
|
1697
|
+
path: { type: 'string', description: 'Path relative to the API base, must start with "/"' },
|
|
1698
|
+
query: {
|
|
1699
|
+
type: 'object',
|
|
1700
|
+
description: 'Query string parameters',
|
|
1701
|
+
additionalProperties: true,
|
|
1702
|
+
},
|
|
1703
|
+
body: { type: 'object', description: 'JSON request body', additionalProperties: true },
|
|
1704
|
+
},
|
|
1705
|
+
},
|
|
1706
|
+
handler: (args) => {
|
|
1707
|
+
const method = String(args.method || '').toUpperCase();
|
|
1708
|
+
if (!ALLOWED_METHODS.includes(method)) {
|
|
1709
|
+
throw new Error(`Method not allowed: ${args.method}`);
|
|
1710
|
+
}
|
|
1711
|
+
if (typeof args.path !== 'string' || !args.path.startsWith('/')) {
|
|
1712
|
+
throw new Error('path must start with "/"');
|
|
1713
|
+
}
|
|
1714
|
+
return systaFetch(method, args.path, { query: args.query, body: args.body });
|
|
1715
|
+
},
|
|
1716
|
+
},
|
|
1717
|
+
{
|
|
1718
|
+
name: 'download_request_file',
|
|
1719
|
+
description:
|
|
1720
|
+
'Download a SysTa attachment (request/task-card/project file) to the LOCAL disk and ' +
|
|
1721
|
+
'return the saved path. Requires the "file.download" scope; all access checks run ' +
|
|
1722
|
+
'server-side. fileId comes from file list endpoints (e.g. GET /requests/:n/files). ' +
|
|
1723
|
+
'Saves into a temp directory unless savePath is given.',
|
|
1724
|
+
inputSchema: {
|
|
1725
|
+
type: 'object',
|
|
1726
|
+
additionalProperties: false,
|
|
1727
|
+
required: ['fileId'],
|
|
1728
|
+
properties: {
|
|
1729
|
+
fileId: { type: 'integer', description: 'File id (from a file list endpoint)' },
|
|
1730
|
+
savePath: {
|
|
1731
|
+
type: 'string',
|
|
1732
|
+
description:
|
|
1733
|
+
'Optional local target: an existing directory OR a full file path. Default: OS temp dir.',
|
|
1734
|
+
},
|
|
1735
|
+
},
|
|
1736
|
+
},
|
|
1737
|
+
handler: async (args) => {
|
|
1738
|
+
const res = await systaFetchBinary(`/files/${encodeURIComponent(args.fileId)}/download`);
|
|
1739
|
+
if (!res.ok) {
|
|
1740
|
+
return { status: res.status, ok: false, data: res.data };
|
|
1741
|
+
}
|
|
1742
|
+
const fileName = sanitizeFileName(res.fileName, `file-${args.fileId}`);
|
|
1743
|
+
const savedPath = resolveSavePath(fileName, args.savePath);
|
|
1744
|
+
fs.writeFileSync(savedPath, res.buffer);
|
|
1745
|
+
return {
|
|
1746
|
+
status: res.status,
|
|
1747
|
+
ok: true,
|
|
1748
|
+
savedPath,
|
|
1749
|
+
fileName,
|
|
1750
|
+
mimeType: res.mimeType,
|
|
1751
|
+
sizeBytes: res.buffer.length,
|
|
1752
|
+
};
|
|
1753
|
+
},
|
|
1754
|
+
},
|
|
1755
|
+
{
|
|
1756
|
+
name: 'upload_file_to',
|
|
1757
|
+
description:
|
|
1758
|
+
'Upload local file(s) as SysTa attachments (multipart) to a request/task-card/project. Requires the "file.upload" scope; ' +
|
|
1759
|
+
'security checks (extension/MIME allowlist, magic bytes, rate limit) run server-side — ' +
|
|
1760
|
+
'executable/script extensions are rejected. Common paths: "/requests/:requestNumber/files" ' +
|
|
1761
|
+
'(request attachment), "/projects/:projectId/files" (project file), ' +
|
|
1762
|
+
'"/requests/:requestId/kanban/cards/:cardId/files" (task card file), ' +
|
|
1763
|
+
'"/files/orphan-upload" (pre-upload for sub-forms). Use method PUT with exactly one file ' +
|
|
1764
|
+
'for "/requests/:n/files/:id" (replace an existing file). The multipart field name is set ' +
|
|
1765
|
+
"automatically to 'file' — you only pass filePaths; you never set the field name yourself.",
|
|
1766
|
+
inputSchema: {
|
|
1767
|
+
type: 'object',
|
|
1768
|
+
additionalProperties: false,
|
|
1769
|
+
required: ['path', 'filePaths'],
|
|
1770
|
+
properties: {
|
|
1771
|
+
path: {
|
|
1772
|
+
type: 'string',
|
|
1773
|
+
description: 'Upload endpoint path relative to the API base, must start with "/"',
|
|
1774
|
+
},
|
|
1775
|
+
filePaths: {
|
|
1776
|
+
type: 'array',
|
|
1777
|
+
minItems: 1,
|
|
1778
|
+
maxItems: 50,
|
|
1779
|
+
items: { type: 'string' },
|
|
1780
|
+
description: 'Absolute local file paths to upload',
|
|
1781
|
+
},
|
|
1782
|
+
method: {
|
|
1783
|
+
type: 'string',
|
|
1784
|
+
enum: ['POST', 'PUT'],
|
|
1785
|
+
description: 'HTTP method (default POST)',
|
|
1786
|
+
},
|
|
1787
|
+
},
|
|
1788
|
+
},
|
|
1789
|
+
handler: (args) => {
|
|
1790
|
+
if (typeof args.path !== 'string' || !args.path.startsWith('/')) {
|
|
1791
|
+
throw new Error('path must start with "/"');
|
|
1792
|
+
}
|
|
1793
|
+
const method = args.method ? String(args.method).toUpperCase() : 'POST';
|
|
1794
|
+
if (!['POST', 'PUT'].includes(method)) {
|
|
1795
|
+
throw new Error(`Method not allowed for upload: ${args.method}`);
|
|
1796
|
+
}
|
|
1797
|
+
const filePaths = Array.isArray(args.filePaths) ? args.filePaths : [];
|
|
1798
|
+
if (filePaths.length < 1 || filePaths.length > 50) {
|
|
1799
|
+
throw new Error('filePaths must contain between 1 and 50 local file paths');
|
|
1800
|
+
}
|
|
1801
|
+
return systaUploadMultipart(args.path, method, filePaths);
|
|
1802
|
+
},
|
|
1803
|
+
},
|
|
1804
|
+
{
|
|
1805
|
+
name: 'list_capabilities',
|
|
1806
|
+
description:
|
|
1807
|
+
'Discover what THIS API key can actually do. Returns a scope-filtered catalog of ' +
|
|
1808
|
+
'modules and endpoints the key is permitted to call (out-of-scope endpoints are not ' +
|
|
1809
|
+
'shown), each with its HTTP methods, safety class (read_only/idempotent/mutating/' +
|
|
1810
|
+
'destructive) and required permission. Call this FIRST to learn available capabilities, ' +
|
|
1811
|
+
'then describe_endpoint for parameter details. Compact view — no field schemas.',
|
|
1812
|
+
inputSchema: { type: 'object', additionalProperties: false, properties: {} },
|
|
1813
|
+
handler: async () => {
|
|
1814
|
+
const res = await systaFetch('GET', '/api-keys/me/capabilities');
|
|
1815
|
+
if (!res.ok) {
|
|
1816
|
+
return res;
|
|
1817
|
+
}
|
|
1818
|
+
return shapeCompact(unwrapCapabilities(res));
|
|
1819
|
+
},
|
|
1820
|
+
},
|
|
1821
|
+
{
|
|
1822
|
+
name: 'describe_module',
|
|
1823
|
+
description:
|
|
1824
|
+
'Orient on ONE SysTa module: returns its purpose (description, TR/EN), key concepts, ' +
|
|
1825
|
+
'and the endpoints THIS key can call within it (path + summary + method/safety). Use the ' +
|
|
1826
|
+
'module name from list_capabilities (e.g. "request", "kanban", "plan", "sla"). Helps map a ' +
|
|
1827
|
+
"user's natural-language intent to the right module before describe_endpoint.",
|
|
1828
|
+
inputSchema: {
|
|
1829
|
+
type: 'object',
|
|
1830
|
+
additionalProperties: false,
|
|
1831
|
+
required: ['module'],
|
|
1832
|
+
properties: {
|
|
1833
|
+
module: { type: 'string', description: 'Module name, e.g. request / kanban / plan / sla' },
|
|
1834
|
+
},
|
|
1835
|
+
},
|
|
1836
|
+
handler: async (args) => {
|
|
1837
|
+
const res = await systaFetch('GET', '/api-keys/me/capabilities');
|
|
1838
|
+
if (!res.ok) {
|
|
1839
|
+
return res;
|
|
1840
|
+
}
|
|
1841
|
+
const caps = unwrapCapabilities(res);
|
|
1842
|
+
const target = String(args.module || '').toLowerCase();
|
|
1843
|
+
const mod = (caps.modules || []).find((m) => String(m.module).toLowerCase() === target);
|
|
1844
|
+
if (!mod) {
|
|
1845
|
+
return {
|
|
1846
|
+
ok: false,
|
|
1847
|
+
error: `Module not found or not permitted for this key: ${args.module}`,
|
|
1848
|
+
availableModules: (caps.modules || []).map((m) => m.module),
|
|
1849
|
+
};
|
|
1850
|
+
}
|
|
1851
|
+
return {
|
|
1852
|
+
module: mod.module,
|
|
1853
|
+
description: mod.description,
|
|
1854
|
+
concepts: mod.concepts,
|
|
1855
|
+
endpoints: (mod.endpoints || []).map((e) => ({
|
|
1856
|
+
path: e.path,
|
|
1857
|
+
summary: e.summary,
|
|
1858
|
+
methods: (e.methods || []).map((mm) => ({
|
|
1859
|
+
method: mm.method,
|
|
1860
|
+
safetyClass: mm.safetyClass,
|
|
1861
|
+
requiredPermission: mm.requiredPermission,
|
|
1862
|
+
})),
|
|
1863
|
+
})),
|
|
1864
|
+
};
|
|
1865
|
+
},
|
|
1866
|
+
},
|
|
1867
|
+
{
|
|
1868
|
+
name: 'describe_endpoint',
|
|
1869
|
+
description:
|
|
1870
|
+
'Get full detail for one capability: input field types (fields), response shape ' +
|
|
1871
|
+
'(responseSchema — the data body inside the {success, data} envelope), safety class, ' +
|
|
1872
|
+
'required permission, and summary/scenario when available. Use the path + method from ' +
|
|
1873
|
+
'list_capabilities. Returns only if the key is permitted to call it.',
|
|
1874
|
+
inputSchema: {
|
|
1875
|
+
type: 'object',
|
|
1876
|
+
additionalProperties: false,
|
|
1877
|
+
required: ['path'],
|
|
1878
|
+
properties: {
|
|
1879
|
+
path: {
|
|
1880
|
+
type: 'string',
|
|
1881
|
+
description: 'Route pattern, e.g. "/api/requests" or "/api/requests/:requestNumber"',
|
|
1882
|
+
},
|
|
1883
|
+
method: {
|
|
1884
|
+
type: 'string',
|
|
1885
|
+
enum: ALLOWED_METHODS,
|
|
1886
|
+
description: 'Optional HTTP method to disambiguate',
|
|
1887
|
+
},
|
|
1888
|
+
},
|
|
1889
|
+
},
|
|
1890
|
+
handler: async (args) => {
|
|
1891
|
+
const targetPath = String(args.path || '');
|
|
1892
|
+
const targetMethod = args.method ? String(args.method).toUpperCase() : '';
|
|
1893
|
+
const res = await systaFetch('GET', '/api-keys/me/capabilities');
|
|
1894
|
+
if (!res.ok) {
|
|
1895
|
+
return res;
|
|
1896
|
+
}
|
|
1897
|
+
const caps = unwrapCapabilities(res);
|
|
1898
|
+
for (const module of caps.modules || []) {
|
|
1899
|
+
for (const endpoint of module.endpoints || []) {
|
|
1900
|
+
if (endpoint.path !== targetPath) {
|
|
1901
|
+
continue;
|
|
1902
|
+
}
|
|
1903
|
+
const match = (endpoint.methods || []).find(
|
|
1904
|
+
(mm) => !targetMethod || mm.method === targetMethod,
|
|
1905
|
+
);
|
|
1906
|
+
if (match) {
|
|
1907
|
+
return {
|
|
1908
|
+
module: module.module,
|
|
1909
|
+
path: endpoint.path,
|
|
1910
|
+
summary: endpoint.summary,
|
|
1911
|
+
...match,
|
|
1912
|
+
};
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
return {
|
|
1917
|
+
ok: false,
|
|
1918
|
+
error: `Endpoint not found or not permitted for this key: ${targetMethod || 'ANY'} ${targetPath}`,
|
|
1919
|
+
};
|
|
1920
|
+
},
|
|
1921
|
+
},
|
|
1922
|
+
];
|
|
1923
|
+
|
|
1924
|
+
const TOOL_MAP = new Map(TOOLS.map((tool) => [tool.name, tool]));
|
|
1925
|
+
|
|
1926
|
+
// ── Scope-aware tool visibility ──────────────────────────────────────────────
|
|
1927
|
+
// Her named tool'un PRIMARY endpoint'inin gerektirdigi izin (permission_key).
|
|
1928
|
+
// `tools/list` bunu, anahtarin scope-filtreli katalogunda GERCEKTEN gorunen izin
|
|
1929
|
+
// setiyle karsilastirir: bir arac yalnizca anahtar o izne sahipse listelenir.
|
|
1930
|
+
// Boylece kapsam disi araclar AJANA HIC GORUNMEZ -> ajan onlari denemez, gereksiz
|
|
1931
|
+
// 403 olmaz. Yikici/wildcard mantigi (`*`/`domain.*` + `.delete` istisnasi) zaten
|
|
1932
|
+
// backend'de uygulanip katalogu filtreledigi icin burada yeniden yazilmaz — tek
|
|
1933
|
+
// kaynak backend. Bu haritada OLMAYAN araclar (list_capabilities, describe_module,
|
|
1934
|
+
// describe_endpoint, systa_api_call) izin gerektirmez, her zaman gorunur: bunlar
|
|
1935
|
+
// kesif/jenerik katmanidir (systa_api_call belirli bir endpoint'i REKLAM ETMEZ;
|
|
1936
|
+
// scope sunucuda 403 ile zorlanir). Permission degerleri canli katalogdan dogrulandi.
|
|
1937
|
+
const TOOL_REQUIRED_PERMISSION = {
|
|
1938
|
+
list_requests: 'request.read',
|
|
1939
|
+
get_request: 'request.tabs.general.view',
|
|
1940
|
+
list_projects: 'project.read',
|
|
1941
|
+
create_request: 'request.create',
|
|
1942
|
+
prepare_create_request: 'request.create',
|
|
1943
|
+
add_request_comment: 'request.comment.create',
|
|
1944
|
+
change_request_status: 'request.update',
|
|
1945
|
+
update_request: 'request.update',
|
|
1946
|
+
assign_request: 'request.update',
|
|
1947
|
+
get_request_board: 'request.read',
|
|
1948
|
+
manage_request_card: 'request.update',
|
|
1949
|
+
move_request_card: 'request.update',
|
|
1950
|
+
request_approval: 'request.approval.create',
|
|
1951
|
+
respond_to_approval: 'request.approval.respond',
|
|
1952
|
+
list_pending_approvals: 'request.approval.view',
|
|
1953
|
+
create_project: 'project.create',
|
|
1954
|
+
get_project: 'project.read',
|
|
1955
|
+
update_project: 'project.update',
|
|
1956
|
+
get_project_health: 'project.read',
|
|
1957
|
+
get_project_analytics: 'project.read',
|
|
1958
|
+
list_milestones: 'project.read',
|
|
1959
|
+
create_milestone: 'project.update',
|
|
1960
|
+
achieve_milestone: 'project.update',
|
|
1961
|
+
manage_project_stakeholders: 'project.update',
|
|
1962
|
+
manage_project_notes: 'project.update',
|
|
1963
|
+
create_plan_baseline: 'request.update',
|
|
1964
|
+
submit_plan_for_approval: 'plan.approval.submit',
|
|
1965
|
+
respond_to_plan_approval: 'plan.approval.review',
|
|
1966
|
+
get_plan_critical_path: 'request.read',
|
|
1967
|
+
distribute_plan_schedule: 'request.read',
|
|
1968
|
+
create_release_list: 'release.list.create',
|
|
1969
|
+
add_release_item: 'release.item.create',
|
|
1970
|
+
advance_release_workflow: 'release.workflow.manage',
|
|
1971
|
+
querybuilder_ai_generate_query: 'report.query-builder',
|
|
1972
|
+
querybuilder_execute_query: 'report.query-builder',
|
|
1973
|
+
querybuilder_describe_fields: 'report.query-builder',
|
|
1974
|
+
querybuilder_saved_query: 'report.query-builder',
|
|
1975
|
+
querybuilder_schedule_report: 'report.query-builder',
|
|
1976
|
+
download_request_file: 'file.download',
|
|
1977
|
+
upload_file_to: 'file.upload',
|
|
1978
|
+
};
|
|
1979
|
+
|
|
1980
|
+
// Hata aninda davranis: fail-open (varsayilan) tum araclari gosterir — gerçek
|
|
1981
|
+
// guvenlik siniri backend 403'tur, gecici bir aglama hatasi tum araç setini
|
|
1982
|
+
// kilitlememeli. SYSTA_MCP_STRICT_TOOLS=1 -> fail-closed: kapsam bilinemezse yalniz
|
|
1983
|
+
// izin gerektirmeyen kesif araclari gorunur.
|
|
1984
|
+
const STRICT_TOOLS = process.env.SYSTA_MCP_STRICT_TOOLS === '1';
|
|
1985
|
+
|
|
1986
|
+
let allowedPermissionsPromise = null;
|
|
1987
|
+
// Anahtarin EFEKTIF izin seti = scope-filtreli katalogda gorunen tum
|
|
1988
|
+
// requiredPermission degerleri. Anahtar+base sabit oldugu icin process omru boyunca
|
|
1989
|
+
// bir kez cekilir (memoized). Basarisizlik -> null (cagiran fail-open/closed'a karar verir).
|
|
1990
|
+
const fetchAllowedPermissions = async () => {
|
|
1991
|
+
try {
|
|
1992
|
+
const res = await systaFetch('GET', '/api-keys/me/capabilities');
|
|
1993
|
+
if (!res || !res.ok) {
|
|
1994
|
+
return null;
|
|
1995
|
+
}
|
|
1996
|
+
const caps = unwrapCapabilities(res) || {};
|
|
1997
|
+
const methods = (caps.modules || [])
|
|
1998
|
+
.flatMap((m) => m.endpoints || [])
|
|
1999
|
+
.flatMap((e) => e.methods || []);
|
|
2000
|
+
const set = new Set();
|
|
2001
|
+
for (const mm of methods) {
|
|
2002
|
+
if (mm.requiredPermission) {
|
|
2003
|
+
set.add(mm.requiredPermission);
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
return set;
|
|
2007
|
+
} catch {
|
|
2008
|
+
return null;
|
|
2009
|
+
}
|
|
2010
|
+
};
|
|
2011
|
+
const getAllowedPermissions = () => {
|
|
2012
|
+
if (!allowedPermissionsPromise) {
|
|
2013
|
+
allowedPermissionsPromise = fetchAllowedPermissions();
|
|
2014
|
+
}
|
|
2015
|
+
return allowedPermissionsPromise;
|
|
2016
|
+
};
|
|
2017
|
+
|
|
2018
|
+
/**
|
|
2019
|
+
* Filter the tool list down to what THIS key can actually use.
|
|
2020
|
+
* @param {Set<string>|null} allowed - effective permission set, or null if unknown
|
|
2021
|
+
* @returns {Array} visible tool definitions
|
|
2022
|
+
*/
|
|
2023
|
+
const visibleTools = (allowed) =>
|
|
2024
|
+
TOOLS.filter((tool) => {
|
|
2025
|
+
const need = TOOL_REQUIRED_PERMISSION[tool.name];
|
|
2026
|
+
if (!need) {
|
|
2027
|
+
return true; // kesif/jenerik araclar — her zaman
|
|
2028
|
+
}
|
|
2029
|
+
if (allowed === null) {
|
|
2030
|
+
return !STRICT_TOOLS; // kapsam bilinemiyor: fail-open varsayilan
|
|
2031
|
+
}
|
|
2032
|
+
return allowed.has(need);
|
|
2033
|
+
});
|
|
2034
|
+
|
|
2035
|
+
// ── JSON-RPC plumbing ────────────────────────────────────────────────────────
|
|
2036
|
+
const send = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
2037
|
+
const reply = (id, result) => ({ jsonrpc: '2.0', id, result });
|
|
2038
|
+
const errorReply = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
|
|
2039
|
+
|
|
2040
|
+
const handleToolCall = async (id, params) => {
|
|
2041
|
+
const name = params && params.name;
|
|
2042
|
+
const tool = TOOL_MAP.get(name);
|
|
2043
|
+
if (!tool) {
|
|
2044
|
+
return errorReply(id, -32602, `Unknown tool: ${name}`);
|
|
2045
|
+
}
|
|
2046
|
+
try {
|
|
2047
|
+
const result = await tool.handler((params && params.arguments) || {});
|
|
2048
|
+
const isError = result && result.ok === false;
|
|
2049
|
+
return reply(id, {
|
|
2050
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
2051
|
+
isError: Boolean(isError),
|
|
2052
|
+
});
|
|
2053
|
+
} catch (toolError) {
|
|
2054
|
+
return reply(id, {
|
|
2055
|
+
content: [{ type: 'text', text: `Tool error: ${toolError.message}` }],
|
|
2056
|
+
isError: true,
|
|
2057
|
+
});
|
|
2058
|
+
}
|
|
2059
|
+
};
|
|
2060
|
+
|
|
2061
|
+
const handleMessage = async (line) => {
|
|
2062
|
+
let message;
|
|
2063
|
+
try {
|
|
2064
|
+
message = JSON.parse(line);
|
|
2065
|
+
} catch {
|
|
2066
|
+
return;
|
|
2067
|
+
}
|
|
2068
|
+
const { id, method, params } = message;
|
|
2069
|
+
|
|
2070
|
+
if (method === 'initialize') {
|
|
2071
|
+
return send(
|
|
2072
|
+
reply(id, {
|
|
2073
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
2074
|
+
capabilities: { tools: {} },
|
|
2075
|
+
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
|
|
2076
|
+
instructions: INSTRUCTIONS,
|
|
2077
|
+
}),
|
|
2078
|
+
);
|
|
2079
|
+
}
|
|
2080
|
+
if (method === 'tools/list') {
|
|
2081
|
+
// Scope-aware: anahtarin yetkili oldugu araclari goster (kapsam disi araclar
|
|
2082
|
+
// ajana HIC gorunmez). Kapsam, anahtarin scope-filtreli katalogundan turetilir.
|
|
2083
|
+
const allowed = BASE_URL && API_KEY ? await getAllowedPermissions() : null;
|
|
2084
|
+
return send(
|
|
2085
|
+
reply(id, {
|
|
2086
|
+
tools: visibleTools(allowed).map((tool) => ({
|
|
2087
|
+
name: tool.name,
|
|
2088
|
+
description: tool.description,
|
|
2089
|
+
inputSchema: tool.inputSchema,
|
|
2090
|
+
})),
|
|
2091
|
+
}),
|
|
2092
|
+
);
|
|
2093
|
+
}
|
|
2094
|
+
if (method === 'tools/call') {
|
|
2095
|
+
return send(await handleToolCall(id, params));
|
|
2096
|
+
}
|
|
2097
|
+
if (method === 'ping') {
|
|
2098
|
+
return send(reply(id, {}));
|
|
2099
|
+
}
|
|
2100
|
+
// Notifications (no id) — acknowledge silently.
|
|
2101
|
+
if (typeof method === 'string' && method.startsWith('notifications/')) {
|
|
2102
|
+
return undefined;
|
|
2103
|
+
}
|
|
2104
|
+
if (id !== undefined && id !== null) {
|
|
2105
|
+
return send(errorReply(id, -32601, `Method not found: ${method}`));
|
|
2106
|
+
}
|
|
2107
|
+
return undefined;
|
|
2108
|
+
};
|
|
2109
|
+
|
|
2110
|
+
// ── stdio loop ───────────────────────────────────────────────────────────────
|
|
2111
|
+
const main = () => {
|
|
2112
|
+
if (!BASE_URL || !API_KEY) {
|
|
2113
|
+
logErr('WARNING: SYSTA_API_BASE_URL and/or SYSTA_API_KEY not set — tool calls will fail.');
|
|
2114
|
+
}
|
|
2115
|
+
logErr(`ready (${TOOLS.length} tools, protocol ${PROTOCOL_VERSION})`);
|
|
2116
|
+
|
|
2117
|
+
let buffer = '';
|
|
2118
|
+
process.stdin.setEncoding('utf8');
|
|
2119
|
+
process.stdin.on('data', (chunk) => {
|
|
2120
|
+
buffer += chunk;
|
|
2121
|
+
let newlineIndex = buffer.indexOf('\n');
|
|
2122
|
+
while (newlineIndex >= 0) {
|
|
2123
|
+
const line = buffer.slice(0, newlineIndex).trim();
|
|
2124
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
2125
|
+
if (line) {
|
|
2126
|
+
// Fire-and-forget: each JSON-RPC reply carries its own id.
|
|
2127
|
+
handleMessage(line).catch((err) => logErr(`handler error: ${err.message}`));
|
|
2128
|
+
}
|
|
2129
|
+
newlineIndex = buffer.indexOf('\n');
|
|
2130
|
+
}
|
|
2131
|
+
});
|
|
2132
|
+
process.stdin.on('end', () => process.exit(0));
|
|
2133
|
+
};
|
|
2134
|
+
|
|
2135
|
+
// ── CLI (npx systa-mcp [--install-skill|--version|--help]) ───────────────────
|
|
2136
|
+
/**
|
|
2137
|
+
* Pakete gomulu istemci SKILL.md'sini ~/.claude/skills/systa/'ya kopyalar.
|
|
2138
|
+
* @param {string} [homeDir] - test/override icin ana dizin; varsayilan os.homedir().
|
|
2139
|
+
* @returns {string} yazilan hedef yol
|
|
2140
|
+
*/
|
|
2141
|
+
const installSkill = (homeDir) => {
|
|
2142
|
+
const src = path.join(__dirname, 'SKILL.md');
|
|
2143
|
+
if (!fs.existsSync(src)) {
|
|
2144
|
+
throw new Error('Bundled SKILL.md not found in this package');
|
|
2145
|
+
}
|
|
2146
|
+
const dir = path.join(homeDir || os.homedir(), '.claude', 'skills', 'systa');
|
|
2147
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
2148
|
+
const dest = path.join(dir, 'SKILL.md');
|
|
2149
|
+
fs.copyFileSync(src, dest);
|
|
2150
|
+
return dest;
|
|
2151
|
+
};
|
|
2152
|
+
|
|
2153
|
+
const HELP_TEXT = `systa-mcp ${SERVER_VERSION} — MCP stdio server for SysTa
|
|
2154
|
+
|
|
2155
|
+
Usage:
|
|
2156
|
+
systa-mcp Run the MCP stdio server (default; used by Claude Code)
|
|
2157
|
+
systa-mcp --install-skill Copy the bundled SysTa skill to ~/.claude/skills/systa/
|
|
2158
|
+
systa-mcp --version Print version and exit
|
|
2159
|
+
systa-mcp --help Show this help
|
|
2160
|
+
|
|
2161
|
+
Environment:
|
|
2162
|
+
SYSTA_API_BASE_URL SysTa API base incl. /api (e.g. https://systa.example.com/api)
|
|
2163
|
+
SYSTA_API_KEY Scoped API key from your SysTa profile (sk_live_...)
|
|
2164
|
+
SYSTA_VENDOR_CODE Optional X-Vendor-Code; normally derived from the key
|
|
2165
|
+
SYSTA_MCP_STRICT_TOOLS=1 Hide all action tools if scope can't be resolved (fail-closed)
|
|
2166
|
+
|
|
2167
|
+
Register with Claude Code:
|
|
2168
|
+
claude mcp add systa -e SYSTA_API_KEY=sk_live_... \\
|
|
2169
|
+
-e SYSTA_API_BASE_URL=https://systa.example.com/api -- npx -y systa-mcp
|
|
2170
|
+
`;
|
|
2171
|
+
|
|
2172
|
+
/**
|
|
2173
|
+
* Handle a one-shot CLI command. Returns true if a command was handled (the
|
|
2174
|
+
* stdio server should then NOT start).
|
|
2175
|
+
* @param {string[]} argv - process.argv.slice(2)
|
|
2176
|
+
* @returns {boolean}
|
|
2177
|
+
*/
|
|
2178
|
+
const runCli = (argv) => {
|
|
2179
|
+
if (argv.includes('--install-skill')) {
|
|
2180
|
+
try {
|
|
2181
|
+
const dest = installSkill();
|
|
2182
|
+
process.stdout.write(`SysTa skill installed -> ${dest}\n`);
|
|
2183
|
+
process.stdout.write('Restart Claude Code (or reload skills) to activate it.\n');
|
|
2184
|
+
} catch (err) {
|
|
2185
|
+
process.stderr.write(`install-skill failed: ${err.message}\n`);
|
|
2186
|
+
process.exitCode = 1;
|
|
2187
|
+
}
|
|
2188
|
+
return true;
|
|
2189
|
+
}
|
|
2190
|
+
if (argv.includes('--version') || argv.includes('-v')) {
|
|
2191
|
+
process.stdout.write(`${SERVER_VERSION}\n`);
|
|
2192
|
+
return true;
|
|
2193
|
+
}
|
|
2194
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
2195
|
+
process.stdout.write(HELP_TEXT);
|
|
2196
|
+
return true;
|
|
2197
|
+
}
|
|
2198
|
+
return false;
|
|
2199
|
+
};
|
|
2200
|
+
|
|
2201
|
+
// stdio dongusu yalnizca dogrudan calistirildiginda baslar; require eden testler
|
|
2202
|
+
// internals'a erisir. CLI komutu islendiyse stdio sunucusu baslamaz.
|
|
2203
|
+
if (require.main === module) {
|
|
2204
|
+
if (!runCli(process.argv.slice(2))) {
|
|
2205
|
+
main();
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
module.exports = {
|
|
2210
|
+
TOOLS,
|
|
2211
|
+
TOOL_MAP,
|
|
2212
|
+
TOOL_REQUIRED_PERMISSION,
|
|
2213
|
+
visibleTools,
|
|
2214
|
+
installSkill,
|
|
2215
|
+
runCli,
|
|
2216
|
+
systaFetch,
|
|
2217
|
+
systaFetchBinary,
|
|
2218
|
+
systaUploadMultipart,
|
|
2219
|
+
handleMessage,
|
|
2220
|
+
shapeCompact,
|
|
2221
|
+
unwrapCapabilities,
|
|
2222
|
+
parseContentDispositionFilename,
|
|
2223
|
+
sanitizeFileName,
|
|
2224
|
+
mimeForFileName,
|
|
2225
|
+
resolveSavePath,
|
|
2226
|
+
};
|