sap-adt-mcp 0.8.49 → 0.8.51

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
@@ -198,9 +198,12 @@ so on startup. There are three channels:
198
198
  missing capability) via the **`adt_report_issue`** tool.
199
199
 
200
200
  What is sent: the sap-adt-mcp version, Node version, OS, the tool name, and the
201
- error/finding with a fingerprint for de-duplication. Before anything leaves your
202
- machine it is scrubbed of **hostnames, users, passwords, tokens, IPs, and
203
- emails**; tool arguments and free-text fields are redacted the same way. Reports
201
+ error/finding with a fingerprint for de-duplication, plus an **anonymous install
202
+ id** (random bytes, cached at `~/.sap-adt-mcp/install-id`) that lets repeat
203
+ reports from the same install be grouped for triage it identifies neither you
204
+ nor your system. Before anything leaves your machine it is scrubbed of
205
+ **hostnames, users, passwords, tokens, IPs, and emails**; tool arguments and
206
+ free-text fields are redacted the same way. Reports
204
207
  go to a relay the maintainer owns, which files/de-dups a GitHub issue — the
205
208
  relay holds the GitHub credentials, never this package.
206
209
 
@@ -310,8 +313,8 @@ or rejecting credentials. Run this first when troubleshooting.
310
313
 
311
314
  | Tool | Purpose | Notes |
312
315
  | --- | --- | --- |
313
- | `adt_get_source` | Fetch ABAP source by object name + type. | Returns plain text. For classes, pick the include via `include`: `main` (default), `definitions`, `implementations`, `macros`, `testclasses`. Function modules require `group`. |
314
- | `adt_set_source` | Replace source. Orchestrates lock → PUT → unlock. | Optional `transport` parameter assigns the change to a TR (`corrNr`). Optional `lockHandle` to reuse an externally-acquired lock. Refused under `readOnly: true`. |
316
+ | `adt_get_source` | Fetch ABAP source by object name + type. | Returns plain text. For classes, pick the include via `include`: `main` (default), `definitions`, `implementations`, `macros`, `testclasses`. Function modules require `group`. For large objects, pass `outputFile` to write the source straight to disk (response omits inline `source`). |
317
+ | `adt_set_source` | Replace source. Orchestrates lock → PUT → unlock. | Supply the new source inline via `source`, or via `sourceFile` (a local path the MCP reads itself) for large objects that exceed the per-call I/O cap. Optional `transport` assigns the change to a TR (`corrNr`); optional `lockHandle` reuses an externally-acquired lock. Refused under `readOnly: true`. |
315
318
  | `adt_create_object` | Create a new ABAP object in a package. | Supported types: program, class, interface, include, functiongroup, function, cds, accesscontrol, metadataext, behaviordef, messageclass. After creation, set the source body with `adt_set_source` and activate. Refused under `readOnly: true`. |
316
319
  | `adt_delete_object` | Delete an object. | Acquires lock and DELETEs. Refused under `readOnly: true`. |
317
320
  | `adt_activate` | Activate one or more objects. | Pass `objects: [{ name, type, group? }]`. |
@@ -608,6 +611,25 @@ sticky-lock pattern:
608
611
  The `lockHandle` parameter on `adt_set_source` skips internal lock/unlock
609
612
  when present.
610
613
 
614
+ ### Large objects (thousands of lines)
615
+
616
+ A multi-thousand-line class or program can exceed the per-call I/O cap, so its
617
+ source cannot be passed inline. Keep the content on disk instead — it never
618
+ enters the agent context:
619
+
620
+ ```text
621
+ 1. adt_get_source { object: "ZCL_BIG", type: "class",
622
+ outputFile: "/tmp/zcl_big.abap" } → writes the file, no inline source
623
+ 2. (edit /tmp/zcl_big.abap locally)
624
+ 3. adt_set_source { object: "ZCL_BIG", type: "class",
625
+ sourceFile: "/tmp/zcl_big.abap",
626
+ transport: "E4DK900123" } → MCP reads the file and PUTs it
627
+ ```
628
+
629
+ Note: assigning to a transport works headless, but TR *creation*
630
+ (`adt_create_transport`) routes through a GUI dialog on some systems and can
631
+ fail with a 500 — pass an existing TR id instead.
632
+
611
633
  ## Caveats
