sap-adt-mcp 0.8.43 → 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 +44 -0
- package/config.example.json +4 -0
- package/package.json +2 -2
- package/src/config.js +26 -0
- package/src/object-uris.js +15 -2
- package/src/panel.js +456 -0
- package/src/server.js +22 -0
- package/src/tools/cds.js +22 -6
- package/src/tools/discovery.js +3 -2
- package/src/tools/panel.js +92 -0
- package/src/tools/source.js +20 -7
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.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/object-uris.js
CHANGED
|
@@ -73,8 +73,21 @@ export function normalizeType(input) {
|
|
|
73
73
|
return aliased ?? upper;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
// ADT types arrive either as a bare key (CLAS, SRVD) or in TADIR-style
|
|
77
|
+
// TYPE/SUBTYPE form (SRVD/SRV, CLAS/OC, DDLS/DF). The subtype only changes the
|
|
78
|
+
// endpoint for function-group members (FUGR/FF module, FUGR/I include); for
|
|
79
|
+
// every other kind it is decoration, so collapse X/Y to its base type X. Without
|
|
80
|
+
// this, a TYPE/SUBTYPE input misses the dispatch table and throws "Unsupported
|
|
81
|
+
// object type".
|
|
82
|
+
function collapseSubtype(t) {
|
|
83
|
+
if (t.includes("/") && t !== "FUGR/FF" && t !== "FUGR/I") {
|
|
84
|
+
return t.split("/")[0];
|
|
85
|
+
}
|
|
86
|
+
return t;
|
|
87
|
+
}
|
|
88
|
+
|
|
76
89
|
export function objectUri({ type, name, group }) {
|
|
77
|
-
const t = normalizeType(type);
|
|
90
|
+
const t = collapseSubtype(normalizeType(type));
|
|
78
91
|
if (typeof name !== "string" || name.length === 0) {
|
|
79
92
|
throw new Error("Object name is required");
|
|
80
93
|
}
|
|
@@ -145,7 +158,7 @@ export const METADATA_XML_ACCEPT = {
|
|
|
145
158
|
};
|
|
146
159
|
|
|
147
160
|
export function sourceUri({ type, name, group, include }) {
|
|
148
|
-
const t = normalizeType(type);
|
|
161
|
+
const t = collapseSubtype(normalizeType(type));
|
|
149
162
|
const base = objectUri({ type: t, name, group });
|
|
150
163
|
|
|
151
164
|
if (t === "CLAS") {
|
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());
|
package/src/tools/cds.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { objectUri } from "../object-uris.js";
|
|
2
2
|
import { parseDataPreview } from "../data-preview.js";
|
|
3
3
|
import { parseObjectReferences } from "../object-references.js";
|
|
4
|
-
import { errorResult, jsonResult } from "../result.js";
|
|
4
|
+
import { errorResult, jsonResult, textResult } from "../result.js";
|
|
5
5
|
import { SYSTEM_HINT } from "./_shared.js";
|
|
6
6
|
|
|
7
7
|
const CDS_PREVIEW_PATH = "/sap/bc/adt/datapreview/cds";
|
|
@@ -86,12 +86,21 @@ export const tools = [
|
|
|
86
86
|
export function register({ getClient }) {
|
|
87
87
|
return {
|
|
88
88
|
adt_cds_data_preview: async (args) => {
|
|
89
|
+
// `entity` is the documented field, but callers reach for `name`/`cdsName`;
|
|
90
|
+
// accept those too and fail cleanly rather than throwing on `.toUpperCase`.
|
|
91
|
+
const entity = args.entity ?? args.name ?? args.cdsName;
|
|
92
|
+
if (typeof entity !== "string" || entity.length === 0) {
|
|
93
|
+
return textResult(
|
|
94
|
+
"adt_cds_data_preview: `entity` is required (the CDS view/entity name, e.g. 'I_CompanyCode').",
|
|
95
|
+
true
|
|
96
|
+
);
|
|
97
|
+
}
|
|
89
98
|
const { client, name: sys } = getClient(args.system);
|
|
90
99
|
const max = Math.min(args.maxRows ?? DEFAULT_MAX_ROWS, HARD_CAP_ROWS);
|
|
91
100
|
const res = await client.request({
|
|
92
101
|
method: "POST",
|
|
93
102
|
path: CDS_PREVIEW_PATH,
|
|
94
|
-
query: { ddlSourceName:
|
|
103
|
+
query: { ddlSourceName: entity.toUpperCase(), rowNumber: String(max) },
|
|
95
104
|
accept: "application/xml",
|
|
96
105
|
});
|
|
97
106
|
const text = await res.text();
|
|
@@ -100,12 +109,12 @@ export function register({ getClient }) {
|
|
|
100
109
|
try {
|
|
101
110
|
parsed = parseDataPreview(text);
|
|
102
111
|
} catch (err) {
|
|
103
|
-
return jsonResult({ system: sys, entity
|
|
112
|
+
return jsonResult({ system: sys, entity, parseError: err.message, raw: text.slice(0, 8000) });
|
|
104
113
|
}
|
|
105
114
|
const rowCount = parsed.rows.length;
|
|
106
115
|
return jsonResult({
|
|
107
116
|
system: sys,
|
|
108
|
-
entity:
|
|
117
|
+
entity: entity.toUpperCase(),
|
|
109
118
|
rowCount,
|
|
110
119
|
truncated: rowCount >= max,
|
|
111
120
|
totalRows: parsed.totalRows,
|
|
@@ -116,8 +125,15 @@ export function register({ getClient }) {
|
|
|
116
125
|
},
|
|
117
126
|
|
|
118
127
|
adt_cds_dependencies: async (args) => {
|
|
128
|
+
const entity = args.entity ?? args.name ?? args.cdsName;
|
|
129
|
+
if (typeof entity !== "string" || entity.length === 0) {
|
|
130
|
+
return textResult(
|
|
131
|
+
"adt_cds_dependencies: `entity` is required (the CDS view/entity name, e.g. 'I_CompanyCode').",
|
|
132
|
+
true
|
|
133
|
+
);
|
|
134
|
+
}
|
|
119
135
|
const { client, name: sys } = getClient(args.system);
|
|
120
|
-
const uri = objectUri({ type: "ddls", name:
|
|
136
|
+
const uri = objectUri({ type: "ddls", name: entity });
|
|
121
137
|
const res = await client.request({ path: GRAPHDATA_PATH, query: { uri }, accept: "application/xml" });
|
|
122
138
|
const text = await res.text();
|
|
123
139
|
if (!res.ok) return errorResult(sys, res.status, text, res.headers.get("content-type"), { stage: "graphdata" });
|
|
@@ -131,7 +147,7 @@ export function register({ getClient }) {
|
|
|
131
147
|
}
|
|
132
148
|
return jsonResult({
|
|
133
149
|
system: sys,
|
|
134
|
-
entity:
|
|
150
|
+
entity: entity.toUpperCase(),
|
|
135
151
|
uri,
|
|
136
152
|
dependencyCount: refs.length,
|
|
137
153
|
dependencies: refs,
|
package/src/tools/discovery.js
CHANGED
|
@@ -315,8 +315,9 @@ export function register({ getClient }) {
|
|
|
315
315
|
|
|
316
316
|
adt_list_packages: async (args) => {
|
|
317
317
|
// `package` is the field name on the sibling adt_browse_package tool, so
|
|
318
|
-
// callers reach for it here too — accept it
|
|
319
|
-
|
|
318
|
+
// callers reach for it here too — accept it (and a bare `name`) as an
|
|
319
|
+
// alias for `root`.
|
|
320
|
+
const rootArg = args.root ?? args.package ?? args.name;
|
|
320
321
|
if (typeof rootArg !== "string" || rootArg.length === 0) {
|
|
321
322
|
return textResult(
|
|
322
323
|
"adt_list_packages: `root` is required (the root package to walk from, e.g. 'ZFLEET').",
|
|
@@ -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
|
+
}
|
package/src/tools/source.js
CHANGED
|
@@ -286,13 +286,26 @@ export function register({ getClient }) {
|
|
|
286
286
|
return textResult("adt_get_source: `type` is required (e.g. 'class', 'program', 'dataelement').", true);
|
|
287
287
|
}
|
|
288
288
|
const { client, name: sys } = getClient(args.system);
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
289
|
+
let t;
|
|
290
|
+
let path;
|
|
291
|
+
try {
|
|
292
|
+
t = normalizeType(args.type);
|
|
293
|
+
path = sourceUri({
|
|
294
|
+
type: args.type,
|
|
295
|
+
name: args.object,
|
|
296
|
+
group: args.group,
|
|
297
|
+
include: args.include,
|
|
298
|
+
});
|
|
299
|
+
} catch (err) {
|
|
300
|
+
// Unknown/unsupported object types (e.g. WAPA) reach the dispatch
|
|
301
|
+
// tables and throw. Return that as a clean tool error with an escape
|
|
302
|
+
// hatch hint instead of letting it surface as a crash.
|
|
303
|
+
return textResult(
|
|
304
|
+
`adt_get_source: ${err.message}. If this type has no high-level mapping yet, ` +
|
|
305
|
+
`fetch it with adt_request against its ADT source URI.`,
|
|
306
|
+
true
|
|
307
|
+
);
|
|
308
|
+
}
|
|
296
309
|
|
|
297
310
|
// DDIC primitives (data element / domain / message class) have no
|
|
298
311
|
// plain-text source — fetch their XML metadata with the right media type
|