sap-adt-mcp 0.8.44 → 0.8.46
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 +44 -0
- package/config.example.json +4 -0
- package/package.json +2 -2
- package/src/config.js +26 -0
- package/src/panel.js +815 -0
- package/src/server.js +22 -0
- package/src/tools/panel.js +92 -0
package/README.md
CHANGED
|
@@ -220,6 +220,43 @@ Turn it all off:
|
|
|
220
220
|
| `reporting.includeArgs` | `true` | Include redacted tool args / repro args. Note: object names can appear here. |
|
|
221
221
|
| `reporting.endpoint` | relay URL | Point at your own relay (see [`worker/`](worker/)). |
|
|
222
222
|
|
|
223
|
+
### Local control panel
|
|
224
|
+
|
|
225
|
+
A small HTML button panel for the **read-only** tools — search, grep, get_source,
|
|
226
|
+
read_table, ATC, where-used, packages, transports, dumps, inactive objects — so
|
|
227
|
+
you can poke at SAP from a browser without going through an agent.
|
|
228
|
+
|
|
229
|
+
The trick: the panel is served **from inside the MCP process itself**, reusing
|
|
230
|
+
the same tool handlers. So it is reachable **only while a session keeps the MCP
|
|
231
|
+
connected** — close the session (or disconnect the MCP) and the process exits,
|
|
232
|
+
taking the panel down with it. There is no standalone server to leave running.
|
|
233
|
+
|
|
234
|
+
**Open it from a session (easiest).** Just ask the agent to open it — that calls
|
|
235
|
+
the **`adt_open_panel`** tool, which starts the panel on demand and opens the URL
|
|
236
|
+
in your browser. **`adt_close_panel`** stops it. In Claude Code the bundled
|
|
237
|
+
**`/panel`** command does the same (`/panel`, `/panel url`, `/panel close`).
|
|
238
|
+
Nothing listens until you ask — the socket opens only on that call.
|
|
239
|
+
|
|
240
|
+
**Or auto-start at boot.** Set it in config or env and it comes up with the
|
|
241
|
+
server:
|
|
242
|
+
|
|
243
|
+
```json
|
|
244
|
+
{ "panel": { "enabled": true, "port": 0 } }
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
…or `SAP_ADT_MCP_PANEL=1` (`SAP_ADT_MCP_PANEL_PORT` pins a port; `port: 0` / unset
|
|
248
|
+
picks a random free one). On boot the server prints the URL, e.g.:
|
|
249
|
+
|
|
250
|
+
```
|
|
251
|
+
[sap-adt-mcp] panel: ready (read-only) → http://127.0.0.1:39555/?t=<token>
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Safety: bound to `127.0.0.1` only, gated by a per-boot random **token** in that
|
|
255
|
+
URL, and limited to a curated **read-only** allowlist — no write tool (set_source,
|
|
256
|
+
activate, delete, lock, transport release) is reachable from a button, regardless
|
|
257
|
+
of config. Each tool's form is rendered from its live input schema, and the
|
|
258
|
+
system selector at the top targets any configured system.
|
|
259
|
+
|
|
223
260
|
## Connect a client
|
|
224
261
|
|
|
225
262
|
### Claude Code (CLI)
|
|
@@ -327,6 +364,13 @@ or rejecting credentials. Run this first when troubleshooting.
|
|
|
327
364
|
| `adt_create_transport` | Create a new TR. | Refused under `readOnly: true`. Endpoint shape varies — see Caveats. |
|
|
328
365
|
| `adt_release_transport` | Release a TR. | Refused under `readOnly: true`. |
|
|
329
366
|
|
|
367
|
+
### Control panel
|
|
368
|
+
|
|
369
|
+
| Tool | Purpose | Notes |
|
|
370
|
+
| --- | --- | --- |
|
|
371
|
+
| `adt_open_panel` | Start the local read-only HTML control panel and return its URL. | Opens the URL in the browser by default (`open: false` to just return it). Reachable only while this session keeps the MCP connected. See [Local control panel](#local-control-panel). |
|
|
372
|
+
| `adt_close_panel` | Stop the panel. | It also stops on its own when the session ends. |
|
|
373
|
+
|
|
330
374
|
### Escape hatch
|
|
331
375
|
|
|
332
376
|
`adt_request` — direct ADT REST call. Use this when a niche endpoint isn't
|
package/config.example.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sap-adt-mcp",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.46",
|
|
4
4
|
"mcpName": "io.github.yzonur/sap-adt-mcp",
|
|
5
5
|
"description": "MCP server giving Claude live access to SAP systems via ADT (ABAP Development Tools) REST. Read source, search, run syntax checks, and edit ABAP objects from any MCP-compatible client.",
|
|
6
6
|
"type": "module",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
},
|
|
22
22
|
"scripts": {
|
|
23
23
|
"start": "node src/server.js",
|
|
24
|
-
"test": "node --test test/adt-error.test.js test/atc-bulk.test.js test/audit.test.js test/cds.test.js test/data-preview.test.js test/diff.test.js test/dump-feed.test.js test/grep-source.test.js test/jobs.test.js test/lifecycle-activate.test.js test/lock.test.js test/mcp-bug-fixes.test.js test/node-structure.test.js test/notes.test.js test/object-create.test.js test/object-references.test.js test/object-uris.test.js test/prompts.test.js test/provenance.test.js test/rap-scaffold.test.js test/reporter.test.js test/security.test.js test/source-chunked.test.js test/source-guard.test.js test/source-pagination.test.js test/syntax-context.test.js test/tools-shape.test.js test/versions.test.js test/worklist.test.js",
|
|
24
|
+
"test": "node --test test/adt-error.test.js test/atc-bulk.test.js test/audit.test.js test/cds.test.js test/data-preview.test.js test/diff.test.js test/dump-feed.test.js test/grep-source.test.js test/jobs.test.js test/lifecycle-activate.test.js test/lock.test.js test/mcp-bug-fixes.test.js test/node-structure.test.js test/notes.test.js test/object-create.test.js test/object-references.test.js test/object-uris.test.js test/panel.test.js test/prompts.test.js test/provenance.test.js test/rap-scaffold.test.js test/reporter.test.js test/security.test.js test/source-chunked.test.js test/source-guard.test.js test/source-pagination.test.js test/syntax-context.test.js test/tools-shape.test.js test/versions.test.js test/worklist.test.js",
|
|
25
25
|
"lint": "eslint src test"
|
|
26
26
|
},
|
|
27
27
|
"keywords": [
|
package/src/config.js
CHANGED
|
@@ -51,10 +51,36 @@ export function loadConfig() {
|
|
|
51
51
|
systems,
|
|
52
52
|
reporting: parseReporting(raw.reporting),
|
|
53
53
|
audit: parseAudit(raw.audit),
|
|
54
|
+
panel: parsePanel(raw.panel),
|
|
54
55
|
configPath,
|
|
55
56
|
};
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
// Local read-only HTTP control panel. OFF by default — it opens a listening
|
|
60
|
+
// socket, which an stdio MCP otherwise never does. Enable via config or env.
|
|
61
|
+
// "panel": { "enabled": true, "port": 0 } (port 0 = random free port)
|
|
62
|
+
// SAP_ADT_MCP_PANEL=1 (also accepts true/yes/on; 0/false/no/off forces off)
|
|
63
|
+
// SAP_ADT_MCP_PANEL_PORT=39555 (overrides config port)
|
|
64
|
+
// The panel lives inside the MCP process: it is reachable only while this
|
|
65
|
+
// Claude session keeps the MCP connected, and dies the moment the process exits.
|
|
66
|
+
// It is bound to 127.0.0.1 and gated by a per-boot random token.
|
|
67
|
+
function parsePanel(raw) {
|
|
68
|
+
const envVal = String(process.env.SAP_ADT_MCP_PANEL ?? "").toLowerCase();
|
|
69
|
+
const envOn = ["1", "true", "yes", "on"].includes(envVal);
|
|
70
|
+
const envOff = ["0", "false", "no", "off"].includes(envVal);
|
|
71
|
+
const r = raw && typeof raw === "object" ? raw : {};
|
|
72
|
+
const enabled = envOn ? true : envOff ? false : r.enabled === true;
|
|
73
|
+
|
|
74
|
+
const envPort = Number.parseInt(process.env.SAP_ADT_MCP_PANEL_PORT ?? "", 10);
|
|
75
|
+
const port = Number.isInteger(envPort)
|
|
76
|
+
? envPort
|
|
77
|
+
: Number.isInteger(r.port)
|
|
78
|
+
? r.port
|
|
79
|
+
: 0; // 0 → OS picks a free port
|
|
80
|
+
|
|
81
|
+
return { enabled, port, host: "127.0.0.1" };
|
|
82
|
+
}
|
|
83
|
+
|
|
58
84
|
// Local write-audit trail (JSONL). On by default; disable via config or env.
|
|
59
85
|
// "audit": { "enabled": false, "path": "/custom/audit.log" }
|
|
60
86
|
// SAP_ADT_MCP_AUDIT=0 (also accepts false/no/off)
|
package/src/panel.js
ADDED
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Local, read-only HTTP control panel.
|
|
6
|
+
//
|
|
7
|
+
// It lives INSIDE the MCP process and reuses the exact same tool handlers the
|
|
8
|
+
// MCP exposes. That gives the "only works while the session is open" property
|
|
9
|
+
// for free: Claude spawns this process when it connects the MCP and kills it
|
|
10
|
+
// when the session ends, taking the HTTP listener down with it. Nothing else
|
|
11
|
+
// keeps it alive.
|
|
12
|
+
//
|
|
13
|
+
// Safety: bound to 127.0.0.1, gated by a per-boot random token, and — most
|
|
14
|
+
// importantly — the allowlist below contains ONLY read-only tools. A button
|
|
15
|
+
// can never write to SAP because no write tool is reachable from here.
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
// Curated read-only set. `name` must match a registered tool; the input form
|
|
19
|
+
// for each is rendered from that tool's real inputSchema, so it stays in sync.
|
|
20
|
+
const PANEL_TOOLS = [
|
|
21
|
+
{ name: "adt_list_systems", label: "Sistemleri Listele", cat: "Bağlantı" },
|
|
22
|
+
{ name: "adt_ping", label: "Ping / Bağlantı Testi", cat: "Bağlantı" },
|
|
23
|
+
{ name: "adt_search_objects", label: "Obje Ara (isim)", cat: "Keşif" },
|
|
24
|
+
{ name: "adt_grep_source", label: "Kaynak İçinde Ara (grep)", cat: "Keşif" },
|
|
25
|
+
{ name: "adt_browse_package", label: "Paket İçeriği", cat: "Keşif" },
|
|
26
|
+
{ name: "adt_where_used", label: "Nerede Kullanılıyor", cat: "Keşif" },
|
|
27
|
+
{ name: "adt_get_source", label: "Kaynak Kodu Getir", cat: "Kaynak" },
|
|
28
|
+
{ name: "adt_read_table", label: "Tablo Oku (SELECT)", cat: "Veri" },
|
|
29
|
+
{ name: "adt_run_atc", label: "ATC Çalıştır", cat: "Kalite" },
|
|
30
|
+
{ name: "adt_list_inactive_objects", label: "Aktif Olmayan Objeler", cat: "Yaşam döngüsü" }, // prettier-ignore
|
|
31
|
+
{ name: "adt_list_transports", label: "Transport'ları Listele", cat: "Transport" },
|
|
32
|
+
{ name: "adt_list_dumps", label: "Dump'ları Listele (ST22)", cat: "Runtime" },
|
|
33
|
+
{ name: "adt_get_dump", label: "Dump Detayı", cat: "Runtime" },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// --- Friendly form layer (panel-only) ---------------------------------------
|
|
37
|
+
// The MCP inputSchemas are written for an agent: terse keys, ADT type codes,
|
|
38
|
+
// regex. The panel re-presents them for a human. None of this touches the
|
|
39
|
+
// agent-facing schemas — it's an overlay shipped in /meta and consumed by the
|
|
40
|
+
// page. Per field: { label, help, advanced (collapse), default, placeholder,
|
|
41
|
+
// options:[{value,label}] (renders a dropdown) }. Object-type dropdowns use the
|
|
42
|
+
// tools' own friendly aliases (normalizeType accepts them), so they stay correct.
|
|
43
|
+
const OBJECT_TYPE_OPTIONS = [
|
|
44
|
+
{ value: "", label: "(seçiniz)" },
|
|
45
|
+
{ value: "class", label: "Sınıf (CLAS)" },
|
|
46
|
+
{ value: "program", label: "Program (PROG)" },
|
|
47
|
+
{ value: "interface", label: "Arayüz (INTF)" },
|
|
48
|
+
{ value: "function", label: "Fonksiyon modülü (FUGR/FF)" },
|
|
49
|
+
{ value: "functiongroup", label: "Fonksiyon grubu (FUGR)" },
|
|
50
|
+
{ value: "include", label: "Include (INCL)" },
|
|
51
|
+
{ value: "table", label: "Tablo (TABL)" },
|
|
52
|
+
{ value: "structure", label: "Yapı / Structure (TABL)" },
|
|
53
|
+
{ value: "dataelement", label: "Veri elemanı (DTEL)" },
|
|
54
|
+
{ value: "domain", label: "Domain (DOMA)" },
|
|
55
|
+
{ value: "cds", label: "CDS View (DDLS)" },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
// objectType filter on quick-search wants the ADT node-type code, not an alias.
|
|
59
|
+
const SEARCH_TYPE_OPTIONS = [
|
|
60
|
+
{ value: "", label: "Tümü" },
|
|
61
|
+
{ value: "CLAS/OC", label: "Sınıf" },
|
|
62
|
+
{ value: "PROG/P", label: "Program" },
|
|
63
|
+
{ value: "INTF/OI", label: "Arayüz" },
|
|
64
|
+
{ value: "FUGR/F", label: "Fonksiyon grubu" },
|
|
65
|
+
{ value: "TABL/DT", label: "Tablo" },
|
|
66
|
+
{ value: "DTEL/DE", label: "Veri elemanı" },
|
|
67
|
+
{ value: "DOMA/DD", label: "Domain" },
|
|
68
|
+
{ value: "DDLS/DF", label: "CDS View" },
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
const CLASS_INCLUDE_OPTIONS = [
|
|
72
|
+
{ value: "", label: "main (varsayılan)" },
|
|
73
|
+
{ value: "definitions", label: "definitions" },
|
|
74
|
+
{ value: "implementations", label: "implementations" },
|
|
75
|
+
{ value: "macros", label: "macros" },
|
|
76
|
+
{ value: "testclasses", label: "testclasses" },
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
// intro: a friendly, non-technical one-liner per tool (replaces the agent-facing
|
|
80
|
+
// description on the card). fields: per-field overlay.
|
|
81
|
+
const TOOL_HINTS = {
|
|
82
|
+
adt_list_systems: { intro: "Tanımlı SAP sistemlerini ve varsayılanı gösterir." },
|
|
83
|
+
adt_ping: { intro: "Seçili sisteme bağlantıyı ve oturumu test eder." },
|
|
84
|
+
adt_search_objects: {
|
|
85
|
+
intro: "Repository'de isme göre obje arar. İsmin bir kısmını ve '*' joker karakterini kullanın.",
|
|
86
|
+
fields: {
|
|
87
|
+
query: { label: "Aranacak isim", placeholder: "örn: ZCL_MUSTERI*", help: "'*' joker karakter olarak kullanılabilir." },
|
|
88
|
+
objectType: { label: "Obje türü", options: SEARCH_TYPE_OPTIONS, help: "Belirli bir türle sınırla; boş = tümü." },
|
|
89
|
+
maxResults: { label: "Kaç sonuç", default: 50, advanced: true },
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
adt_grep_source: {
|
|
93
|
+
intro: "Kaynak kod İÇİNDE metin arar (isimde değil). Bir paket, transport ya da obje listesi seçin.",
|
|
94
|
+
fields: {
|
|
95
|
+
pattern: { label: "Aranan metin", placeholder: "örn: CALL FUNCTION", help: "Düz metin yazın; ileri düzey için regex (Gelişmiş)." },
|
|
96
|
+
package: { label: "Paket", placeholder: "örn: ZFLEET" },
|
|
97
|
+
transport: { label: "Transport", advanced: true, placeholder: "örn: E4DK900123" },
|
|
98
|
+
objects: { label: "Obje listesi (JSON)", advanced: true },
|
|
99
|
+
recursive: { label: "Alt paketlere de in", advanced: true },
|
|
100
|
+
flags: { label: "Regex flag'leri", advanced: true, help: "Boş = harf duyarsız ('i'). '' yazıp kapatamazsınız; teknik." },
|
|
101
|
+
prefix: { label: "Alt paket öneki", advanced: true },
|
|
102
|
+
maxPackages: { label: "Maks. paket", advanced: true },
|
|
103
|
+
maxObjects: { label: "Maks. obje", advanced: true },
|
|
104
|
+
maxMatches: { label: "Maks. eşleşme", advanced: true },
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
adt_browse_package: {
|
|
108
|
+
intro: "Bir paketin doğrudan içeriğini (tek seviye) listeler.",
|
|
109
|
+
fields: { package: { label: "Paket", placeholder: "örn: ZLOCAL" } },
|
|
110
|
+
},
|
|
111
|
+
adt_where_used: {
|
|
112
|
+
intro: "Bir objenin nerelerde kullanıldığını listeler.",
|
|
113
|
+
fields: {
|
|
114
|
+
object: { label: "Obje adı", placeholder: "örn: ZCL_MUSTERI" },
|
|
115
|
+
type: { label: "Obje türü", options: OBJECT_TYPE_OPTIONS },
|
|
116
|
+
group: { label: "Fonksiyon grubu", advanced: true, help: "Sadece FM/FUGR include için." },
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
adt_get_source: {
|
|
120
|
+
intro: "Bir objenin kaynak kodunu getirir.",
|
|
121
|
+
fields: {
|
|
122
|
+
object: { label: "Obje adı", placeholder: "örn: ZCL_MUSTERI" },
|
|
123
|
+
type: { label: "Obje türü", options: OBJECT_TYPE_OPTIONS },
|
|
124
|
+
include: { label: "Sınıf include'u", options: CLASS_INCLUDE_OPTIONS, advanced: true },
|
|
125
|
+
group: { label: "Fonksiyon grubu", advanced: true },
|
|
126
|
+
onlyMethod: { label: "Sadece bu metod", advanced: true, placeholder: "örn: CONSTRUCTOR" },
|
|
127
|
+
firstLine: { label: "İlk satır", advanced: true },
|
|
128
|
+
lastLine: { label: "Son satır", advanced: true },
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
adt_read_table: {
|
|
132
|
+
intro: "Veritabanına salt-okunur SELECT çalıştırır (sadece SELECT).",
|
|
133
|
+
fields: {
|
|
134
|
+
query: { label: "SELECT sorgusu", placeholder: "SELECT matnr, matkl FROM mara WHERE matnr LIKE 'M%'" },
|
|
135
|
+
maxRows: { label: "Maks. satır", default: 100, advanced: true },
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
adt_run_atc: {
|
|
139
|
+
intro: "Seçili objelerde ABAP Test Cockpit (ATC) kontrolü çalıştırır.",
|
|
140
|
+
fields: {
|
|
141
|
+
objects: { label: "Objeler (JSON liste)", placeholder: '[{"name":"ZCL_X","type":"class"}]', help: 'Her öğe { "name": "...", "type": "..." }.' },
|
|
142
|
+
checkVariant: { label: "Kontrol varyantı", advanced: true, help: "Boş = DEFAULT." },
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
adt_list_inactive_objects: {
|
|
146
|
+
intro: "Düzenlenmiş ama henüz aktive edilmemiş objeleri listeler.",
|
|
147
|
+
},
|
|
148
|
+
adt_list_transports: {
|
|
149
|
+
intro: "Görünür transport isteklerini listeler.",
|
|
150
|
+
fields: {
|
|
151
|
+
user: { label: "Kullanıcı (sahibi)", placeholder: "boş = bağlantı kullanıcısı" },
|
|
152
|
+
status: {
|
|
153
|
+
label: "Durum",
|
|
154
|
+
options: [
|
|
155
|
+
{ value: "", label: "Değiştirilebilir (varsayılan)" },
|
|
156
|
+
{ value: "modifiable", label: "Değiştirilebilir" },
|
|
157
|
+
{ value: "released", label: "Serbest bırakılmış" },
|
|
158
|
+
{ value: "all", label: "Tümü" },
|
|
159
|
+
],
|
|
160
|
+
},
|
|
161
|
+
targets: { label: "Hedef sistemler", advanced: true },
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
adt_list_dumps: {
|
|
165
|
+
intro: "ST22 kısa dökümlerini (short dumps) listeler.",
|
|
166
|
+
fields: {
|
|
167
|
+
user: { label: "Kullanıcı", placeholder: "örn: OYILMAZ" },
|
|
168
|
+
from: { label: "Başlangıç tarihi", placeholder: "YYYYMMDD, örn: 20260513" },
|
|
169
|
+
to: { label: "Bitiş tarihi", placeholder: "YYYYMMDD", advanced: true },
|
|
170
|
+
host: { label: "Sunucu (host)", advanced: true },
|
|
171
|
+
maxResults: { label: "Kaç sonuç", default: 20, advanced: true },
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
adt_get_dump: {
|
|
175
|
+
intro: "Tek bir kısa dökümün detayını id ile getirir.",
|
|
176
|
+
fields: {
|
|
177
|
+
dumpId: { label: "Dump id", help: "adt_list_dumps çıktısındaki id." },
|
|
178
|
+
chapters: { label: "Bölümler (JSON liste)", advanced: true },
|
|
179
|
+
full: { label: "Ham metni de ekle", advanced: true, help: "Büyük (100KB+)." },
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// Turkish column/field labels for the result tables. Unknowns are humanized.
|
|
185
|
+
const COLUMN_LABELS = {
|
|
186
|
+
name: "İsim",
|
|
187
|
+
type: "Tür",
|
|
188
|
+
description: "Açıklama",
|
|
189
|
+
object: "Obje",
|
|
190
|
+
line: "Satır",
|
|
191
|
+
text: "Metin",
|
|
192
|
+
uri: "URI",
|
|
193
|
+
package: "Paket",
|
|
194
|
+
host: "Host",
|
|
195
|
+
user: "Kullanıcı",
|
|
196
|
+
client: "Mandant",
|
|
197
|
+
readOnly: "Salt-okunur",
|
|
198
|
+
isDefault: "Varsayılan",
|
|
199
|
+
priority: "Öncelik",
|
|
200
|
+
checkTitle: "Kontrol",
|
|
201
|
+
messageTitle: "Mesaj",
|
|
202
|
+
message: "Mesaj",
|
|
203
|
+
timestamp: "Zaman",
|
|
204
|
+
program: "Program",
|
|
205
|
+
runtimeError: "Hata",
|
|
206
|
+
id: "Id",
|
|
207
|
+
hits: "Eşleşme",
|
|
208
|
+
status: "Durum",
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
function timingSafeEqualStr(a, b) {
|
|
212
|
+
const ba = Buffer.from(String(a));
|
|
213
|
+
const bb = Buffer.from(String(b));
|
|
214
|
+
if (ba.length !== bb.length) return false;
|
|
215
|
+
return crypto.timingSafeEqual(ba, bb);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Reject anything whose Host header isn't loopback — cheap DNS-rebinding guard.
|
|
219
|
+
function hostIsLoopback(hostHeader) {
|
|
220
|
+
if (!hostHeader) return false;
|
|
221
|
+
const host = hostHeader.split(":")[0].toLowerCase();
|
|
222
|
+
return host === "127.0.0.1" || host === "localhost" || host === "[::1]";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function readBody(req, limit = 1_000_000) {
|
|
226
|
+
return new Promise((resolve, reject) => {
|
|
227
|
+
let size = 0;
|
|
228
|
+
const chunks = [];
|
|
229
|
+
req.on("data", (c) => {
|
|
230
|
+
size += c.length;
|
|
231
|
+
if (size > limit) {
|
|
232
|
+
reject(new Error("body too large"));
|
|
233
|
+
req.destroy();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
chunks.push(c);
|
|
237
|
+
});
|
|
238
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
239
|
+
req.on("error", reject);
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Flatten an MCP tool result ({ content:[{text}], isError }) to { text, isError }.
|
|
244
|
+
function flattenResult(out) {
|
|
245
|
+
const text = (out?.content ?? [])
|
|
246
|
+
.filter((c) => c && c.type === "text")
|
|
247
|
+
.map((c) => c.text)
|
|
248
|
+
.join("\n");
|
|
249
|
+
return { text, isError: Boolean(out?.isError) };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Build the static "view" (allowlist + descriptors + meta + html) from the live
|
|
253
|
+
// registry. Done once; the token is per-listen, not part of the view.
|
|
254
|
+
function buildView({ tools, handlers, config, version }) {
|
|
255
|
+
const allow = new Map(PANEL_TOOLS.map((t) => [t.name, t]));
|
|
256
|
+
const descriptors = [];
|
|
257
|
+
for (const item of PANEL_TOOLS) {
|
|
258
|
+
const def = tools.find((t) => t.name === item.name);
|
|
259
|
+
if (!def || typeof handlers[item.name] !== "function") continue; // tool not present
|
|
260
|
+
const hint = TOOL_HINTS[item.name] ?? {};
|
|
261
|
+
descriptors.push({
|
|
262
|
+
name: def.name,
|
|
263
|
+
label: item.label,
|
|
264
|
+
cat: item.cat,
|
|
265
|
+
description: def.description ?? "",
|
|
266
|
+
intro: hint.intro ?? null,
|
|
267
|
+
fields: hint.fields ?? {},
|
|
268
|
+
schema: def.inputSchema ?? { type: "object", properties: {}, required: [] },
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
const meta = {
|
|
272
|
+
version,
|
|
273
|
+
systems: Object.keys(config.systems),
|
|
274
|
+
defaultSystem: config.defaultSystem ?? null,
|
|
275
|
+
readOnly: Boolean(config.readOnly),
|
|
276
|
+
tools: descriptors,
|
|
277
|
+
columnLabels: COLUMN_LABELS,
|
|
278
|
+
};
|
|
279
|
+
return { allow, meta, html: renderHtml() };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function makeServer({ allow, meta, html, handlers, token }) {
|
|
283
|
+
return http.createServer(async (req, res) => {
|
|
284
|
+
try {
|
|
285
|
+
if (!hostIsLoopback(req.headers.host)) {
|
|
286
|
+
res.writeHead(403).end("forbidden");
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
290
|
+
|
|
291
|
+
// The page itself is harmless without the token (every data call needs it),
|
|
292
|
+
// so serve it on GET / regardless. The JS reads the token from its own URL.
|
|
293
|
+
if (req.method === "GET" && url.pathname === "/") {
|
|
294
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
295
|
+
res.end(html);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Everything below requires the token.
|
|
300
|
+
const supplied =
|
|
301
|
+
req.headers["x-panel-token"] || url.searchParams.get("t") || "";
|
|
302
|
+
if (!timingSafeEqualStr(supplied, token)) {
|
|
303
|
+
res.writeHead(401, { "content-type": "application/json" });
|
|
304
|
+
res.end(JSON.stringify({ error: "bad or missing token" }));
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (req.method === "GET" && url.pathname === "/meta") {
|
|
309
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
310
|
+
res.end(JSON.stringify(meta));
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (req.method === "POST" && url.pathname === "/call") {
|
|
315
|
+
const body = await readBody(req);
|
|
316
|
+
let payload;
|
|
317
|
+
try {
|
|
318
|
+
payload = JSON.parse(body || "{}");
|
|
319
|
+
} catch {
|
|
320
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
321
|
+
res.end(JSON.stringify({ error: "invalid JSON body" }));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const name = payload?.name;
|
|
325
|
+
const args = payload?.args ?? {};
|
|
326
|
+
if (!allow.has(name) || typeof handlers[name] !== "function") {
|
|
327
|
+
// Hard wall: only curated read-only tools are reachable.
|
|
328
|
+
res.writeHead(403, { "content-type": "application/json" });
|
|
329
|
+
res.end(JSON.stringify({ error: `tool not allowed: ${name}` }));
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
const out = await handlers[name](args);
|
|
334
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
335
|
+
res.end(JSON.stringify(flattenResult(out)));
|
|
336
|
+
} catch (err) {
|
|
337
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
338
|
+
res.end(
|
|
339
|
+
JSON.stringify({ text: `Error: ${err.message}`, isError: true })
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
res.writeHead(404).end("not found");
|
|
346
|
+
} catch (err) {
|
|
347
|
+
try {
|
|
348
|
+
res.writeHead(500).end(String(err?.message ?? err));
|
|
349
|
+
} catch {
|
|
350
|
+
/* response already sent */
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// --- Singleton lifecycle -----------------------------------------------------
|
|
357
|
+
// The panel is a process-wide singleton: server.js wires the live registry in
|
|
358
|
+
// once via configurePanel(), and the adt_open_panel / adt_close_panel tools (or
|
|
359
|
+
// boot auto-start) flip it on and off. Only one listener ever exists.
|
|
360
|
+
let configured = null; // { handlers, config, log, view }
|
|
361
|
+
let running = null; // { server, token, url }
|
|
362
|
+
|
|
363
|
+
export function configurePanel({ tools, handlers, config, version, log }) {
|
|
364
|
+
configured = {
|
|
365
|
+
handlers,
|
|
366
|
+
config,
|
|
367
|
+
log: typeof log === "function" ? log : () => {},
|
|
368
|
+
view: buildView({ tools, handlers, config, version }),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function isPanelConfigured() {
|
|
373
|
+
return Boolean(configured);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function isPanelRunning() {
|
|
377
|
+
return Boolean(running);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export function getPanelUrl() {
|
|
381
|
+
return running?.url ?? null;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Start the listener if it isn't already up. Resolves with the tokenized URL.
|
|
385
|
+
export function ensurePanelStarted() {
|
|
386
|
+
if (!configured) {
|
|
387
|
+
return Promise.reject(new Error("panel not configured"));
|
|
388
|
+
}
|
|
389
|
+
if (running) {
|
|
390
|
+
return Promise.resolve({ url: running.url, alreadyRunning: true });
|
|
391
|
+
}
|
|
392
|
+
const { handlers, config, log, view } = configured;
|
|
393
|
+
const token = crypto.randomBytes(24).toString("hex");
|
|
394
|
+
const server = makeServer({ ...view, handlers, token });
|
|
395
|
+
|
|
396
|
+
return new Promise((resolve, reject) => {
|
|
397
|
+
const onError = (err) => {
|
|
398
|
+
server.removeListener("listening", onListening);
|
|
399
|
+
log(`error: ${err.message}`, true);
|
|
400
|
+
reject(err);
|
|
401
|
+
};
|
|
402
|
+
const onListening = () => {
|
|
403
|
+
server.removeListener("error", onError);
|
|
404
|
+
server.on("error", (err) => log(`error: ${err.message}`, true));
|
|
405
|
+
server.on("close", () => {
|
|
406
|
+
if (running?.server === server) running = null;
|
|
407
|
+
});
|
|
408
|
+
const { port } = server.address();
|
|
409
|
+
const url = `http://${config.panel.host}:${port}/?t=${token}`;
|
|
410
|
+
running = { server, token, url };
|
|
411
|
+
log(`ready (read-only) → ${url}`);
|
|
412
|
+
resolve({ url, alreadyRunning: false });
|
|
413
|
+
};
|
|
414
|
+
server.once("error", onError);
|
|
415
|
+
server.once("listening", onListening);
|
|
416
|
+
server.listen(config.panel.port, config.panel.host);
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Stop the listener. Returns the URL that was being served, or null if it wasn't up.
|
|
421
|
+
export function stopPanel() {
|
|
422
|
+
if (!running) return null;
|
|
423
|
+
const { server, url } = running;
|
|
424
|
+
running = null;
|
|
425
|
+
server.close();
|
|
426
|
+
return url;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Convenience used by boot auto-start and tests: configure + start in one call.
|
|
430
|
+
// Returns { server, getUrl } once listening (test-friendly shape preserved).
|
|
431
|
+
export function startPanel(opts) {
|
|
432
|
+
configurePanel(opts);
|
|
433
|
+
const { handlers, config, view } = configured;
|
|
434
|
+
const token = crypto.randomBytes(24).toString("hex");
|
|
435
|
+
const server = makeServer({ ...view, handlers, token });
|
|
436
|
+
server.on("error", (err) => configured.log(`error: ${err.message}`, true));
|
|
437
|
+
server.on("close", () => {
|
|
438
|
+
if (running?.server === server) running = null;
|
|
439
|
+
});
|
|
440
|
+
server.listen(config.panel.port, config.panel.host, () => {
|
|
441
|
+
const { port } = server.address();
|
|
442
|
+
const url = `http://${config.panel.host}:${port}/?t=${token}`;
|
|
443
|
+
running = { server, token, url };
|
|
444
|
+
configured.log(`ready (read-only) → ${url}`);
|
|
445
|
+
});
|
|
446
|
+
return {
|
|
447
|
+
server,
|
|
448
|
+
getUrl: () => {
|
|
449
|
+
const addr = server.address();
|
|
450
|
+
return addr
|
|
451
|
+
? `http://${config.panel.host}:${addr.port}/?t=${token}`
|
|
452
|
+
: null;
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// --- The served page (single self-contained document) -----------------------
|
|
458
|
+
function renderHtml() {
|
|
459
|
+
// Token comes from this page's own ?t=… ; meta + forms are fetched at load.
|
|
460
|
+
return `<!doctype html>
|
|
461
|
+
<html lang="tr">
|
|
462
|
+
<head>
|
|
463
|
+
<meta charset="utf-8" />
|
|
464
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
465
|
+
<title>SAP ADT — Kontrol Paneli</title>
|
|
466
|
+
<style>
|
|
467
|
+
:root { color-scheme: dark; }
|
|
468
|
+
* { box-sizing: border-box; }
|
|
469
|
+
body { margin: 0; font: 14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
|
470
|
+
background:#0f1116; color:#e6e8ee; }
|
|
471
|
+
header { padding:14px 20px; border-bottom:1px solid #222633; display:flex; gap:14px;
|
|
472
|
+
align-items:center; position:sticky; top:0; background:#0f1116; z-index:5; flex-wrap:wrap; }
|
|
473
|
+
header h1 { font-size:16px; margin:0; font-weight:600; }
|
|
474
|
+
.pill { font-size:11px; padding:2px 8px; border-radius:999px; background:#1b2030; color:#9aa3b8; }
|
|
475
|
+
.pill.ro { background:#13361f; color:#5fd38a; }
|
|
476
|
+
.pill.warn { background:#3a2417; color:#e0a06a; }
|
|
477
|
+
label.sysrow { margin-left:auto; font-size:12px; color:#9aa3b8; display:flex; gap:6px; align-items:center; }
|
|
478
|
+
select, input, textarea { background:#161a24; color:#e6e8ee; border:1px solid #2a3142;
|
|
479
|
+
border-radius:6px; padding:6px 8px; font:inherit; }
|
|
480
|
+
textarea { width:100%; min-height:54px; resize:vertical; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px; }
|
|
481
|
+
main { padding:18px 20px; display:grid; grid-template-columns:repeat(auto-fill,minmax(320px,1fr)); gap:14px; }
|
|
482
|
+
.card { background:#141822; border:1px solid #232838; border-radius:10px; padding:14px; }
|
|
483
|
+
.card h3 { margin:0 0 2px; font-size:14px; }
|
|
484
|
+
.cat { font-size:10px; text-transform:uppercase; letter-spacing:.06em; color:#717a90; }
|
|
485
|
+
.desc { color:#8b93a7; font-size:11px; margin:4px 0 10px; max-height:48px; overflow:auto; }
|
|
486
|
+
.field { margin-bottom:8px; }
|
|
487
|
+
.field label { display:block; font-size:11px; color:#9aa3b8; margin-bottom:3px; }
|
|
488
|
+
.field label .req { color:#e0716a; }
|
|
489
|
+
.field input, .field textarea { width:100%; }
|
|
490
|
+
button.run { background:#2f6df6; color:#fff; border:none; border-radius:7px; padding:8px 14px;
|
|
491
|
+
font:inherit; font-weight:600; cursor:pointer; }
|
|
492
|
+
button.run:hover { background:#4079f8; }
|
|
493
|
+
button.run:disabled { opacity:.5; cursor:default; }
|
|
494
|
+
pre.out { margin:10px 0 0; background:#0b0d13; border:1px solid #202635;
|
|
495
|
+
border-radius:7px; padding:10px; max-height:340px; overflow:auto; white-space:pre-wrap;
|
|
496
|
+
word-break:break-word; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11.5px; }
|
|
497
|
+
pre.out.err { border-color:#5a2a2a; color:#f0a3a3; }
|
|
498
|
+
pre.out:empty { display:none; }
|
|
499
|
+
.muted { color:#717a90; }
|
|
500
|
+
#boot { padding:40px 20px; color:#8b93a7; }
|
|
501
|
+
/* friendly form */
|
|
502
|
+
.field .help { display:block; color:#717a90; font-size:10.5px; margin-top:3px; }
|
|
503
|
+
details.adv { margin:4px 0 8px; border-top:1px dashed #2a3142; padding-top:6px; }
|
|
504
|
+
details.adv > summary { cursor:pointer; color:#8b93a7; font-size:11px; list-style:none; user-select:none; }
|
|
505
|
+
details.adv > summary::before { content:"▸ "; }
|
|
506
|
+
details.adv[open] > summary::before { content:"▾ "; }
|
|
507
|
+
/* rendered results */
|
|
508
|
+
.res { margin-top:10px; }
|
|
509
|
+
.res:empty { display:none; }
|
|
510
|
+
.summary { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:8px; }
|
|
511
|
+
.chip { font-size:10.5px; padding:2px 8px; border-radius:999px; background:#1b2030; color:#aeb6c9; }
|
|
512
|
+
.chip b { color:#e6e8ee; font-weight:600; }
|
|
513
|
+
.chip.ok { background:#13361f; color:#5fd38a; }
|
|
514
|
+
.chip.bad { background:#3a1d1d; color:#f0a3a3; }
|
|
515
|
+
.errbox { background:#2a1414; border:1px solid #5a2a2a; border-radius:7px; padding:10px;
|
|
516
|
+
color:#f0a3a3; font-size:12px; }
|
|
517
|
+
.errbox .st { font-weight:600; color:#ffd0d0; }
|
|
518
|
+
.blk { margin:8px 0; }
|
|
519
|
+
.blk > .blktitle { font-size:11px; color:#9aa3b8; margin-bottom:4px; text-transform:uppercase; letter-spacing:.05em; }
|
|
520
|
+
table.grid { width:100%; border-collapse:collapse; font-size:11.5px; display:block; overflow:auto; max-height:360px; }
|
|
521
|
+
table.grid th, table.grid td { border:1px solid #232838; padding:4px 7px; text-align:left;
|
|
522
|
+
vertical-align:top; white-space:pre-wrap; word-break:break-word; max-width:340px; }
|
|
523
|
+
table.grid th { background:#171c28; color:#9aa3b8; position:sticky; top:0; font-weight:600; }
|
|
524
|
+
table.grid tr:nth-child(even) td { background:#10131b; }
|
|
525
|
+
.code { background:#0b0d13; border:1px solid #202635; border-radius:7px; padding:10px;
|
|
526
|
+
max-height:340px; overflow:auto; white-space:pre-wrap; word-break:break-word;
|
|
527
|
+
font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11.5px; color:#cdd3e0; }
|
|
528
|
+
.chapter { margin:8px 0; }
|
|
529
|
+
.chapter > .chtitle { font-size:11.5px; color:#cda; font-weight:600; margin-bottom:3px; }
|
|
530
|
+
details.raw { margin-top:10px; }
|
|
531
|
+
details.raw > summary { cursor:pointer; color:#717a90; font-size:11px; }
|
|
532
|
+
details.raw pre { margin:6px 0 0; background:#0b0d13; border:1px solid #202635; border-radius:7px;
|
|
533
|
+
padding:10px; max-height:300px; overflow:auto; white-space:pre-wrap; word-break:break-word;
|
|
534
|
+
font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11px; color:#8b93a7; }
|
|
535
|
+
</style>
|
|
536
|
+
</head>
|
|
537
|
+
<body>
|
|
538
|
+
<header>
|
|
539
|
+
<h1>SAP ADT — Kontrol Paneli</h1>
|
|
540
|
+
<span class="pill ro">read-only</span>
|
|
541
|
+
<span id="ver" class="pill"></span>
|
|
542
|
+
<span id="globalro" class="pill warn" style="display:none">sistem global read-only</span>
|
|
543
|
+
<label class="sysrow">Sistem:
|
|
544
|
+
<select id="system"></select>
|
|
545
|
+
</label>
|
|
546
|
+
</header>
|
|
547
|
+
<div id="boot">Yükleniyor… <span class="muted">(MCP process'i ile aynı ömürde — session kapanınca bu sayfa ölür)</span></div>
|
|
548
|
+
<main id="cards" style="display:none"></main>
|
|
549
|
+
<script>
|
|
550
|
+
const TOKEN = new URLSearchParams(location.search).get("t") || "";
|
|
551
|
+
const $ = (s,el=document)=>el.querySelector(s);
|
|
552
|
+
|
|
553
|
+
async function call(name, args){
|
|
554
|
+
const r = await fetch("/call", {
|
|
555
|
+
method:"POST",
|
|
556
|
+
headers:{ "content-type":"application/json", "x-panel-token":TOKEN },
|
|
557
|
+
body: JSON.stringify({ name, args })
|
|
558
|
+
});
|
|
559
|
+
if(!r.ok){ return { text:"HTTP "+r.status+" — "+(await r.text()), isError:true }; }
|
|
560
|
+
return r.json();
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
let COLUMN_LABELS = {};
|
|
564
|
+
const CODE_KEYS = new Set(["source","result","raw","rawtext","metadataxml","runresponse","resultxml"]);
|
|
565
|
+
|
|
566
|
+
function humanize(k){
|
|
567
|
+
return String(k).replace(/([a-z0-9])([A-Z])/g,"$1 $2").replace(/[_-]+/g," ")
|
|
568
|
+
.replace(/^./,c=>c.toUpperCase());
|
|
569
|
+
}
|
|
570
|
+
function labelFor(k){ return COLUMN_LABELS[k] || humanize(k); }
|
|
571
|
+
function el(tag, cls, txt){ const e=document.createElement(tag); if(cls) e.className=cls; if(txt!=null) e.textContent=txt; return e; }
|
|
572
|
+
function short(v, n){ const s=String(v); return s.length>n ? s.slice(0,n)+"…" : s; }
|
|
573
|
+
function isPlainObject(v){ return v && typeof v==="object" && !Array.isArray(v); }
|
|
574
|
+
|
|
575
|
+
// ----- friendly form ---------------------------------------------------------
|
|
576
|
+
function fieldFor(key, prop, required, hint){
|
|
577
|
+
hint=hint||{};
|
|
578
|
+
const wrap=el("div","field");
|
|
579
|
+
const lab=el("label",null,hint.label||key);
|
|
580
|
+
if(required){ const s=el("span","req"," *"); lab.appendChild(s); }
|
|
581
|
+
wrap.appendChild(lab);
|
|
582
|
+
let input;
|
|
583
|
+
const t=prop.type;
|
|
584
|
+
const options = hint.options || (Array.isArray(prop.enum) ? prop.enum.map(v=>({value:v,label:v})) : null);
|
|
585
|
+
if(options){
|
|
586
|
+
input=document.createElement("select");
|
|
587
|
+
if(!hint.options){ const b=el("option",null,"(varsayılan)"); b.value=""; input.appendChild(b); }
|
|
588
|
+
for(const o of options){ const op=el("option",null,o.label); op.value=o.value; input.appendChild(op); }
|
|
589
|
+
} else if(t==="boolean"){
|
|
590
|
+
input=document.createElement("input"); input.type="checkbox"; input.style.width="auto";
|
|
591
|
+
} else if(t==="integer"||t==="number"){
|
|
592
|
+
input=document.createElement("input"); input.type="number";
|
|
593
|
+
if(prop.minimum!=null) input.min=prop.minimum;
|
|
594
|
+
if(prop.maximum!=null) input.max=prop.maximum;
|
|
595
|
+
} else if(t==="array"||t==="object"){
|
|
596
|
+
input=document.createElement("textarea");
|
|
597
|
+
input.placeholder=hint.placeholder || ("JSON, örn: "+(t==="array"?'[{"name":"...","type":"..."}]':'{}'));
|
|
598
|
+
} else {
|
|
599
|
+
input=document.createElement("input"); input.type="text";
|
|
600
|
+
if(hint.placeholder) input.placeholder=hint.placeholder;
|
|
601
|
+
}
|
|
602
|
+
input.dataset.key=key; input.dataset.jtype=t||"string";
|
|
603
|
+
if(hint.default!=null){ if(input.type==="checkbox") input.checked=!!hint.default; else input.value=String(hint.default); }
|
|
604
|
+
const tip=hint.help||prop.description; if(tip) input.title=tip;
|
|
605
|
+
wrap.appendChild(input);
|
|
606
|
+
if(hint.help){ wrap.appendChild(el("span","help",hint.help)); }
|
|
607
|
+
return wrap;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function collect(card){
|
|
611
|
+
const args={};
|
|
612
|
+
for(const node of card.querySelectorAll("[data-key]")){
|
|
613
|
+
const k=node.dataset.key, t=node.dataset.jtype;
|
|
614
|
+
if(node.type==="checkbox"){ if(node.checked) args[k]=true; continue; }
|
|
615
|
+
const raw=node.value.trim();
|
|
616
|
+
if(raw==="") continue;
|
|
617
|
+
if(t==="integer"){ args[k]=parseInt(raw,10); }
|
|
618
|
+
else if(t==="number"){ args[k]=Number(raw); }
|
|
619
|
+
else if(t==="array"||t==="object"){
|
|
620
|
+
try { args[k]=JSON.parse(raw); } catch(e){ throw new Error((k)+": geçersiz JSON"); }
|
|
621
|
+
}
|
|
622
|
+
else args[k]=raw;
|
|
623
|
+
}
|
|
624
|
+
return args;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function buildCard(tool, getSystem){
|
|
628
|
+
const card=el("section","card");
|
|
629
|
+
card.appendChild(el("div","cat",tool.cat));
|
|
630
|
+
card.appendChild(el("h3",null,tool.label));
|
|
631
|
+
card.appendChild(el("div","desc",tool.intro||tool.description));
|
|
632
|
+
|
|
633
|
+
const props=tool.schema.properties||{};
|
|
634
|
+
const req=new Set(tool.schema.required||[]);
|
|
635
|
+
const hints=tool.fields||{};
|
|
636
|
+
const adv=el("details","adv"); adv.appendChild(el("summary",null,"Gelişmiş ayarlar"));
|
|
637
|
+
let advCount=0;
|
|
638
|
+
for(const key of Object.keys(props)){
|
|
639
|
+
if(key==="system") continue; // global system selector handles it
|
|
640
|
+
const hint=hints[key]||{};
|
|
641
|
+
const f=fieldFor(key, props[key], req.has(key), hint);
|
|
642
|
+
if(hint.advanced){ adv.appendChild(f); advCount++; } else card.appendChild(f);
|
|
643
|
+
}
|
|
644
|
+
if(advCount>0) card.appendChild(adv);
|
|
645
|
+
|
|
646
|
+
const btn=el("button","run","Çalıştır"); card.appendChild(btn);
|
|
647
|
+
const out=el("div","res"); card.appendChild(out);
|
|
648
|
+
|
|
649
|
+
btn.addEventListener("click", async ()=>{
|
|
650
|
+
let args;
|
|
651
|
+
try { args=collect(card); } catch(e){ showError(out, e.message); return; }
|
|
652
|
+
const sys=getSystem(); if(sys) args.system=sys;
|
|
653
|
+
btn.disabled=true; const old=btn.textContent; btn.textContent="Çalışıyor…";
|
|
654
|
+
out.textContent="";
|
|
655
|
+
try {
|
|
656
|
+
const res=await call(tool.name, args);
|
|
657
|
+
renderResult(out, tool.name, res);
|
|
658
|
+
} catch(e){ showError(out, String(e.message||e)); }
|
|
659
|
+
finally { btn.disabled=false; btn.textContent=old; }
|
|
660
|
+
});
|
|
661
|
+
return card;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ----- result rendering ------------------------------------------------------
|
|
665
|
+
function showError(out, msg){ out.innerHTML=""; const b=el("div","errbox"); b.appendChild(el("span","st","Hata")); b.appendChild(document.createTextNode(" — "+msg)); out.appendChild(b); }
|
|
666
|
+
|
|
667
|
+
function renderResult(out, tool, res){
|
|
668
|
+
out.innerHTML="";
|
|
669
|
+
const raw=(res&&res.text)||"";
|
|
670
|
+
let data;
|
|
671
|
+
try { data=JSON.parse(raw); }
|
|
672
|
+
catch(e){ out.appendChild(el("div","code",raw||"(boş yanıt)")); return; }
|
|
673
|
+
|
|
674
|
+
if((res&&res.isError) || (isPlainObject(data)&&data.ok===false)){
|
|
675
|
+
renderErrorObject(out, data);
|
|
676
|
+
appendRaw(out, data);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
if(!isPlainObject(data)){ out.appendChild(el("div","code",raw)); return; }
|
|
680
|
+
renderObject(out, data);
|
|
681
|
+
appendRaw(out, data);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function renderErrorObject(out, data){
|
|
685
|
+
const b=el("div","errbox");
|
|
686
|
+
const err=data.error||{};
|
|
687
|
+
const msg = err.message || err.localizedMessage || (typeof err==="string"?err:null)
|
|
688
|
+
|| err.raw || data.parseError || "İstek başarısız.";
|
|
689
|
+
const st = data.status!=null ? ("HTTP "+data.status) : "Hata";
|
|
690
|
+
b.appendChild(el("span","st",st));
|
|
691
|
+
b.appendChild(document.createTextNode(" — "+short(msg, 1200)));
|
|
692
|
+
out.appendChild(b);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function renderObject(out, data){
|
|
696
|
+
const scalars=[], tables=[], scalarArrays=[], maps=[], codes=[];
|
|
697
|
+
for(const [k,v] of Object.entries(data)){
|
|
698
|
+
if(v==null) continue;
|
|
699
|
+
const lk=k.toLowerCase();
|
|
700
|
+
if(CODE_KEYS.has(lk) || (typeof v==="string" && (v.indexOf("\n")>=0 || v.length>160))){ codes.push([k,v]); }
|
|
701
|
+
else if(Array.isArray(v)){
|
|
702
|
+
if(v.length && isPlainObject(v[0])) tables.push([k,v]);
|
|
703
|
+
else if(v.length) scalarArrays.push([k,v]);
|
|
704
|
+
}
|
|
705
|
+
else if(isPlainObject(v)) maps.push([k,v]);
|
|
706
|
+
else scalars.push([k,v]); // string/number/boolean
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if(scalars.length){
|
|
710
|
+
const s=el("div","summary");
|
|
711
|
+
for(const [k,v] of scalars){
|
|
712
|
+
const c=el("span","chip"+(typeof v==="boolean"?(v?" ok":" bad"):""));
|
|
713
|
+
const b=el("b",null,labelFor(k)+": ");
|
|
714
|
+
c.appendChild(b);
|
|
715
|
+
c.appendChild(document.createTextNode(typeof v==="boolean"?(v?"evet":"hayır"):short(v,80)));
|
|
716
|
+
s.appendChild(c);
|
|
717
|
+
}
|
|
718
|
+
out.appendChild(s);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
for(const [k,arr] of tables){ out.appendChild(blockTitle(labelFor(k)+" ("+arr.length+")")); out.appendChild(makeTable(arr)); }
|
|
722
|
+
|
|
723
|
+
for(const [k,arr] of scalarArrays){
|
|
724
|
+
const blk=el("div","blk"); blk.appendChild(el("div","blktitle",labelFor(k)+" ("+arr.length+")"));
|
|
725
|
+
blk.appendChild(el("div","code", arr.map(x=>String(x)).join(", "))); out.appendChild(blk);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
for(const [k,obj] of maps){
|
|
729
|
+
const vals=Object.values(obj);
|
|
730
|
+
if(vals.length && vals.every(x=>typeof x==="string")){
|
|
731
|
+
// chapter-style sections (e.g. dump chapters)
|
|
732
|
+
const blk=el("div","blk"); blk.appendChild(el("div","blktitle",labelFor(k)));
|
|
733
|
+
for(const [sk,sv] of Object.entries(obj)){
|
|
734
|
+
const ch=el("div","chapter"); ch.appendChild(el("div","chtitle",labelFor(sk)));
|
|
735
|
+
ch.appendChild(el("div","code",short(sv,8000))); blk.appendChild(ch);
|
|
736
|
+
}
|
|
737
|
+
out.appendChild(blk);
|
|
738
|
+
} else if(vals.length && vals.every(x=>typeof x==="number")){
|
|
739
|
+
// histogram (e.g. byPriority)
|
|
740
|
+
const blk=el("div","blk"); blk.appendChild(el("div","blktitle",labelFor(k)));
|
|
741
|
+
const s=el("div","summary");
|
|
742
|
+
for(const [sk,sv] of Object.entries(obj)){ const c=el("span","chip"); c.appendChild(el("b",null,labelFor(sk)+": ")); c.appendChild(document.createTextNode(String(sv))); s.appendChild(c); }
|
|
743
|
+
blk.appendChild(s); out.appendChild(blk);
|
|
744
|
+
} else {
|
|
745
|
+
const d=el("details","raw"); d.appendChild(el("summary",null,labelFor(k)));
|
|
746
|
+
d.appendChild(el("pre",null,JSON.stringify(obj,null,2))); out.appendChild(d);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
for(const [k,v] of codes){
|
|
751
|
+
const blk=el("div","blk"); blk.appendChild(el("div","blktitle",labelFor(k)));
|
|
752
|
+
blk.appendChild(el("div","code",short(v,20000))); out.appendChild(blk);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
if(!scalars.length && !tables.length && !scalarArrays.length && !maps.length && !codes.length){
|
|
756
|
+
out.appendChild(el("div","code","(boş yanıt)"));
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function blockTitle(t){ return el("div","blktitle",t); }
|
|
761
|
+
|
|
762
|
+
function makeTable(rows){
|
|
763
|
+
const cols=[]; const seen=new Set();
|
|
764
|
+
for(const r of rows){ for(const k of Object.keys(r)){ if(!seen.has(k)){ seen.add(k); cols.push(k); } } }
|
|
765
|
+
const table=el("table","grid");
|
|
766
|
+
const thead=document.createElement("thead"); const htr=document.createElement("tr");
|
|
767
|
+
for(const c of cols) htr.appendChild(el("th",null,labelFor(c)));
|
|
768
|
+
thead.appendChild(htr); table.appendChild(thead);
|
|
769
|
+
const tbody=document.createElement("tbody");
|
|
770
|
+
for(const r of rows){
|
|
771
|
+
const tr=document.createElement("tr");
|
|
772
|
+
for(const c of cols){
|
|
773
|
+
const v=r[c];
|
|
774
|
+
const cell = v==null ? "" : (typeof v==="object" ? JSON.stringify(v) : String(v));
|
|
775
|
+
tr.appendChild(el("td",null,short(cell,300)));
|
|
776
|
+
}
|
|
777
|
+
tbody.appendChild(tr);
|
|
778
|
+
}
|
|
779
|
+
table.appendChild(tbody);
|
|
780
|
+
return table;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function appendRaw(out, data){
|
|
784
|
+
const d=el("details","raw"); d.appendChild(el("summary",null,"Ham JSON"));
|
|
785
|
+
d.appendChild(el("pre",null,JSON.stringify(data,null,2)));
|
|
786
|
+
out.appendChild(d);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
(async function init(){
|
|
790
|
+
let meta;
|
|
791
|
+
try {
|
|
792
|
+
const r=await fetch("/meta",{headers:{"x-panel-token":TOKEN}});
|
|
793
|
+
if(!r.ok) throw new Error("HTTP "+r.status);
|
|
794
|
+
meta=await r.json();
|
|
795
|
+
} catch(e){
|
|
796
|
+
$("#boot").textContent="Panel'e bağlanılamadı: "+e.message+" — token geçersiz ya da session kapandı.";
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
COLUMN_LABELS = meta.columnLabels || {};
|
|
800
|
+
$("#ver").textContent="v"+meta.version;
|
|
801
|
+
if(meta.readOnly) $("#globalro").style.display="";
|
|
802
|
+
const sel=$("#system");
|
|
803
|
+
for(const s of meta.systems){ const o=el("option",null,s); o.value=s; sel.appendChild(o); }
|
|
804
|
+
if(meta.defaultSystem) sel.value=meta.defaultSystem;
|
|
805
|
+
const getSystem=()=>sel.value||"";
|
|
806
|
+
|
|
807
|
+
const cards=$("#cards");
|
|
808
|
+
for(const tool of meta.tools){ cards.appendChild(buildCard(tool, getSystem)); }
|
|
809
|
+
$("#boot").style.display="none";
|
|
810
|
+
cards.style.display="";
|
|
811
|
+
})();
|
|
812
|
+
</script>
|
|
813
|
+
</body>
|
|
814
|
+
</html>`;
|
|
815
|
+
}
|
package/src/server.js
CHANGED
|
@@ -16,6 +16,7 @@ import { listPrompts, getPrompt } from "./prompts.js";
|
|
|
16
16
|
import { textResult } from "./result.js";
|
|
17
17
|
import { createReporter } from "./reporter.js";
|
|
18
18
|
import { createAuditLog, toolContext } from "./audit.js";
|
|
19
|
+
import { configurePanel, ensurePanelStarted } from "./panel.js";
|
|
19
20
|
|
|
20
21
|
import * as connectionTools from "./tools/connection.js";
|
|
21
22
|
import * as sourceTools from "./tools/source.js";
|
|
@@ -34,6 +35,7 @@ import * as worklistTools from "./tools/worklist.js";
|
|
|
34
35
|
import * as jobTools from "./tools/jobs.js";
|
|
35
36
|
import * as rapTools from "./tools/rap.js";
|
|
36
37
|
import * as reportTools from "./tools/report.js";
|
|
38
|
+
import * as panelTools from "./tools/panel.js";
|
|
37
39
|
|
|
38
40
|
const PKG = JSON.parse(
|
|
39
41
|
readFileSync(
|
|
@@ -123,6 +125,7 @@ const TOOL_MODULES = [
|
|
|
123
125
|
jobTools,
|
|
124
126
|
rapTools,
|
|
125
127
|
reportTools,
|
|
128
|
+
panelTools,
|
|
126
129
|
];
|
|
127
130
|
|
|
128
131
|
const tools = [];
|
|
@@ -237,4 +240,23 @@ async function validateConfig() {
|
|
|
237
240
|
process.exit(allOk ? 0 : 1);
|
|
238
241
|
}
|
|
239
242
|
|
|
243
|
+
// --- Local control panel -----------------------------------------------------
|
|
244
|
+
// Always WIRED so the adt_open_panel tool can start it on demand ("paneli aç"),
|
|
245
|
+
// but it does not listen until either that tool is called or config.panel.enabled
|
|
246
|
+
// auto-starts it at boot. Either way it serves a read-only HTML button panel from
|
|
247
|
+
// inside this process — reachable only while this session keeps the MCP connected,
|
|
248
|
+
// and it dies when the process exits.
|
|
249
|
+
configurePanel({
|
|
250
|
+
tools,
|
|
251
|
+
handlers,
|
|
252
|
+
config,
|
|
253
|
+
version: PKG.version,
|
|
254
|
+
log: (msg) => process.stderr.write(`[${PKG.name}] panel: ${msg}\n`),
|
|
255
|
+
});
|
|
256
|
+
if (config.panel?.enabled) {
|
|
257
|
+
ensurePanelStarted().catch((err) =>
|
|
258
|
+
process.stderr.write(`[${PKG.name}] panel: ${err.message}\n`)
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
240
262
|
await server.connect(new StdioServerTransport());
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { exec } from "node:child_process";
|
|
2
|
+
import { jsonResult } from "../result.js";
|
|
3
|
+
import {
|
|
4
|
+
ensurePanelStarted,
|
|
5
|
+
stopPanel,
|
|
6
|
+
isPanelRunning,
|
|
7
|
+
getPanelUrl,
|
|
8
|
+
} from "../panel.js";
|
|
9
|
+
|
|
10
|
+
// Best-effort "open this URL in the user's browser". Never throws.
|
|
11
|
+
function openInBrowser(url) {
|
|
12
|
+
const cmd =
|
|
13
|
+
process.platform === "darwin"
|
|
14
|
+
? "open"
|
|
15
|
+
: process.platform === "win32"
|
|
16
|
+
? "start \"\""
|
|
17
|
+
: "xdg-open";
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
try {
|
|
20
|
+
exec(`${cmd} "${url}"`, (err) => resolve(!err));
|
|
21
|
+
} catch {
|
|
22
|
+
resolve(false);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const tools = [
|
|
28
|
+
{
|
|
29
|
+
name: "adt_open_panel",
|
|
30
|
+
description:
|
|
31
|
+
"Open the local, read-only SAP control panel: an HTML page with buttons for the read-only tools (search, grep source, get source, read table, ATC, where-used, packages, transports, dumps, inactive objects). Returns the URL to open. The panel is served from inside THIS MCP process, so it works only while this session keeps the MCP connected and dies when the session ends. Bound to 127.0.0.1, gated by a per-boot random token, read-only tools only — no write tool is reachable. By default also opens the URL in the local default browser.",
|
|
32
|
+
inputSchema: {
|
|
33
|
+
type: "object",
|
|
34
|
+
properties: {
|
|
35
|
+
open: {
|
|
36
|
+
type: "boolean",
|
|
37
|
+
description:
|
|
38
|
+
"Also open the URL in the local default browser. Default true. Set false to just return the URL.",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: "adt_close_panel",
|
|
45
|
+
description:
|
|
46
|
+
"Stop the local control panel started by adt_open_panel. (It also stops on its own when this MCP session ends.)",
|
|
47
|
+
inputSchema: { type: "object", properties: {} },
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
export function register() {
|
|
52
|
+
return {
|
|
53
|
+
adt_open_panel: async (args = {}) => {
|
|
54
|
+
let info;
|
|
55
|
+
try {
|
|
56
|
+
info = await ensurePanelStarted();
|
|
57
|
+
} catch (err) {
|
|
58
|
+
return jsonResult(
|
|
59
|
+
{
|
|
60
|
+
ok: false,
|
|
61
|
+
error: `Could not start panel: ${err.message}`,
|
|
62
|
+
},
|
|
63
|
+
true
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
const wantOpen = args.open !== false;
|
|
67
|
+
const browserOpened = wantOpen ? await openInBrowser(info.url) : false;
|
|
68
|
+
return jsonResult({
|
|
69
|
+
ok: true,
|
|
70
|
+
url: info.url,
|
|
71
|
+
alreadyRunning: info.alreadyRunning,
|
|
72
|
+
browserOpened,
|
|
73
|
+
readOnly: true,
|
|
74
|
+
note:
|
|
75
|
+
"Open this URL in a browser. The panel lives inside this MCP process — " +
|
|
76
|
+
"it closes automatically when the session ends. Read-only tools only.",
|
|
77
|
+
});
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
adt_close_panel: async () => {
|
|
81
|
+
const wasRunning = isPanelRunning();
|
|
82
|
+
const stoppedUrl = stopPanel();
|
|
83
|
+
return jsonResult({
|
|
84
|
+
ok: true,
|
|
85
|
+
wasRunning,
|
|
86
|
+
stoppedUrl,
|
|
87
|
+
stillRunning: isPanelRunning(),
|
|
88
|
+
currentUrl: getPanelUrl(),
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|