612
634
 
613
635
  - **NetWeaver release variation.** A few ADT endpoints (especially around
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sap-adt-mcp",
3
- "version": "0.8.49",
3
+ "version": "0.8.51",
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/config.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",
24
+ "test": "node --test test/adt-error.test.js test/atc-bulk.test.js test/audit.test.js test/cds.test.js test/config.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/query-blank.test.js test/rap-scaffold.test.js test/reporter.test.js test/security.test.js test/source-chunked.test.js test/source-file-io.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/adt-client.js CHANGED
@@ -108,7 +108,9 @@ export class AdtClient {
108
108
  path: resolvedPath,
109
109
  status: res.status,
110
110
  ok: res.ok,
111
- ...(query?.corrNr ? { transport: String(query.corrNr) } : {}),
111
+ ...(query?.corrNr && String(query.corrNr).trim()
112
+ ? { transport: String(query.corrNr).trim() }
113
+ : {}),
112
114
  });
113
115
  }
114
116
 
@@ -285,7 +287,15 @@ export class AdtClient {
285
287
  }
286
288
  if (query) {
287
289
  for (const [k, v] of Object.entries(query)) {
288
- url.searchParams.set(k, String(v));
290
+ // A null/undefined/blank value is never a meaningful ADT query param, and
291
+ // a whitespace-only one is actively harmful: a transport passed as " "
292
+ // reaches the CTS backend as corrNr=%20 and 500s the request (#68). Skip
293
+ // empties uniformly so every corrNr-bearing tool (set_source, chunked,
294
+ // activate/delete, rap) is covered at one point.
295
+ if (v == null) continue;
296
+ const s = String(v);
297
+ if (s.trim() === "") continue;
298
+ url.searchParams.set(k, s);
289
299
  }
290
300
  }
291
301
  return url.toString();
@@ -201,20 +201,32 @@ export function buildCreateRequest({
201
201
  }
202
202
  }
203
203
 
204
+ // The ADT object-create framework accepts a wildcard content type on the POST;
205
+ // abap-adt-api (the reference client) creates *every* object type with exactly
206
+ // this, never a resource-specific media type. It is our universal last resort.
207
+ export const GENERIC_CREATE_MEDIA_TYPE = "application/*";
208
+
204
209
  // Most ADT create endpoints version their media type (…v2+xml, …v3+xml). The
205
210
  // shapes above target the newest version a modern stack accepts, but older
206
211
  // systems only know an earlier one and answer the newer media type with
207
212
  // 415 ExceptionUnsupportedMediaType. Given a content-type, return the chain to
208
- // try: the requested version first, then every lower version down to v1.
209
- // A content-type with no `.vN+xml` segment is returned unchanged (single item).
213
+ // try: the requested version first, then every lower version down to v1, then
214
+ // the generic wildcard as a final catch-all some systems register neither
215
+ // versioned type for a given object (e.g. DDLS/CDS create 415'd on both v2 and
216
+ // v1, #64), but the create framework always honours application/*.
210
217
  export function mediaTypeFallbacks(contentType) {
211
218
  const m = /^(.*\.)v(\d+)(\+xml)$/.exec(contentType);
212
- if (!m) return [contentType];
219
+ if (!m) {
220
+ return contentType === GENERIC_CREATE_MEDIA_TYPE
221
+ ? [contentType]
222
+ : [contentType, GENERIC_CREATE_MEDIA_TYPE];
223
+ }
213
224
  const [, prefix, ver, suffix] = m;
214
225
  const chain = [];
215
226
  for (let v = Number(ver); v >= 1; v--) {
216
227
  chain.push(`${prefix}v${v}${suffix}`);
217
228
  }
229
+ chain.push(GENERIC_CREATE_MEDIA_TYPE);
218
230
  return chain;
219
231
  }
220
232
 
@@ -18,6 +18,29 @@ export function parseObjectReferences(xml) {
18
18
  return out;
19
19
  }
20
20
 
