sap-adt-mcp 0.7.1
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/LICENSE +21 -0
- package/README.md +527 -0
- package/config.example.json +32 -0
- package/package.json +55 -0
- package/src/adt-client.js +278 -0
- package/src/adt-error.js +135 -0
- package/src/config.js +78 -0
- package/src/data-preview.js +148 -0
- package/src/diff.js +123 -0
- package/src/dump-feed.js +251 -0
- package/src/lock.js +52 -0
- package/src/node-structure.js +56 -0
- package/src/object-create.js +244 -0
- package/src/object-references.js +28 -0
- package/src/object-uris.js +156 -0
- package/src/prompts.js +533 -0
- package/src/result.js +23 -0
- package/src/server.js +206 -0
- package/src/tools/_shared.js +11 -0
- package/src/tools/cds.js +157 -0
- package/src/tools/connection.js +46 -0
- package/src/tools/cross-system.js +191 -0
- package/src/tools/data.js +86 -0
- package/src/tools/discovery.js +520 -0
- package/src/tools/jobs.js +107 -0
- package/src/tools/lifecycle.js +314 -0
- package/src/tools/notes.js +147 -0
- package/src/tools/quality.js +407 -0
- package/src/tools/rap.js +287 -0
- package/src/tools/request.js +103 -0
- package/src/tools/runtime.js +244 -0
- package/src/tools/source.js +622 -0
- package/src/tools/transports.js +163 -0
- package/src/tools/versions.js +154 -0
- package/src/tools/worklist.js +112 -0
- package/src/xml.js +8 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { Agent, fetch as undiciFetch } from "undici";
|
|
2
|
+
import { BUILD_FINGERPRINT, CLIENT_TRACE_SALT } from "./tools/_shared.js";
|
|
3
|
+
|
|
4
|
+
const UNSAFE_METHODS = new Set(["POST", "PUT", "DELETE", "PATCH"]);
|
|
5
|
+
const DISCOVERY_PATH = "/sap/bc/adt/discovery";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
8
|
+
|
|
9
|
+
// ADT endpoints that use POST but are read-only queries — allowed in read-only mode.
|
|
10
|
+
const READONLY_POST_PATHS = [
|
|
11
|
+
"/sap/bc/adt/repository/nodestructure",
|
|
12
|
+
"/sap/bc/adt/repository/informationsystem/search",
|
|
13
|
+
"/sap/bc/adt/repository/informationsystem/usagereferences",
|
|
14
|
+
"/sap/bc/adt/abapsource/parsers",
|
|
15
|
+
"/sap/bc/adt/checkruns",
|
|
16
|
+
// ATC worklist analysis: POSTs that create a transient worklist and run
|
|
17
|
+
// checks but do not modify any repository object — read-only in spirit.
|
|
18
|
+
"/sap/bc/adt/atc/",
|
|
19
|
+
// Data preview (OpenSQL SELECT / CDS preview) — read-only query execution.
|
|
20
|
+
"/sap/bc/adt/datapreview/",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
// Headers a caller is NOT allowed to override via adt_request. Letting these
|
|
24
|
+
// through would let the LLM (or anything that prompt-injects it) impersonate
|
|
25
|
+
// another user (Authorization), forge a session (Cookie), or smuggle a stale
|
|
26
|
+
// CSRF token. The client manages all three internally.
|
|
27
|
+
const PROTECTED_HEADERS = new Set([
|
|
28
|
+
"authorization",
|
|
29
|
+
"cookie",
|
|
30
|
+
"x-csrf-token",
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const DEBUG = process.env.SAP_ADT_MCP_DEBUG === "1";
|
|
34
|
+
|
|
35
|
+
export class ReadOnlyViolationError extends Error {
|
|
36
|
+
constructor(method, path) {
|
|
37
|
+
super(
|
|
38
|
+
`Read-only mode: refusing ${method} ${path}. ` +
|
|
39
|
+
"Set readOnly: false in config to allow writes."
|
|
40
|
+
);
|
|
41
|
+
this.name = "ReadOnlyViolationError";
|
|
42
|
+
this.code = "READ_ONLY";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class AdtClient {
|
|
47
|
+
constructor(profile) {
|
|
48
|
+
this.profile = profile;
|
|
49
|
+
this.cookies = new Map();
|
|
50
|
+
this.csrfToken = null;
|
|
51
|
+
this.timeoutMs = profile.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
52
|
+
this.authHeader =
|
|
53
|
+
"Basic " +
|
|
54
|
+
Buffer.from(`${profile.user}:${profile.password}`).toString("base64");
|
|
55
|
+
this.dispatcher =
|
|
56
|
+
profile.rejectUnauthorized === false
|
|
57
|
+
? new Agent({ connect: { rejectUnauthorized: false } })
|
|
58
|
+
: undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async request({ method = "GET", path, query, body, headers = {}, accept, timeoutMs }) {
|
|
62
|
+
const upperMethod = method.toUpperCase();
|
|
63
|
+
|
|
64
|
+
// Resolve the path the same way #buildUrl will resolve it (collapsing
|
|
65
|
+
// "../" segments) BEFORE running the read-only check. Doing the check on
|
|
66
|
+
// the raw string lets a caller smuggle a write through a read-only
|
|
67
|
+
// allowlist entry, e.g. "/sap/bc/adt/checkruns/../programs/..."
|
|
68
|
+
// ─ startsWith() sees "/sap/bc/adt/checkruns" and allows the request, but
|
|
69
|
+
// new URL() then collapses it to "/sap/bc/adt/programs/...". Normalize
|
|
70
|
+
// first, gate second.
|
|
71
|
+
const resolvedPath = this.#resolvePath(path);
|
|
72
|
+
|
|
73
|
+
if (UNSAFE_METHODS.has(upperMethod) && this.profile.readOnly) {
|
|
74
|
+
if (!isReadOnlyPostPath(resolvedPath)) {
|
|
75
|
+
throw new ReadOnlyViolationError(upperMethod, resolvedPath);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (UNSAFE_METHODS.has(upperMethod) && !this.csrfToken) {
|
|
80
|
+
await this.#fetchCsrf();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let res = await this.#send(upperMethod, resolvedPath, query, body, headers, accept, undefined, timeoutMs);
|
|
84
|
+
|
|
85
|
+
if (
|
|
86
|
+
res.status === 403 &&
|
|
87
|
+
(res.headers.get("x-csrf-token") || "").toLowerCase() === "required"
|
|
88
|
+
) {
|
|
89
|
+
this.csrfToken = null;
|
|
90
|
+
await this.#fetchCsrf();
|
|
91
|
+
res = await this.#send(upperMethod, resolvedPath, query, body, headers, accept, undefined, timeoutMs);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return res;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Public wrapper so callers (e.g. the adt_request handler) can apply the
|
|
98
|
+
// same path-prefix policy the client itself enforces internally.
|
|
99
|
+
resolvePath(path) {
|
|
100
|
+
return this.#resolvePath(path);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#resolvePath(path) {
|
|
104
|
+
if (typeof path !== "string" || path.length === 0) {
|
|
105
|
+
throw new Error("ADT path must be a non-empty string");
|
|
106
|
+
}
|
|
107
|
+
const base = this.profile.host.replace(/\/$/, "");
|
|
108
|
+
const normalized = path.startsWith("/") ? path : `/${path}`;
|
|
109
|
+
let url;
|
|
110
|
+
try {
|
|
111
|
+
url = new URL(base + normalized);
|
|
112
|
+
} catch {
|
|
113
|
+
throw new Error(`ADT path is not a valid URL component: ${path}`);
|
|
114
|
+
}
|
|
115
|
+
// Preserve any query stuffed into the path (some internal callers do
|
|
116
|
+
// this); the read-only check below splits on "?" anyway.
|
|
117
|
+
return url.pathname + url.search;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async #fetchCsrf() {
|
|
121
|
+
// Pass the fetch header via `internalHeaders` so #send doesn't strip it
|
|
122
|
+
// through the PROTECTED_HEADERS filter (that filter exists to stop
|
|
123
|
+
// adt_request callers from forging Authorization/Cookie/X-CSRF-Token —
|
|
124
|
+
// it must not apply to the client's own CSRF handshake).
|
|
125
|
+
const res = await this.#send(
|
|
126
|
+
"GET",
|
|
127
|
+
DISCOVERY_PATH,
|
|
128
|
+
null,
|
|
129
|
+
null,
|
|
130
|
+
{},
|
|
131
|
+
"application/atomsvc+xml",
|
|
132
|
+
{ "X-CSRF-Token": "Fetch" }
|
|
133
|
+
);
|
|
134
|
+
const token = res.headers.get("x-csrf-token");
|
|
135
|
+
if (!token || token.toLowerCase() === "required") {
|
|
136
|
+
const body = await res.text().catch(() => "");
|
|
137
|
+
throw new Error(
|
|
138
|
+
`Failed to fetch CSRF token (status ${res.status}): ${body.slice(0, 200)}`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
this.csrfToken = token;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async #send(method, adtPath, query, body, extraHeaders, accept, internalHeaders, timeoutMs) {
|
|
145
|
+
const url = this.#buildUrl(adtPath, query);
|
|
146
|
+
const headers = new Headers();
|
|
147
|
+
headers.set("Authorization", this.authHeader);
|
|
148
|
+
headers.set(
|
|
149
|
+
"Accept",
|
|
150
|
+
accept ?? "application/xml, application/json;q=0.9, */*;q=0.1"
|
|
151
|
+
);
|
|
152
|
+
headers.set(
|
|
153
|
+
"User-Agent",
|
|
154
|
+
`sap-adt-mcp (build ${BUILD_FINGERPRINT}; trace ${CLIENT_TRACE_SALT})`
|
|
155
|
+
);
|
|
156
|
+
if (this.cookies.size > 0) headers.set("Cookie", this.#cookieHeader());
|
|
157
|
+
if (this.csrfToken && UNSAFE_METHODS.has(method)) {
|
|
158
|
+
headers.set("X-CSRF-Token", this.csrfToken);
|
|
159
|
+
}
|
|
160
|
+
// Apply caller-supplied headers, but never let them override the auth /
|
|
161
|
+
// session headers the client owns. Without this, adt_request callers (or
|
|
162
|
+
// anything that prompt-injects the LLM into using it) could swap in a
|
|
163
|
+
// forged Authorization to impersonate another SAP user.
|
|
164
|
+
for (const [k, v] of Object.entries(extraHeaders)) {
|
|
165
|
+
if (PROTECTED_HEADERS.has(k.toLowerCase())) continue;
|
|
166
|
+
headers.set(k, v);
|
|
167
|
+
}
|
|
168
|
+
// Internal headers come from the client itself (e.g. the X-CSRF-Token:
|
|
169
|
+
// Fetch handshake) and are trusted — they bypass the protection filter.
|
|
170
|
+
if (internalHeaders) {
|
|
171
|
+
for (const [k, v] of Object.entries(internalHeaders)) {
|
|
172
|
+
headers.set(k, v);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let reqBody;
|
|
177
|
+
if (body !== undefined && body !== null) {
|
|
178
|
+
if (typeof body === "string") {
|
|
179
|
+
reqBody = body;
|
|
180
|
+
} else {
|
|
181
|
+
reqBody = JSON.stringify(body);
|
|
182
|
+
if (!headers.has("Content-Type")) {
|
|
183
|
+
headers.set("Content-Type", "application/json");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const init = { method, headers, body: reqBody };
|
|
189
|
+
if (this.dispatcher) init.dispatcher = this.dispatcher;
|
|
190
|
+
|
|
191
|
+
const effectiveTimeout = timeoutMs ?? this.timeoutMs;
|
|
192
|
+
const controller = new AbortController();
|
|
193
|
+
const timer = setTimeout(() => controller.abort(), effectiveTimeout);
|
|
194
|
+
init.signal = controller.signal;
|
|
195
|
+
|
|
196
|
+
if (DEBUG) traceRequest(method, url, headers, reqBody);
|
|
197
|
+
|
|
198
|
+
let res;
|
|
199
|
+
const start = Date.now();
|
|
200
|
+
try {
|
|
201
|
+
res = await undiciFetch(url, init);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
if (err.name === "AbortError") {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`ADT request timed out after ${effectiveTimeout}ms: ${method} ${adtPath}`,
|
|
206
|
+
{ cause: err }
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
throw err;
|
|
210
|
+
} finally {
|
|
211
|
+
clearTimeout(timer);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (DEBUG) traceResponse(method, url, res.status, Date.now() - start);
|
|
215
|
+
|
|
216
|
+
this.#captureCookies(res.headers);
|
|
217
|
+
return res;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
#buildUrl(adtPath, query) {
|
|
221
|
+
const base = this.profile.host.replace(/\/$/, "");
|
|
222
|
+
const normalized = adtPath.startsWith("/") ? adtPath : `/${adtPath}`;
|
|
223
|
+
const url = new URL(base + normalized);
|
|
224
|
+
if (this.profile.client && !url.searchParams.has("sap-client")) {
|
|
225
|
+
url.searchParams.set("sap-client", this.profile.client);
|
|
226
|
+
}
|
|
227
|
+
if (this.profile.language && !url.searchParams.has("sap-language")) {
|
|
228
|
+
url.searchParams.set("sap-language", this.profile.language);
|
|
229
|
+
}
|
|
230
|
+
if (query) {
|
|
231
|
+
for (const [k, v] of Object.entries(query)) {
|
|
232
|
+
url.searchParams.set(k, String(v));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return url.toString();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
#captureCookies(headers) {
|
|
239
|
+
const setCookies =
|
|
240
|
+
typeof headers.getSetCookie === "function" ? headers.getSetCookie() : [];
|
|
241
|
+
for (const sc of setCookies) {
|
|
242
|
+
const [pair] = sc.split(";");
|
|
243
|
+
const eq = pair.indexOf("=");
|
|
244
|
+
if (eq > 0) {
|
|
245
|
+
this.cookies.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
#cookieHeader() {
|
|
251
|
+
return [...this.cookies.entries()]
|
|
252
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
253
|
+
.join("; ");
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function isReadOnlyPostPath(p) {
|
|
258
|
+
if (typeof p !== "string") return false;
|
|
259
|
+
const lower = p.toLowerCase().split("?")[0];
|
|
260
|
+
return READONLY_POST_PATHS.some((allowed) => lower.startsWith(allowed));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function traceRequest(method, url, headers, body) {
|
|
264
|
+
const safeUrl = url.replace(/\/\/[^@/]+@/, "//***@"); // strip basic-auth prefix if any
|
|
265
|
+
process.stderr.write(`[adt-debug] → ${method} ${safeUrl}\n`);
|
|
266
|
+
for (const [k, v] of headers.entries()) {
|
|
267
|
+
if (k.toLowerCase() === "authorization") continue;
|
|
268
|
+
process.stderr.write(`[adt-debug] ${k}: ${v}\n`);
|
|
269
|
+
}
|
|
270
|
+
if (body) {
|
|
271
|
+
const preview = typeof body === "string" ? body.slice(0, 200) : "[binary]";
|
|
272
|
+
process.stderr.write(`[adt-debug] body: ${preview}\n`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function traceResponse(method, url, status, ms) {
|
|
277
|
+
process.stderr.write(`[adt-debug] ← ${status} ${method} ${url} (${ms}ms)\n`);
|
|
278
|
+
}
|
package/src/adt-error.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Parse SAP ADT error envelopes.
|
|
2
|
+
//
|
|
3
|
+
// ADT typically returns failures as XML wrapped in <exc:exception>:
|
|
4
|
+
// <exc:exception ... xmlns:exc="http://www.sap.com/abapxml/types/communication">
|
|
5
|
+
// <namespace id="com.sap.adt"/>
|
|
6
|
+
// <type id="ExceptionResourceFailure"/>
|
|
7
|
+
// <message lang="EN">Object ZFOO does not exist</message>
|
|
8
|
+
// <localizedMessage lang="EN">...</localizedMessage>
|
|
9
|
+
// <properties>
|
|
10
|
+
// <entry key="LONGTEXT"><![CDATA[<html>...full diagnostics...</html>]]></entry>
|
|
11
|
+
// <entry key="T100KEY-ID">SLOCK</entry>
|
|
12
|
+
// <entry key="T100KEY-NO">038</entry>
|
|
13
|
+
// <entry key="T100KEY-V1">…blocking-transport id…</entry>
|
|
14
|
+
// <entry key="T100KEY-V2">…</entry>
|
|
15
|
+
// …
|
|
16
|
+
// </properties>
|
|
17
|
+
// </exc:exception>
|
|
18
|
+
//
|
|
19
|
+
// On CTS / SLOCK / S_LOCK errors, the properties carry the actual diagnostic
|
|
20
|
+
// (which TR blocks the lock, who owns it, suggested resolution). Older code
|
|
21
|
+
// dropped these — surface them as `properties.longText` and `properties.t100`.
|
|
22
|
+
//
|
|
23
|
+
// Some endpoints return abap-style messages instead — we fall through to a
|
|
24
|
+
// best-effort message extraction.
|
|
25
|
+
|
|
26
|
+
const TYPE_RE = /<type[^>]*id\s*=\s*"([^"]+)"/i;
|
|
27
|
+
const NS_RE = /<namespace[^>]*id\s*=\s*"([^"]+)"/i;
|
|
28
|
+
const MSG_RE = /<message[^>]*>([\s\S]*?)<\/message>/i;
|
|
29
|
+
const LOCAL_MSG_RE = /<localizedMessage[^>]*>([\s\S]*?)<\/localizedMessage>/i;
|
|
30
|
+
|
|
31
|
+
// Match both <entry key="X">val</entry> and <property name="X">val</property>
|
|
32
|
+
// shapes — different ADT endpoints use different conventions.
|
|
33
|
+
const PROPERTY_RE =
|
|
34
|
+
/<(?:entry|property)\b[^>]*\b(?:key|name)\s*=\s*"([^"]+)"[^>]*>([\s\S]*?)<\/(?:entry|property)>/gi;
|
|
35
|
+
|
|
36
|
+
export function parseAdtError(body, contentType) {
|
|
37
|
+
if (typeof body !== "string" || body.length === 0) return null;
|
|
38
|
+
|
|
39
|
+
const isXml =
|
|
40
|
+
(contentType && /xml/i.test(contentType)) ||
|
|
41
|
+
body.trimStart().startsWith("<");
|
|
42
|
+
|
|
43
|
+
if (!isXml) return null;
|
|
44
|
+
|
|
45
|
+
const hasExceptionTag = /<\w*:?exception\b/i.test(body);
|
|
46
|
+
if (!hasExceptionTag && !MSG_RE.test(body)) return null;
|
|
47
|
+
|
|
48
|
+
const type = match(body, TYPE_RE);
|
|
49
|
+
const namespace = match(body, NS_RE);
|
|
50
|
+
const message = decode(match(body, MSG_RE));
|
|
51
|
+
const localizedMessage = decode(match(body, LOCAL_MSG_RE));
|
|
52
|
+
const props = extractProperties(body);
|
|
53
|
+
|
|
54
|
+
if (!type && !message && !localizedMessage && !props) return null;
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
type,
|
|
58
|
+
namespace,
|
|
59
|
+
message,
|
|
60
|
+
localizedMessage: localizedMessage === message ? undefined : localizedMessage,
|
|
61
|
+
...(props ? { properties: props } : {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function extractProperties(body) {
|
|
66
|
+
const raw = {};
|
|
67
|
+
let m;
|
|
68
|
+
PROPERTY_RE.lastIndex = 0;
|
|
69
|
+
while ((m = PROPERTY_RE.exec(body)) !== null) {
|
|
70
|
+
const key = m[1];
|
|
71
|
+
const value = stripCData(m[2]);
|
|
72
|
+
if (key && value) raw[key] = decode(value).trim();
|
|
73
|
+
}
|
|
74
|
+
if (Object.keys(raw).length === 0) return null;
|
|
75
|
+
|
|
76
|
+
const result = {};
|
|
77
|
+
if (raw.LONGTEXT) {
|
|
78
|
+
result.longText = stripHtml(raw.LONGTEXT);
|
|
79
|
+
}
|
|
80
|
+
const t100 = {};
|
|
81
|
+
if (raw["T100KEY-ID"]) t100.id = raw["T100KEY-ID"];
|
|
82
|
+
if (raw["T100KEY-NO"]) t100.number = raw["T100KEY-NO"];
|
|
83
|
+
for (let i = 1; i <= 4; i++) {
|
|
84
|
+
const v = raw[`T100KEY-V${i}`];
|
|
85
|
+
if (v) {
|
|
86
|
+
t100.vars = t100.vars || [];
|
|
87
|
+
t100.vars.push(v);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (Object.keys(t100).length > 0) result.t100 = t100;
|
|
91
|
+
|
|
92
|
+
// Anything else (e.g. CTS-specific properties) — keep as `other`.
|
|
93
|
+
const other = {};
|
|
94
|
+
for (const k of Object.keys(raw)) {
|
|
95
|
+
if (k === "LONGTEXT" || k.startsWith("T100KEY-")) continue;
|
|
96
|
+
other[k] = raw[k];
|
|
97
|
+
}
|
|
98
|
+
if (Object.keys(other).length > 0) result.other = other;
|
|
99
|
+
|
|
100
|
+
return Object.keys(result).length > 0 ? result : null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function stripCData(s) {
|
|
104
|
+
if (!s) return s;
|
|
105
|
+
const trimmed = s.trim();
|
|
106
|
+
const m = trimmed.match(/^<!\[CDATA\[([\s\S]*)\]\]>$/);
|
|
107
|
+
return m ? m[1] : trimmed;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function stripHtml(s) {
|
|
111
|
+
if (!s) return s;
|
|
112
|
+
return s
|
|
113
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
114
|
+
.replace(/<\/p>/gi, "\n")
|
|
115
|
+
.replace(/<[^>]+>/g, "")
|
|
116
|
+
.replace(/\u00A0/g, " ")
|
|
117
|
+
.replace(/[ \t]+\n/g, "\n")
|
|
118
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
119
|
+
.trim();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function match(s, re) {
|
|
123
|
+
const m = s.match(re);
|
|
124
|
+
return m ? m[1].trim() : undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function decode(s) {
|
|
128
|
+
if (!s) return s;
|
|
129
|
+
return s
|
|
130
|
+
.replace(/</g, "<")
|
|
131
|
+
.replace(/>/g, ">")
|
|
132
|
+
.replace(/"/g, '"')
|
|
133
|
+
.replace(/'/g, "'")
|
|
134
|
+
.replace(/&/g, "&");
|
|
135
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
|
|
5
|
+
const CANDIDATE_PATHS = [
|
|
6
|
+
process.env.SAP_ADT_MCP_CONFIG,
|
|
7
|
+
path.join(os.homedir(), ".sap-adt-mcp", "config.json"),
|
|
8
|
+
path.join(process.cwd(), "config.json"),
|
|
9
|
+
].filter(Boolean);
|
|
10
|
+
|
|
11
|
+
export function loadConfig() {
|
|
12
|
+
const configPath = CANDIDATE_PATHS.find((p) => fs.existsSync(p));
|
|
13
|
+
if (!configPath) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
"No config found. Set SAP_ADT_MCP_CONFIG or create ~/.sap-adt-mcp/config.json " +
|
|
16
|
+
"(see config.example.json)."
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let raw;
|
|
21
|
+
try {
|
|
22
|
+
raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
23
|
+
} catch (err) {
|
|
24
|
+
throw new Error(`Failed to parse config at ${configPath}: ${err.message}`, {
|
|
25
|
+
cause: err,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const globalReadOnly = raw.readOnly === true;
|
|
30
|
+
|
|
31
|
+
const systems = {};
|
|
32
|
+
for (const [name, profile] of Object.entries(raw.systems ?? {})) {
|
|
33
|
+
systems[name] = {
|
|
34
|
+
host: requireString(profile.host, `${name}.host`),
|
|
35
|
+
client: profile.client != null ? String(profile.client) : undefined,
|
|
36
|
+
language: profile.language != null ? String(profile.language) : undefined,
|
|
37
|
+
user: requireString(profile.user, `${name}.user`),
|
|
38
|
+
password: resolveSecret(profile.password, name),
|
|
39
|
+
rejectUnauthorized: profile.rejectUnauthorized !== false,
|
|
40
|
+
readOnly: globalReadOnly || profile.readOnly === true,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (Object.keys(systems).length === 0) {
|
|
45
|
+
throw new Error(`No systems configured in ${configPath}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
defaultSystem: raw.defaultSystem,
|
|
50
|
+
readOnly: globalReadOnly,
|
|
51
|
+
systems,
|
|
52
|
+
configPath,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function requireString(value, key) {
|
|
57
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
58
|
+
throw new Error(`Config: ${key} must be a non-empty string`);
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resolveSecret(value, systemName) {
|
|
64
|
+
if (typeof value !== "string") {
|
|
65
|
+
throw new Error(`System ${systemName}: password must be a string`);
|
|
66
|
+
}
|
|
67
|
+
if (value.startsWith("env:")) {
|
|
68
|
+
const varName = value.slice(4);
|
|
69
|
+
const resolved = process.env[varName];
|
|
70
|
+
if (!resolved) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`System ${systemName}: env var ${varName} is not set`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return resolved;
|
|
76
|
+
}
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// SAP ADT Data Preview endpoint helpers.
|
|
2
|
+
//
|
|
3
|
+
// The /sap/bc/adt/datapreview/freestyle endpoint accepts an OpenSQL SELECT
|
|
4
|
+
// statement and returns column metadata + rows in a dataPreview-namespaced
|
|
5
|
+
// XML document. The exact shape varies slightly across releases:
|
|
6
|
+
//
|
|
7
|
+
// <dataPreview:tableData>
|
|
8
|
+
// <dataPreview:columns>
|
|
9
|
+
// <dataPreview:metadata dataPreview:name="MATNR" dataPreview:type="C" ... />
|
|
10
|
+
// ...
|
|
11
|
+
// </dataPreview:columns>
|
|
12
|
+
// <dataPreview:values>
|
|
13
|
+
// <dataPreview:row>
|
|
14
|
+
// <dataPreview:value>...</dataPreview:value>
|
|
15
|
+
// ...
|
|
16
|
+
// </dataPreview:row>
|
|
17
|
+
// </dataPreview:values>
|
|
18
|
+
// </dataPreview:tableData>
|
|
19
|
+
//
|
|
20
|
+
// Older releases emit a flat <dataPreview:dataSet> with
|
|
21
|
+
// <dataPreview:data columnName="X">value</dataPreview:data> per cell — we
|
|
22
|
+
// handle both.
|
|
23
|
+
|
|
24
|
+
// Client-side guard. The SAP /datapreview/freestyle endpoint already enforces
|
|
25
|
+
// SELECT-only on the server side, but we add a cheap sanity check so callers
|
|
26
|
+
// fail fast and obvious mistakes don't burn a round-trip. We don't try to
|
|
27
|
+
// fully parse OpenSQL — we just confirm the statement starts with SELECT and
|
|
28
|
+
// doesn't chain a second statement via ";".
|
|
29
|
+
export function validateSelect(query) {
|
|
30
|
+
if (typeof query !== "string" || query.trim().length === 0) {
|
|
31
|
+
return { ok: false, reason: "Query is empty." };
|
|
32
|
+
}
|
|
33
|
+
// Strip leading line comments (ABAP: " or *).
|
|
34
|
+
let stripped = query.trim();
|
|
35
|
+
while (true) {
|
|
36
|
+
const before = stripped;
|
|
37
|
+
stripped = stripped.replace(/^\s*(?:"|\*)[^\n]*\n/, "").trimStart();
|
|
38
|
+
if (stripped === before) break;
|
|
39
|
+
}
|
|
40
|
+
if (!/^SELECT\b/i.test(stripped)) {
|
|
41
|
+
return { ok: false, reason: "Only SELECT statements are allowed." };
|
|
42
|
+
}
|
|
43
|
+
// Reject a semicolon followed by more non-trivial content (statement chain).
|
|
44
|
+
const withoutTrailingSemi = stripped.replace(/;\s*$/, "");
|
|
45
|
+
if (/;\s*\S/.test(withoutTrailingSemi)) {
|
|
46
|
+
return { ok: false, reason: "Multiple statements are not allowed." };
|
|
47
|
+
}
|
|
48
|
+
return { ok: true };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const METADATA_RE =
|
|
52
|
+
/<[a-zA-Z]+:metadata\b([^/]*)\/>|<[a-zA-Z]+:metadata\b([^>]*)>[\s\S]*?<\/[a-zA-Z]+:metadata>/g;
|
|
53
|
+
const ROW_RE = /<[a-zA-Z]+:row\b[^>]*>([\s\S]*?)<\/[a-zA-Z]+:row>/g;
|
|
54
|
+
const VALUE_RE = /<[a-zA-Z]+:value\b[^>]*>([\s\S]*?)<\/[a-zA-Z]+:value>/g;
|
|
55
|
+
const DATA_CELL_RE =
|
|
56
|
+
/<[a-zA-Z]+:data\b[^>]*\b(?:dataPreview:)?columnName="([^"]+)"[^>]*>([\s\S]*?)<\/[a-zA-Z]+:data>/g;
|
|
57
|
+
const ATTR_RE = (attr) =>
|
|
58
|
+
new RegExp(`\\b(?:[a-zA-Z]+:)?${attr}="([^"]*)"`, "i");
|
|
59
|
+
|
|
60
|
+
function pickAttr(s, attr) {
|
|
61
|
+
const m = s.match(ATTR_RE(attr));
|
|
62
|
+
return m ? decodeEntities(m[1]) : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function decodeEntities(s) {
|
|
66
|
+
return s
|
|
67
|
+
.replace(/</g, "<")
|
|
68
|
+
.replace(/>/g, ">")
|
|
69
|
+
.replace(/"/g, '"')
|
|
70
|
+
.replace(/'/g, "'")
|
|
71
|
+
.replace(/&/g, "&");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function pickTagText(xml, tag) {
|
|
75
|
+
const re = new RegExp(`<[a-zA-Z]+:${tag}\\b[^>]*>([\\s\\S]*?)<\\/[a-zA-Z]+:${tag}>`, "i");
|
|
76
|
+
const m = xml.match(re);
|
|
77
|
+
return m ? decodeEntities(m[1].trim()) : undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function parseDataPreview(xml) {
|
|
81
|
+
const columns = [];
|
|
82
|
+
for (const m of xml.matchAll(METADATA_RE)) {
|
|
83
|
+
const attrs = m[1] ?? m[2] ?? "";
|
|
84
|
+
const name = pickAttr(attrs, "name");
|
|
85
|
+
if (!name) continue;
|
|
86
|
+
columns.push({
|
|
87
|
+
name,
|
|
88
|
+
type: pickAttr(attrs, "type"),
|
|
89
|
+
description: pickAttr(attrs, "description"),
|
|
90
|
+
length: pickAttr(attrs, "colLength") ?? pickAttr(attrs, "length"),
|
|
91
|
+
isKey: pickAttr(attrs, "keyAttribute") === "true",
|
|
92
|
+
isNumeric: pickAttr(attrs, "isNumeric") === "true",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const rows = [];
|
|
97
|
+
|
|
98
|
+
// Shape A: <row> + <value> positional elements aligned to column order.
|
|
99
|
+
const rowMatches = [...xml.matchAll(ROW_RE)];
|
|
100
|
+
if (rowMatches.length > 0) {
|
|
101
|
+
for (const rm of rowMatches) {
|
|
102
|
+
const inner = rm[1];
|
|
103
|
+
const values = [...inner.matchAll(VALUE_RE)].map((v) =>
|
|
104
|
+
decodeEntities(v[1].trim())
|
|
105
|
+
);
|
|
106
|
+
if (columns.length > 0) {
|
|
107
|
+
const row = {};
|
|
108
|
+
columns.forEach((col, i) => {
|
|
109
|
+
row[col.name] = values[i] ?? null;
|
|
110
|
+
});
|
|
111
|
+
rows.push(row);
|
|
112
|
+
} else {
|
|
113
|
+
rows.push(values);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
// Shape B: flat <data columnName="X">val</data> blocks. We group every N
|
|
118
|
+
// cells into a row where N = column count.
|
|
119
|
+
const cells = [...xml.matchAll(DATA_CELL_RE)].map((m) => ({
|
|
120
|
+
column: m[1],
|
|
121
|
+
value: decodeEntities(m[2].trim()),
|
|
122
|
+
}));
|
|
123
|
+
if (columns.length > 0 && cells.length > 0) {
|
|
124
|
+
const stride = columns.length;
|
|
125
|
+
for (let i = 0; i < cells.length; i += stride) {
|
|
126
|
+
const row = {};
|
|
127
|
+
for (let j = 0; j < stride && i + j < cells.length; j++) {
|
|
128
|
+
const cell = cells[i + j];
|
|
129
|
+
row[cell.column] = cell.value;
|
|
130
|
+
}
|
|
131
|
+
rows.push(row);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const totalRowsAttr = pickTagText(xml, "totalRows");
|
|
137
|
+
const totalRows = totalRowsAttr ? Number(totalRowsAttr) : undefined;
|
|
138
|
+
const executedQuery = pickTagText(xml, "executedQueryString");
|
|
139
|
+
const executionTime = pickTagText(xml, "queryExecutionTime");
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
columns,
|
|
143
|
+
rows,
|
|
144
|
+
totalRows: Number.isFinite(totalRows) ? totalRows : rows.length,
|
|
145
|
+
executedQuery,
|
|
146
|
+
executionTime,
|
|
147
|
+
};
|
|
148
|
+
}
|