sap-adt-mcp 0.8.44 → 0.8.45

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 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
@@ -11,6 +11,10 @@
11
11
  "audit": {
12
12
  "enabled": true
13
13
  },
14
+ "panel": {
15
+ "enabled": false,
16
+ "port": 0
17
+ },
14
18
  "systems": {
15
19
  "DEV": {
16
20
  "host": "https://sap-dev.example.com:44300",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sap-adt-mcp",
3
- "version": "0.8.44",
3
+ "version": "0.8.45",
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,456 @@
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
+ function timingSafeEqualStr(a, b) {
37
+ const ba = Buffer.from(String(a));
38
+ const bb = Buffer.from(String(b));
39
+ if (ba.length !== bb.length) return false;
40
+ return crypto.timingSafeEqual(ba, bb);
41
+ }
42
+
43
+ // Reject anything whose Host header isn't loopback — cheap DNS-rebinding guard.
44
+ function hostIsLoopback(hostHeader) {
45
+ if (!hostHeader) return false;
46
+ const host = hostHeader.split(":")[0].toLowerCase();
47
+ return host === "127.0.0.1" || host === "localhost" || host === "[::1]";
48
+ }
49
+
50
+ function readBody(req, limit = 1_000_000) {
51
+ return new Promise((resolve, reject) => {
52
+ let size = 0;
53
+ const chunks = [];
54
+ req.on("data", (c) => {
55
+ size += c.length;
56
+ if (size > limit) {
57
+ reject(new Error("body too large"));
58
+ req.destroy();
59
+ return;
60
+ }
61
+ chunks.push(c);
62
+ });
63
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
64
+ req.on("error", reject);
65
+ });
66
+ }
67
+
68
+ // Flatten an MCP tool result ({ content:[{text}], isError }) to { text, isError }.
69
+ function flattenResult(out) {
70
+ const text = (out?.content ?? [])
71
+ .filter((c) => c && c.type === "text")
72
+ .map((c) => c.text)
73
+ .join("\n");
74
+ return { text, isError: Boolean(out?.isError) };
75
+ }
76
+
77
+ // Build the static "view" (allowlist + descriptors + meta + html) from the live
78
+ // registry. Done once; the token is per-listen, not part of the view.
79
+ function buildView({ tools, handlers, config, version }) {
80
+ const allow = new Map(PANEL_TOOLS.map((t) => [t.name, t]));
81
+ const descriptors = [];
82
+ for (const item of PANEL_TOOLS) {
83
+ const def = tools.find((t) => t.name === item.name);
84
+ if (!def || typeof handlers[item.name] !== "function") continue; // tool not present
85
+ descriptors.push({
86
+ name: def.name,
87
+ label: item.label,
88
+ cat: item.cat,
89
+ description: def.description ?? "",
90
+ schema: def.inputSchema ?? { type: "object", properties: {}, required: [] },
91
+ });
92
+ }
93
+ const meta = {
94
+ version,
95
+ systems: Object.keys(config.systems),
96
+ defaultSystem: config.defaultSystem ?? null,
97
+ readOnly: Boolean(config.readOnly),
98
+ tools: descriptors,
99
+ };
100
+ return { allow, meta, html: renderHtml() };
101
+ }
102
+
103
+ function makeServer({ allow, meta, html, handlers, token }) {
104
+ return http.createServer(async (req, res) => {
105
+ try {
106
+ if (!hostIsLoopback(req.headers.host)) {
107
+ res.writeHead(403).end("forbidden");
108
+ return;
109
+ }
110
+ const url = new URL(req.url, "http://127.0.0.1");
111
+
112
+ // The page itself is harmless without the token (every data call needs it),
113
+ // so serve it on GET / regardless. The JS reads the token from its own URL.
114
+ if (req.method === "GET" && url.pathname === "/") {
115
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
116
+ res.end(html);
117
+ return;
118
+ }
119
+
120
+ // Everything below requires the token.
121
+ const supplied =
122
+ req.headers["x-panel-token"] || url.searchParams.get("t") || "";
123
+ if (!timingSafeEqualStr(supplied, token)) {
124
+ res.writeHead(401, { "content-type": "application/json" });
125
+ res.end(JSON.stringify({ error: "bad or missing token" }));
126
+ return;
127
+ }
128
+
129
+ if (req.method === "GET" && url.pathname === "/meta") {
130
+ res.writeHead(200, { "content-type": "application/json" });
131
+ res.end(JSON.stringify(meta));
132
+ return;
133
+ }
134
+
135
+ if (req.method === "POST" && url.pathname === "/call") {
136
+ const body = await readBody(req);
137
+ let payload;
138
+ try {
139
+ payload = JSON.parse(body || "{}");
140
+ } catch {
141
+ res.writeHead(400, { "content-type": "application/json" });
142
+ res.end(JSON.stringify({ error: "invalid JSON body" }));
143
+ return;
144
+ }
145
+ const name = payload?.name;
146
+ const args = payload?.args ?? {};
147
+ if (!allow.has(name) || typeof handlers[name] !== "function") {
148
+ // Hard wall: only curated read-only tools are reachable.
149
+ res.writeHead(403, { "content-type": "application/json" });
150
+ res.end(JSON.stringify({ error: `tool not allowed: ${name}` }));
151
+ return;
152
+ }
153
+ try {
154
+ const out = await handlers[name](args);
155
+ res.writeHead(200, { "content-type": "application/json" });
156
+ res.end(JSON.stringify(flattenResult(out)));
157
+ } catch (err) {
158
+ res.writeHead(200, { "content-type": "application/json" });
159
+ res.end(
160
+ JSON.stringify({ text: `Error: ${err.message}`, isError: true })
161
+ );
162
+ }
163
+ return;
164
+ }
165
+
166
+ res.writeHead(404).end("not found");
167
+ } catch (err) {
168
+ try {
169
+ res.writeHead(500).end(String(err?.message ?? err));
170
+ } catch {
171
+ /* response already sent */
172
+ }
173
+ }
174
+ });
175
+ }
176
+
177
+ // --- Singleton lifecycle -----------------------------------------------------
178
+ // The panel is a process-wide singleton: server.js wires the live registry in
179
+ // once via configurePanel(), and the adt_open_panel / adt_close_panel tools (or
180
+ // boot auto-start) flip it on and off. Only one listener ever exists.
181
+ let configured = null; // { handlers, config, log, view }
182
+ let running = null; // { server, token, url }
183
+
184
+ export function configurePanel({ tools, handlers, config, version, log }) {
185
+ configured = {
186
+ handlers,
187
+ config,
188
+ log: typeof log === "function" ? log : () => {},
189
+ view: buildView({ tools, handlers, config, version }),
190
+ };
191
+ }
192
+
193
+ export function isPanelConfigured() {
194
+ return Boolean(configured);
195
+ }
196
+
197
+ export function isPanelRunning() {
198
+ return Boolean(running);
199
+ }
200
+
201
+ export function getPanelUrl() {
202
+ return running?.url ?? null;
203
+ }
204
+
205
+ // Start the listener if it isn't already up. Resolves with the tokenized URL.
206
+ export function ensurePanelStarted() {
207
+ if (!configured) {
208
+ return Promise.reject(new Error("panel not configured"));
209
+ }
210
+ if (running) {
211
+ return Promise.resolve({ url: running.url, alreadyRunning: true });
212
+ }
213
+ const { handlers, config, log, view } = configured;
214
+ const token = crypto.randomBytes(24).toString("hex");
215
+ const server = makeServer({ ...view, handlers, token });
216
+
217
+ return new Promise((resolve, reject) => {
218
+ const onError = (err) => {
219
+ server.removeListener("listening", onListening);
220
+ log(`error: ${err.message}`, true);
221
+ reject(err);
222
+ };
223
+ const onListening = () => {
224
+ server.removeListener("error", onError);
225
+ server.on("error", (err) => log(`error: ${err.message}`, true));
226
+ server.on("close", () => {
227
+ if (running?.server === server) running = null;
228
+ });
229
+ const { port } = server.address();
230
+ const url = `http://${config.panel.host}:${port}/?t=${token}`;
231
+ running = { server, token, url };
232
+ log(`ready (read-only) → ${url}`);
233
+ resolve({ url, alreadyRunning: false });
234
+ };
235
+ server.once("error", onError);
236
+ server.once("listening", onListening);
237
+ server.listen(config.panel.port, config.panel.host);
238
+ });
239
+ }
240
+
241
+ // Stop the listener. Returns the URL that was being served, or null if it wasn't up.
242
+ export function stopPanel() {
243
+ if (!running) return null;
244
+ const { server, url } = running;
245
+ running = null;
246
+ server.close();
247
+ return url;
248
+ }
249
+
250
+ // Convenience used by boot auto-start and tests: configure + start in one call.
251
+ // Returns { server, getUrl } once listening (test-friendly shape preserved).
252
+ export function startPanel(opts) {
253
+ configurePanel(opts);
254
+ const { handlers, config, view } = configured;
255
+ const token = crypto.randomBytes(24).toString("hex");
256
+ const server = makeServer({ ...view, handlers, token });
257
+ server.on("error", (err) => configured.log(`error: ${err.message}`, true));
258
+ server.on("close", () => {
259
+ if (running?.server === server) running = null;
260
+ });
261
+ server.listen(config.panel.port, config.panel.host, () => {
262
+ const { port } = server.address();
263
+ const url = `http://${config.panel.host}:${port}/?t=${token}`;
264
+ running = { server, token, url };
265
+ configured.log(`ready (read-only) → ${url}`);
266
+ });
267
+ return {
268
+ server,
269
+ getUrl: () => {
270
+ const addr = server.address();
271
+ return addr
272
+ ? `http://${config.panel.host}:${addr.port}/?t=${token}`
273
+ : null;
274
+ },
275
+ };
276
+ }
277
+
278
+ // --- The served page (single self-contained document) -----------------------
279
+ function renderHtml() {
280
+ // Token comes from this page's own ?t=… ; meta + forms are fetched at load.
281
+ return `<!doctype html>
282
+ <html lang="tr">
283
+ <head>
284
+ <meta charset="utf-8" />
285
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
286
+ <title>SAP ADT — Kontrol Paneli</title>
287
+ <style>
288
+ :root { color-scheme: dark; }
289
+ * { box-sizing: border-box; }
290
+ body { margin: 0; font: 14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
291
+ background:#0f1116; color:#e6e8ee; }
292
+ header { padding:14px 20px; border-bottom:1px solid #222633; display:flex; gap:14px;
293
+ align-items:center; position:sticky; top:0; background:#0f1116; z-index:5; flex-wrap:wrap; }
294
+ header h1 { font-size:16px; margin:0; font-weight:600; }
295
+ .pill { font-size:11px; padding:2px 8px; border-radius:999px; background:#1b2030; color:#9aa3b8; }
296
+ .pill.ro { background:#13361f; color:#5fd38a; }
297
+ .pill.warn { background:#3a2417; color:#e0a06a; }
298
+ label.sysrow { margin-left:auto; font-size:12px; color:#9aa3b8; display:flex; gap:6px; align-items:center; }
299
+ select, input, textarea { background:#161a24; color:#e6e8ee; border:1px solid #2a3142;
300
+ border-radius:6px; padding:6px 8px; font:inherit; }
301
+ textarea { width:100%; min-height:54px; resize:vertical; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px; }
302
+ main { padding:18px 20px; display:grid; grid-template-columns:repeat(auto-fill,minmax(320px,1fr)); gap:14px; }
303
+ .card { background:#141822; border:1px solid #232838; border-radius:10px; padding:14px; }
304
+ .card h3 { margin:0 0 2px; font-size:14px; }
305
+ .cat { font-size:10px; text-transform:uppercase; letter-spacing:.06em; color:#717a90; }
306
+ .desc { color:#8b93a7; font-size:11px; margin:4px 0 10px; max-height:48px; overflow:auto; }
307
+ .field { margin-bottom:8px; }
308
+ .field label { display:block; font-size:11px; color:#9aa3b8; margin-bottom:3px; }
309
+ .field label .req { color:#e0716a; }
310
+ .field input, .field textarea { width:100%; }
311
+ button.run { background:#2f6df6; color:#fff; border:none; border-radius:7px; padding:8px 14px;
312
+ font:inherit; font-weight:600; cursor:pointer; }
313
+ button.run:hover { background:#4079f8; }
314
+ button.run:disabled { opacity:.5; cursor:default; }
315
+ pre.out { margin:10px 0 0; background:#0b0d13; border:1px solid #202635;
316
+ border-radius:7px; padding:10px; max-height:340px; overflow:auto; white-space:pre-wrap;
317
+ word-break:break-word; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11.5px; }
318
+ pre.out.err { border-color:#5a2a2a; color:#f0a3a3; }
319
+ pre.out:empty { display:none; }
320
+ .muted { color:#717a90; }
321
+ #boot { padding:40px 20px; color:#8b93a7; }
322
+ </style>
323
+ </head>
324
+ <body>
325
+ <header>
326
+ <h1>SAP ADT — Kontrol Paneli</h1>
327
+ <span class="pill ro">read-only</span>
328
+ <span id="ver" class="pill"></span>
329
+ <span id="globalro" class="pill warn" style="display:none">sistem global read-only</span>
330
+ <label class="sysrow">Sistem:
331
+ <select id="system"></select>
332
+ </label>
333
+ </header>
334
+ <div id="boot">Yükleniyor… <span class="muted">(MCP process'i ile aynı ömürde — session kapanınca bu sayfa ölür)</span></div>
335
+ <main id="cards" style="display:none"></main>
336
+ <script>
337
+ const TOKEN = new URLSearchParams(location.search).get("t") || "";
338
+ const $ = (s,el=document)=>el.querySelector(s);
339
+
340
+ async function call(name, args){
341
+ const r = await fetch("/call", {
342
+ method:"POST",
343
+ headers:{ "content-type":"application/json", "x-panel-token":TOKEN },
344
+ body: JSON.stringify({ name, args })
345
+ });
346
+ if(!r.ok){ return { text:"HTTP "+r.status+" — "+(await r.text()), isError:true }; }
347
+ return r.json();
348
+ }
349
+
350
+ function fieldFor(key, prop, required){
351
+ const wrap = document.createElement("div"); wrap.className="field";
352
+ const lab = document.createElement("label");
353
+ lab.innerHTML = key + (required?' <span class="req">*</span>':'') ;
354
+ if(prop.description){ lab.title = prop.description; }
355
+ wrap.appendChild(lab);
356
+ let input;
357
+ const t = prop.type;
358
+ if(t==="boolean"){
359
+ input=document.createElement("input"); input.type="checkbox";
360
+ input.style.width="auto";
361
+ } else if(t==="integer"||t==="number"){
362
+ input=document.createElement("input"); input.type="number";
363
+ if(prop.minimum!=null) input.min=prop.minimum;
364
+ if(prop.maximum!=null) input.max=prop.maximum;
365
+ } else if(t==="array"||t==="object"){
366
+ input=document.createElement("textarea");
367
+ input.placeholder="JSON, örn: "+(t==="array"?'[{"name":"...","type":"..."}]':'{}');
368
+ } else if(Array.isArray(prop.enum)){
369
+ input=document.createElement("select");
370
+ const blank=document.createElement("option"); blank.value=""; blank.textContent="(varsayılan)";
371
+ input.appendChild(blank);
372
+ for(const v of prop.enum){ const o=document.createElement("option"); o.value=v; o.textContent=v; input.appendChild(o); }
373
+ } else {
374
+ input=document.createElement("input"); input.type="text";
375
+ }
376
+ input.dataset.key=key; input.dataset.jtype=t||"string";
377
+ if(prop.description) input.title=prop.description;
378
+ wrap.appendChild(input);
379
+ return wrap;
380
+ }
381
+
382
+ function collect(card){
383
+ const args={};
384
+ for(const el of card.querySelectorAll("[data-key]")){
385
+ const k=el.dataset.key, t=el.dataset.jtype;
386
+ if(el.type==="checkbox"){ if(el.checked) args[k]=true; continue; }
387
+ const raw=el.value.trim();
388
+ if(raw==="") continue;
389
+ if(t==="integer"){ args[k]=parseInt(raw,10); }
390
+ else if(t==="number"){ args[k]=Number(raw); }
391
+ else if(t==="array"||t==="object"){
392
+ try { args[k]=JSON.parse(raw); } catch(e){ throw new Error(k+": geçersiz JSON"); }
393
+ }
394
+ else args[k]=raw;
395
+ }
396
+ return args;
397
+ }
398
+
399
+ function buildCard(tool, getSystem){
400
+ const card=document.createElement("section"); card.className="card";
401
+ const cat=document.createElement("div"); cat.className="cat"; cat.textContent=tool.cat; card.appendChild(cat);
402
+ const h=document.createElement("h3"); h.textContent=tool.label; card.appendChild(h);
403
+ const d=document.createElement("div"); d.className="desc"; d.textContent=tool.description; card.appendChild(d);
404
+
405
+ const props=tool.schema.properties||{};
406
+ const req=new Set(tool.schema.required||[]);
407
+ for(const key of Object.keys(props)){
408
+ if(key==="system") continue; // handled by the global system selector
409
+ card.appendChild(fieldFor(key, props[key], req.has(key)));
410
+ }
411
+ const btn=document.createElement("button"); btn.className="run"; btn.textContent="Çalıştır"; card.appendChild(btn);
412
+ const out=document.createElement("pre"); out.className="out"; card.appendChild(out);
413
+
414
+ btn.addEventListener("click", async ()=>{
415
+ let args;
416
+ try { args=collect(card); } catch(e){ out.className="out err"; out.textContent=e.message; return; }
417
+ const sys=getSystem();
418
+ if(sys) args.system=sys;
419
+ btn.disabled=true; const old=btn.textContent; btn.textContent="Çalışıyor…";
420
+ out.className="out"; out.textContent="";
421
+ try {
422
+ const res=await call(tool.name, args);
423
+ out.className="out"+(res.isError?" err":"");
424
+ out.textContent=res.text||"(boş yanıt)";
425
+ } catch(e){ out.className="out err"; out.textContent=String(e.message||e); }
426
+ finally { btn.disabled=false; btn.textContent=old; }
427
+ });
428
+ return card;
429
+ }
430
+
431
+ (async function init(){
432
+ let meta;
433
+ try {
434
+ const r=await fetch("/meta",{headers:{"x-panel-token":TOKEN}});
435
+ if(!r.ok) throw new Error("HTTP "+r.status);
436
+ meta=await r.json();
437
+ } catch(e){
438
+ $("#boot").textContent="Panel'e bağlanılamadı: "+e.message+" — token geçersiz ya da session kapandı.";
439
+ return;
440
+ }
441
+ $("#ver").textContent="v"+meta.version;
442
+ if(meta.readOnly) $("#globalro").style.display="";
443
+ const sel=$("#system");
444
+ for(const s of meta.systems){ const o=document.createElement("option"); o.value=s; o.textContent=s; sel.appendChild(o); }
445
+ if(meta.defaultSystem) sel.value=meta.defaultSystem;
446
+ const getSystem=()=>sel.value||"";
447
+
448
+ const cards=$("#cards");
449
+ for(const tool of meta.tools){ cards.appendChild(buildCard(tool, getSystem)); }
450
+ $("#boot").style.display="none";
451
+ cards.style.display="";
452
+ })();
453
+ </script>
454
+ </body>
455
+ </html>`;
456
+ }
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
+ }