21
+ // The where-used / usageReferences response nests the referencing object inside
22
+ // <usageReferences:adtObject adtcore:name=… adtcore:type=… …> rather than the
23
+ // flat <adtcore:objectReference/> element the search endpoints use, so
24
+ // parseObjectReferences finds nothing in it. Extract each adtObject's adtcore
25
+ // attributes (prefix-agnostic — the server chooses the namespace prefix).
26
+ const ADTOBJ_RE = /<(?:[\w]+:)?adtObject\b([^>]*)>/gi;
27
+
28
+ export function parseUsageReferences(xml) {
29
+ if (typeof xml !== "string") return [];
30
+ const out = [];
31
+ for (const m of xml.matchAll(ADTOBJ_RE)) {
32
+ const attrs = {};
33
+ for (const a of m[1].matchAll(ATTR_RE)) {
34
+ const key = a[1].replace(/^adtcore:/, "");
35
+ attrs[key] = decodeEntities(a[2]);
36
+ }
37
+ if (attrs.name || attrs.uri) out.push(attrs);
38
+ }
39
+ // Older systems emit the flat objectReference shape here too — fall back so a
40
+ // response in either form is parsed.
41
+ return out.length ? out : parseObjectReferences(xml);
42
+ }
43
+
21
44
  function decodeEntities(s) {
22
45
  return s
23
46
  .replace(/&lt;/g, "<")
@@ -86,6 +86,15 @@ function collapseSubtype(t) {
86
86
  return t;
87
87
  }
88
88
 
89
+ // The canonical base type: normalize aliases, then collapse a decorative
90
+ // TADIR subtype (DOMA/DD → DOMA) the same way objectUri/sourceUri do internally.
91
+ // Callers that dispatch on the type (e.g. the metadata-XML Accept lookup) must
92
+ // use this, not the raw normalizeType output — a subtype like "DOMA/DD" otherwise
93
+ // misses the table and falls through to a text/plain fetch that 406s (#66).
94
+ export function baseType(input) {
95
+ return collapseSubtype(normalizeType(input));
96
+ }
97
+
89
98
  export function objectUri({ type, name, group }) {
90
99
  const t = collapseSubtype(normalizeType(type));
91
100
  if (typeof name !== "string" || name.length === 0) {
package/src/reporter.js CHANGED
@@ -14,7 +14,9 @@
14
14
  // Opt out with `"reporting": { "enabled": false }` in config, or SAP_ADT_MCP_REPORT=0.
15
15
 
16
16
  import crypto from "node:crypto";
17
+ import fs from "node:fs";
17
18
  import os from "node:os";
19
+ import path from "node:path";
18
20
  import { BUILD_FINGERPRINT } from "./tools/_shared.js";
19
21
 
20
22
  // Filled in by the maintainer after deploying the relay Worker. Overridable per
@@ -147,6 +149,33 @@ function hash16(s) {
147
149
  return crypto.createHash("sha256").update(s).digest("hex").slice(0, 16);
148
150
  }
149
151
 
152
+ // --- Install identity --------------------------------------------------------
153
+
154
+ // A stable, ANONYMOUS per-install identifier. It is random bytes — never derived
155
+ // from any machine, user, or config attribute — so it groups repeat reports from
156
+ // the same install for triage without identifying the person or their system.
157
+ // Generated once and cached at ~/.sap-adt-mcp/install-id, reused thereafter.
158
+ // Only touched when reporting is enabled. Best-effort: any fs failure falls back
159
+ // to an ephemeral id so a report is never blocked.
160
+ function resolveInstallId() {
161
+ const dir = path.join(os.homedir(), ".sap-adt-mcp");
162
+ const file = path.join(dir, "install-id");
163
+ try {
164
+ const existing = fs.readFileSync(file, "utf8").trim();
165
+ if (/^[0-9a-f]{16}$/i.test(existing)) return existing;
166
+ } catch {
167
+ // Not created yet (or unreadable) — fall through and mint one.
168
+ }
169
+ const id = crypto.randomBytes(8).toString("hex");
170
+ try {
171
+ fs.mkdirSync(dir, { recursive: true });
172
+ fs.writeFileSync(file, id + "\n", { mode: 0o600 });
173
+ } catch {
174
+ // Couldn't persist — return an ephemeral id for this run rather than fail.
175
+ }
176
+ return id;
177
+ }
178
+
150
179
  function normalizeText(s) {
151
180
  return String(s ?? "")
152
181
  .replace(/0x[0-9a-f]+/gi, "")
@@ -242,6 +271,9 @@ export function createReporter(config, pkg) {
242
271
  const allowManual = rep.allowManual !== false;
243
272
  const redact = makeRedactor(collectSecrets(config.systems));
244
273
  const seen = new Set(); // per-process de-dup: one POST per fingerprint per run.
274
+ // Only mint/read the install-id when reporting is on, so an opted-out install
275
+ // writes nothing and sends nothing.
276
+ const installId = enabled ? resolveInstallId() : null;
245
277
 
246
278
  // source = the x-report-source header (crash/adt-error use "sap-adt-mcp";
247
279
  // agent-initiated reports use "sap-adt-mcp-manual"). The relay routes on it.
@@ -265,6 +297,7 @@ export function createReporter(config, pkg) {
265
297
  return {
266
298
  kind,
267
299
  fingerprint,
300
+ install: installId,
268
301
  build: BUILD_FINGERPRINT,
269
302
  version: pkg.version,
270
303
  node: process.version,
@@ -1,6 +1,6 @@
1
1
  import { objectUri, sourceUri } from "../object-uris.js";
2
2
  import { fetchPackageNodes } from "../node-structure.js";
3
- import { parseObjectReferences } from "../object-references.js";
3
+ import { parseObjectReferences, parseUsageReferences } from "../object-references.js";
4
4
  import { errorResult, jsonResult, textResult } from "../result.js";
5
5
  import { OBJECT_TYPE_HINT, SYSTEM_HINT } from "./_shared.js";
6
6
 
@@ -279,23 +279,34 @@ export function register({ getClient }) {
279
279
 
280
280
  adt_where_used: async (args) => {
281
281
  const { client, name: sys } = getClient(args.system);
282
- const uri = objectUri({ type: args.type, name: args.object, group: args.group });
282
+ let uri;
283
+ try {
284
+ uri = objectUri({ type: args.type, name: args.object, group: args.group });
285
+ } catch (err) {
286
+ // e.g. a function module (FUGR/FF) passed without its group — objectUri
287
+ // throws. Return a clean tool error instead of crashing (#74).
288
+ return textResult(`adt_where_used: ${err.message}`, true);
289
+ }
290
+ // The endpoint requires a real request body with a <usageReferenceRequest>
291
+ // root; the previous empty POST 400'd with "System expected the element
292
+ // usageReferenceRequest" on every object (#73). An empty <affectedObjects/>
293
+ // means "all usages of <uri>". Mirrors the reference client (abap-adt-api).
294
+ const body =
295
+ `<?xml version="1.0" encoding="UTF-8"?>` +
296
+ `<usagereferences:usageReferenceRequest xmlns:usagereferences="http://www.sap.com/adt/ris/usageReferences">` +
297
+ `<usagereferences:affectedObjects/>` +
298
+ `</usagereferences:usageReferenceRequest>`;
283
299
  const res = await client.request({
284
300
  method: "POST",
285
301
  path: "/sap/bc/adt/repository/informationsystem/usageReferences",
286
302
  query: { uri },
287
- // Without this the server rejects the POST with 400 "Content type
288
- // missing" — it hits DDIC objects (tables, structures) where the tool
289
- // sent no request entity. The typed (empty) body is what ADT expects to
290
- // mean "all usages of <uri>".
291
- headers: {
292
- "Content-Type":
293
- "application/vnd.sap.adt.repository.usageReferences.request.v1+xml",
294
- },
303
+ headers: { "Content-Type": "application/*" },
304
+ accept: "application/*",
305
+ body,
295
306
  });
296
307
  const text = await res.text();
297
308
  if (!res.ok) return errorResult(sys, res.status, text, res.headers.get("content-type"));
298
- const refs = parseObjectReferences(text);
309
+ const refs = parseUsageReferences(text);
299
310
  return jsonResult({
300
311
  system: sys,
301
312
  object: args.object,
@@ -156,10 +156,17 @@ async function runAtcWorklist(client, sys, { uris, checkVariant, maxResults }) {
156
156
  }
157
157
 
158
158
  // Normalize a caller-supplied include context into an ADT object URI. Accepts a
159
- // full ADT path as-is; treats a bare token as a program name.
159
+ // full ADT path as-is; treats anything else as a program name.
160
+ //
161
+ // A real ADT object URI begins with the ADT path prefix. A *namespaced* ABAP
162
+ // name (e.g. "/FGLR/R_PO_ASSET_CREATE") also begins with "/", so keying on a
163
+ // leading slash alone misclassified it as a ready-made URI and produced an
164
+ // unmappable ?context= → 500 uriMappingError (#67). Only the ADT-path prefix
165
+ // marks a ready URI; every other value — bare or namespaced — is a program name
166
+ // whose slashes must be percent-encoded into the programs URI.
160
167
  export function toContextUri(input) {
161
168
  const s = String(input).trim();
162
- if (s.startsWith("/")) return s;
169
+ if (s.startsWith("/sap/bc/adt/")) return s;
163
170
  return `/sap/bc/adt/programs/programs/${encodeURIComponent(s.toLowerCase())}`;
164
171
  }
165
172
 
@@ -1,8 +1,36 @@
1
- import { sourceUri, objectUri, normalizeType, METADATA_XML_ACCEPT } from "../object-uris.js";
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { sourceUri, objectUri, normalizeType, baseType, METADATA_XML_ACCEPT } from "../object-uris.js";
2
4
  import { acquireLock, releaseLock } from "../lock.js";
3
5
  import { errorResult, jsonResult, textResult } from "../result.js";
4
6
  import { OBJECT_TYPE_HINT, SYSTEM_HINT } from "./_shared.js";
5
7
 
8
+ // Read a local file for a write. The MCP process reads it directly, so a large
9
+ // object's source never has to travel back through the agent's per-call I/O cap
10
+ // (#39). fs errors become a clean { ok:false } instead of a crash.
11
+ function readLocalFile(p) {
12
+ try {
13
+ return { ok: true, content: fs.readFileSync(p, "utf8") };
14
+ } catch (err) {
15
+ const why = err.code === "ENOENT" ? "file not found" : err.message;
16
+ return { ok: false, error: why };
17
+ }
18
+ }
19
+
20
+ // Write fetched source straight to disk (symmetric to readLocalFile) so a big
21
+ // read → edit → write round-trip can stay entirely on the filesystem. Creates
22
+ // the parent directory if needed.
23
+ function writeLocalFile(p, content) {
24
+ try {
25
+ const dir = path.dirname(p);
26
+ if (dir) fs.mkdirSync(dir, { recursive: true });
27
+ fs.writeFileSync(p, content);
28
+ return { ok: true };
29
+ } catch (err) {
30
+ return { ok: false, error: err.message };
31
+ }
32
+ }
33
+
6
34
  const TOP_LEVEL_KEYWORDS = [
7
35
  "CLASS",
8
36
  "INTERFACE",
@@ -120,7 +148,7 @@ export const tools = [
120
148
  {
121
149
  name: "adt_get_source",
122
150
  description:
123
- "Fetch the ABAP source of an object (program, class, interface, function module, include, CDS, table). Returns plain text. DDIC primitives without plain-text source (data element, domain, message class) return their ADT XML metadata instead (format: 'xml'). For sources larger than the MCP per-call output cap (~64 KB), use firstLine/lastLine to paginate or onlyMethod to slice a single method body.",
151
+ "Fetch the ABAP source of an object (program, class, interface, function module, include, CDS, table). Returns plain text. DDIC primitives without plain-text source (data element, domain, message class) return their ADT XML metadata instead (format: 'xml'). For sources larger than the MCP per-call output cap (~64 KB), use firstLine/lastLine to paginate or onlyMethod to slice a single method body — or pass `outputFile` to write the full source straight to disk (then edit it and write it back with adt_set_source `sourceFile`, keeping large objects entirely off the agent's I/O path).",
124
152
  inputSchema: {
125
153
  type: "object",
126
154
  properties: {
@@ -152,6 +180,11 @@ export const tools = [
152
180
  description:
153
181
  "Return only the METHOD <name> ... ENDMETHOD block (case-insensitive). Returns metadata about the slice (startLine, endLine). Convenient for inspecting one method of a large class without paginating manually.",
154
182
  },
183
+ outputFile: {
184
+ type: "string",
185
+ description:
186
+ "Local file path to write the source to instead of returning it inline. The response carries metadata (bytesWritten, totalLines) but omits `source`, so an arbitrarily large object can be fetched without hitting the output cap. Respects firstLine/lastLine/onlyMethod when set (otherwise writes the full source).",
187
+ },
155
188
  },
156
189
  required: ["object", "type"],
157
190
  },
@@ -159,7 +192,7 @@ export const tools = [
159
192
  {
160
193
  name: "adt_set_source",
161
194
  description:
162
- "Replace the ABAP source of an object. The supplied `source` is the FULL text of the include — it ATOMICALLY REPLACES the entire include on the server. Passing a partial chunk or diff will delete the rest of the include. Orchestrates lock → PUT → unlock automatically. Requires read-only mode to be off for the target system.",
195
+ "Replace the ABAP source of an object. The new source is the FULL text of the include — it ATOMICALLY REPLACES the entire include on the server. Passing a partial chunk or diff will delete the rest of the include. Supply the source inline via `source`, OR — for a large object that would blow the per-call I/O cap — via `sourceFile` (a local path the MCP process reads itself). Orchestrates lock → PUT → unlock automatically. Requires read-only mode to be off for the target system.",
163
196
  inputSchema: {
164
197
  type: "object",
165
198
  properties: {
@@ -175,7 +208,12 @@ export const tools = [
175
208
  source: {
176
209
  type: "string",
177
210
  description:
178
- "New ABAP source code — FULL text of the include. Atomic replace. Do not pass a partial diff.",
211
+ "New ABAP source code — FULL text of the include. Atomic replace. Do not pass a partial diff. Mutually exclusive with `sourceFile`.",
212
+ },
213
+ sourceFile: {
214
+ type: "string",
215
+ description:
216
+ "Local file path whose contents become the new FULL source. The MCP process reads the file, so there is no size ceiling — use this for large objects (e.g. a multi-thousand-line class) that cannot be passed inline. Mutually exclusive with `source`.",
179
217
  },
180
218
  transport: {
181
219
  type: "string",
@@ -193,7 +231,7 @@ export const tools = [
193
231
  "Set to true to bypass the partial-source guard (e.g. when writing a content-free or fragment include intentionally). The guard rejects sources whose first non-comment line does not start with a recognized top-level ABAP construct keyword.",
194
232
  },
195
233
  },
196
- required: ["object", "type", "source"],
234
+ required: ["object", "type"],
197
235
  },
198
236
  },
199
237
  {
@@ -273,23 +311,35 @@ export function register({ getClient }) {
273
311
  return {
274
312
  adt_get_source: async (args) => {
275
313
  if (typeof args.object !== "string" || args.object.length === 0) {
276
- const hint =
277
- args.name !== undefined
278
- ? " (you passed `name` — the field is `object`)"
279
- : "";
314
+ const wrong =
315
+ args.object_name !== undefined
316
+ ? "object_name"
317
+ : args.name !== undefined
318
+ ? "name"
319
+ : null;
320
+ const hint = wrong ? ` (you passed \`${wrong}\` — the field is \`object\`)` : "";
280
321
  return textResult(
281
322
  `adt_get_source: \`object\` is required${hint}. Also use \`firstLine\`/\`lastLine\` (not \`line\`/\`endLine\`) to paginate.`,
282
323
  true
283
324
  );
284
325
  }
285
326
  if (typeof args.type !== "string" || args.type.length === 0) {
286
- return textResult("adt_get_source: `type` is required (e.g. 'class', 'program', 'dataelement').", true);
327
+ const hint =
328
+ args.object_type !== undefined
329
+ ? " (you passed `object_type` — the field is `type`)"
330
+ : "";
331
+ return textResult(
332
+ `adt_get_source: \`type\` is required${hint} (e.g. 'class', 'program', 'dataelement').`,
333
+ true
334
+ );
287
335
  }
288
336
  const { client, name: sys } = getClient(args.system);
289
337
  let t;
290
338
  let path;
291
339
  try {
292
- t = normalizeType(args.type);
340
+ // Collapse decorative subtypes (DOMA/DD → DOMA) so the metadata-XML
341
+ // Accept lookup below matches; sourceUri collapses internally already.
342
+ t = baseType(args.type);
293
343
  path = sourceUri({
294
344
  type: args.type,
295
345
  name: args.object,
@@ -315,17 +365,24 @@ export function register({ getClient }) {
315
365
  const res = await client.request({ path, accept: metaAccept });
316
366
  const text = await res.text();
317
367
  if (!res.ok) return errorResult(sys, res.status, text, res.headers.get("content-type"));
318
- return jsonResult({
368
+ const xmlMeta = {
319
369
  system: sys,
320
370
  object: args.object,
321
371
  type: t,
322
372
  path,
323
373
  format: "xml",
324
- source: text,
325
374
  bytes: text.length,
326
375
  totalLines: text.split(/\r?\n/).length,
327
376
  note: `${t} has no plain-text source; returning ADT XML metadata (${metaAccept.split("+")[0]}+xml).`,
328
- });
377
+ };
378
+ if (args.outputFile) {
379
+ const w = writeLocalFile(args.outputFile, text);
380
+ if (!w.ok) {
381
+ return errorResult(sys, 500, `Could not write outputFile ${JSON.stringify(args.outputFile)}: ${w.error}`, "text/plain", { stage: "output" });
382
+ }
383
+ return jsonResult({ ...xmlMeta, outputFile: args.outputFile, bytesWritten: text.length });
384
+ }
385
+ return jsonResult({ ...xmlMeta, source: text });
329
386
  }
330
387
 
331
388
  const res = await client.request({ path, accept: "text/plain" });
@@ -374,12 +431,11 @@ export function register({ getClient }) {
374
431
  }
375
432
 
376
433
  const slice = allLines.slice(sliceFirst - 1, sliceLast).join("\n");
377
- return jsonResult({
434
+ const meta = {
378
435
  system: sys,
379
436
  object: args.object,
380
437
  type: normalizeType(args.type),
381
438
  path,
382
- source: slice,
383
439
  bytes: slice.length,
384
440
  lines: sliceLast - sliceFirst + 1,
385
441
  totalLines,
@@ -388,13 +444,70 @@ export function register({ getClient }) {
388
444
  lastLine: sliceLast,
389
445
  scope,
390
446
  truncated: scope !== "full",
391
- });
447
+ };
448
+ if (args.outputFile) {
449
+ const w = writeLocalFile(args.outputFile, slice);
450
+ if (!w.ok) {
451
+ return errorResult(sys, 500, `Could not write outputFile ${JSON.stringify(args.outputFile)}: ${w.error}`, "text/plain", { stage: "output" });
452
+ }
453
+ return jsonResult({ ...meta, outputFile: args.outputFile, bytesWritten: slice.length });
454
+ }
455
+ return jsonResult({ ...meta, source: slice });
392
456
  },
393
457
 
394
458
  adt_set_source: async (args) => {
395
459
  const { client, name: sys } = getClient(args.system);
396
- if (!args.acknowledgePartial) {
397
- const partialReason = detectPartialSource(args.source);
460
+
461
+ // Resolve the new source from either inline `source` or a local
462
+ // `sourceFile` (read here by the MCP, so large objects bypass the agent's
463
+ // I/O cap — #39). Exactly one of the two must be supplied.
464
+ const hasInline = typeof args.source === "string";
465
+ const hasFile = typeof args.sourceFile === "string" && args.sourceFile.length > 0;
466
+ if (hasInline && hasFile) {
467
+ return textResult("adt_set_source: pass either `source` or `sourceFile`, not both.", true);
468
+ }
469
+ if (!hasInline && !hasFile) {
470
+ return textResult(
471
+ "adt_set_source: `source` (inline full text) or `sourceFile` (local path) is required.",
472
+ true
473
+ );
474
+ }
475
+ let source = args.source;
476
+ if (hasFile) {
477
+ const rd = readLocalFile(args.sourceFile);
478
+ if (!rd.ok) {
479
+ return textResult(
480
+ `adt_set_source: could not read sourceFile ${JSON.stringify(args.sourceFile)}: ${rd.error}.`,
481
+ true
482
+ );
483
+ }
484
+ source = rd.content;
485
+ }
486
+
487
+ let metaType = null;
488
+ let objUri;
489
+ let srcPath;
490
+ try {
491
+ // DDIC primitives (domain / data element / message class) are edited as
492
+ // XML metadata, so their PUT uses the resource media type, not text/plain.
493
+ metaType = METADATA_XML_ACCEPT[baseType(args.type)] ?? null;
494
+ objUri = objectUri({ type: args.type, name: args.object, group: args.group });
495
+ srcPath = sourceUri({
496
+ type: args.type,
497
+ name: args.object,
498
+ group: args.group,
499
+ include: args.include,
500
+ });
501
+ } catch (err) {
502
+ // Bad/unknown type, or a function module without its group — return a
503
+ // clean error instead of crashing (cf. #65/#74).
504
+ return textResult(`adt_set_source: ${err.message}`, true);
505
+ }
506
+
507
+ // The partial-source guard only makes sense for plain ABAP text; XML
508
+ // metadata (metaType set) never starts with an ABAP keyword, so skip it.
509
+ if (!metaType && !args.acknowledgePartial) {
510
+ const partialReason = detectPartialSource(source);
398
511
  if (partialReason) {
399
512
  return errorResult(sys, 422, partialReason, "text/plain", {
400
513
  stage: "validate",
@@ -402,17 +515,6 @@ export function register({ getClient }) {
402
515
  });
403
516
  }
404
517
  }
405
- const objUri = objectUri({
406
- type: args.type,
407
- name: args.object,
408
- group: args.group,
409
- });
410
- const srcPath = sourceUri({
411
- type: args.type,
412
- name: args.object,
413
- group: args.group,
414
- include: args.include,
415
- });
416
518
 
417
519
  const externalLock = typeof args.lockHandle === "string" && args.lockHandle.length > 0;
418
520
  let handle = args.lockHandle;
@@ -435,10 +537,10 @@ export function register({ getClient }) {
435
537
  path: srcPath,
436
538
  query: putQuery,
437
539
  headers: {
438
- "Content-Type": "text/plain; charset=utf-8",
540
+ "Content-Type": metaType ? metaType : "text/plain; charset=utf-8",
439
541
  "X-sap-adt-sessiontype": "stateful",
440
542
  },
441
- body: args.source,
543
+ body: source,
442
544
  });
443
545
  const putText = await putRes.text();
444
546
  if (!putRes.ok) {
@@ -42,7 +42,7 @@ export const tools = [
42
42
  {
43
43
  name: "adt_create_transport",
44
44
  description:
45
- "Create a new transport request. Returns the new TR number. Subject to read-only mode.",
45
+ "Create a new transport request. Returns the new TR number. Subject to read-only mode. Note: on some systems TR *creation* routes through a SAP GUI dialog and fails headless with a 500 (SAPLSTRD/SAPLSPO4 'No window system type'). Assigning changes to an EXISTING request works headless — pass that TR id as `transport` to adt_lock / adt_set_source instead.",
46
46
  inputSchema: {
47
47
  type: "object",
48
48
  properties: {
@@ -118,10 +118,18 @@ export function register({ getClient }) {
118
118
  adt_create_transport: async (args) => {
119
119
  const { client, name: sys, profile } = getClient(args.system);
120
120
  const trType = args.type ?? "K";
121
+ // Only emit tm:target when a real target is given. Sending tm:target=""
122
+ // (the old default) makes some systems reject the create with an opaque
123
+ // 500 — the same blank-value hazard as a whitespace corrNr (#68/#63).
124
+ // Omitting it lets CTS apply the connection's default consolidation route.
125
+ const targetAttr =
126
+ typeof args.target === "string" && args.target.trim()
127
+ ? ` tm:target="${escapeXml(args.target.trim())}"`
128
+ : "";
121
129
  const xml =
122
130
  `<?xml version="1.0" encoding="UTF-8"?>` +
123
131
  `<tm:root xmlns:tm="http://www.sap.com/cts/adt/tm" tm:useraction="newrequest">` +
124
- `<tm:request tm:desc="${escapeXml(args.description)}" tm:type="${trType}" tm:target="${escapeXml(args.target ?? "")}" tm:cliDep="X">` +
132
+ `<tm:request tm:desc="${escapeXml(args.description)}" tm:type="${trType}"${targetAttr} tm:cliDep="X">` +
125
133
  `<tm:user tm:name="${escapeXml(profile.user.toUpperCase())}"/>` +
126
134
  `</tm:request>` +
127
135
  `</tm:root>`;