abap-mcp 0.3.0 → 0.3.2

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
@@ -110,7 +110,7 @@ loop condition), per-repo `.mcp.json`, and a GitHub Actions quality gate for aba
110
110
 
111
111
  ```bash
112
112
  npm install
113
- npm run check # typecheck + 115 tests + build — the CI gate
113
+ npm run check # typecheck + 122 tests + build — the CI gate
114
114
  node dist/cli.js # stdio MCP server
115
115
  npx @modelcontextprotocol/inspector --cli node dist/cli.js --method tools/list
116
116
  ```
@@ -65,16 +65,24 @@ export function checkCloudReadiness(files, baselineVersion = "v758") {
65
65
  * Cross-check statically-extracted object references against the bundled SAP
66
66
  * Cloudification snapshot. Flags two cases:
67
67
  * - `deprecated` — the referenced object is a deprecated released API.
68
- * - `not-released` — direct access to a classic/internal table that is not a
69
- * released API (the typical "SELECT FROM mara" case), with a curated CDS
70
- * successor hint when one is known.
68
+ * - `not-released` — the reference is explicitly recorded as notToBeReleased
69
+ * in SAP's list: direct access to a classic/internal table (the typical
70
+ * "SELECT … FROM mara" case, with a curated CDS successor hint when one is
71
+ * known) or a CALL FUNCTION to an internal-only function module.
71
72
  * Released objects and references the snapshot does not recognise are silent —
72
- * absence from the list is "not known to be a problem", not proof either way.
73
+ * absence from the list (every customer Z/Y-object, for a start) is "not known
74
+ * to be a problem", not proof either way.
73
75
  */
74
76
  function computeReleasedApiFindings(files, baselineVersion) {
75
77
  const out = [];
76
78
  for (const ref of extractObjectReferences(files, baselineVersion)) {
77
- const hit = lookupReleased(ref.name, ref.objectType);
79
+ let hit = lookupReleased(ref.name, ref.objectType);
80
+ // An ABAP-SQL FROM clause names either a DDIC table or a CDS entity, but
81
+ // the extractor labels both TABL — fall back to the CDS record so a
82
+ // deprecated CDS view in a SELECT is still caught.
83
+ if (!hit.recorded && ref.objectType === "TABL") {
84
+ hit = lookupReleased(ref.name, "CDS_STOB");
85
+ }
78
86
  if (hit.state === "released")
79
87
  continue;
80
88
  if (hit.state === "deprecated") {
@@ -90,10 +98,24 @@ function computeReleasedApiFindings(files, baselineVersion) {
90
98
  });
91
99
  continue;
92
100
  }
93
- // not-released. Only flag DB tables (direct table access is the cloud
94
- // anti-pattern); a CALL FUNCTION to a module simply absent from the list is
95
- // too noisy to report as a finding without a system to confirm against.
96
- if (ref.objectType === "TABL") {
101
+ // not-released. Only flag names SAP's snapshot explicitly records as
102
+ // notToBeReleased a name merely absent from the list (every customer
103
+ // Z/Y-object, for a start) is silent: absence is "not known to be a
104
+ // problem", not evidence of one.
105
+ if (!hit.recorded)
106
+ continue;
107
+ if (ref.objectType === "FUNC") {
108
+ out.push({
109
+ object: ref.name,
110
+ objectType: ref.objectType,
111
+ state: "not-released",
112
+ file: ref.file,
113
+ line: ref.line,
114
+ note: `Function module ${ref.name} is recorded as not-to-be-released in SAP's Cloudification list — it will not become a public API in ABAP Cloud; use a released successor API instead.`,
115
+ });
116
+ continue;
117
+ }
118
+ if (ref.objectType === "TABL" && hit.objectType === "TABL") {
97
119
  const successor = suggestSuccessor(ref.name);
98
120
  out.push({
99
121
  object: ref.name,
@@ -12,6 +12,13 @@ export interface ReleasedLookup {
12
12
  objectType: string | undefined;
13
13
  state: ReleasedState;
14
14
  applicationComponent?: string | undefined;
15
+ /**
16
+ * true when the name was found in SAP's snapshot (under the requested type,
17
+ * if one was given). false means absent — "not released as of the snapshot"
18
+ * by omission, which is weaker evidence than an explicit notToBeReleased
19
+ * record and must not be reported as a violation on its own.
20
+ */
21
+ recorded: boolean;
15
22
  }
16
23
  /**
17
24
  * Look up an object in the bundled released-API list. Case-insensitive.
@@ -21,10 +28,13 @@ export interface ReleasedLookup {
21
28
  * (typical for classic DDIC tables). A `released`/`deprecated` result is taken
22
29
  * verbatim from SAP's published data.
23
30
  *
24
- * When `objectType` is given it disambiguates the few hundred names that exist
25
- * under more than one object type; otherwise the first recorded entry is used,
26
- * preferring a `released` or `deprecated` record over a `notToBeReleased` one
27
- * so a genuinely released API is never masked by a same-named internal object.
31
+ * When `objectType` is given the lookup is strict: only a record of exactly
32
+ * that type answers the query a same-named record under a different type is
33
+ * a miss (`recorded: false`), never a substitute (a released class must not
34
+ * make a non-released table look released). Untyped lookups use the first
35
+ * recorded entry, preferring a `released` or `deprecated` record over a
36
+ * `notToBeReleased` one so a genuinely released API is never masked by a
37
+ * same-named internal object.
28
38
  */
29
39
  export declare function lookupReleased(objectName: string, objectType?: string): ReleasedLookup;
30
40
  /**
@@ -44,35 +44,43 @@ function toState(rawState) {
44
44
  * (typical for classic DDIC tables). A `released`/`deprecated` result is taken
45
45
  * verbatim from SAP's published data.
46
46
  *
47
- * When `objectType` is given it disambiguates the few hundred names that exist
48
- * under more than one object type; otherwise the first recorded entry is used,
49
- * preferring a `released` or `deprecated` record over a `notToBeReleased` one
50
- * so a genuinely released API is never masked by a same-named internal object.
47
+ * When `objectType` is given the lookup is strict: only a record of exactly
48
+ * that type answers the query a same-named record under a different type is
49
+ * a miss (`recorded: false`), never a substitute (a released class must not
50
+ * make a non-released table look released). Untyped lookups use the first
51
+ * recorded entry, preferring a `released` or `deprecated` record over a
52
+ * `notToBeReleased` one so a genuinely released API is never masked by a
53
+ * same-named internal object.
51
54
  */
52
55
  export function lookupReleased(objectName, objectType) {
53
56
  const key = objectName.trim().toUpperCase();
54
57
  const entries = data.objects[key];
58
+ const wantedType = objectType?.trim().toUpperCase();
55
59
  if (entries === undefined || entries.length === 0) {
56
- return { name: objectName, objectType, state: "not-released" };
60
+ return { name: objectName, objectType: wantedType, state: "not-released", recorded: false };
57
61
  }
58
- const wantedType = objectType?.trim().toUpperCase();
59
62
  let chosen;
60
63
  if (wantedType !== undefined) {
61
64
  chosen = entries.find((e) => e[0].toUpperCase() === wantedType);
65
+ if (chosen === undefined) {
66
+ // Typed query, no record of that type: a miss, not a cross-type answer.
67
+ return { name: objectName, objectType: wantedType, state: "not-released", recorded: false };
68
+ }
62
69
  }
63
- if (chosen === undefined) {
70
+ else {
64
71
  // Prefer a released/deprecated record over notToBeReleased when ambiguous.
65
72
  chosen =
66
73
  entries.find((e) => e[1] === "released" || e[1] === "deprecated") ?? entries[0];
67
74
  }
68
75
  if (chosen === undefined) {
69
- return { name: objectName, objectType, state: "not-released" };
76
+ return { name: objectName, objectType: wantedType, state: "not-released", recorded: false };
70
77
  }
71
78
  return {
72
79
  name: objectName,
73
80
  objectType: chosen[0],
74
81
  state: toState(chosen[1]),
75
82
  applicationComponent: chosen[2],
83
+ recorded: true,
76
84
  };
77
85
  }
78
86
  /**
@@ -473,6 +473,9 @@ export const checkReleasedApiTool = defineTool({
473
473
  state: z
474
474
  .enum(["released", "deprecated", "not-released"])
475
475
  .describe("'released' = safe public API; 'deprecated' = retiring; 'not-released' = not a public API."),
476
+ recorded: z
477
+ .boolean()
478
+ .describe("true = explicitly present in SAP's snapshot (under the requested type, if one was given); false = absent — 'not released as of the snapshot' by omission only."),
476
479
  applicationComponent: z
477
480
  .string()
478
481
  .optional()
@@ -504,6 +507,7 @@ export const checkReleasedApiTool = defineTool({
504
507
  name: hit.name,
505
508
  objectType: hit.objectType,
506
509
  state: hit.state,
510
+ recorded: hit.recorded,
507
511
  applicationComponent: hit.applicationComponent,
508
512
  ...(successor !== undefined ? { successor } : {}),
509
513
  };
@@ -511,7 +515,8 @@ export const checkReleasedApiTool = defineTool({
511
515
  const text = results
512
516
  .map((r) => {
513
517
  const tail = r.successor !== undefined ? ` → use ${r.successor}` : "";
514
- return `${r.name}: ${r.state}${r.objectType !== undefined ? ` (${r.objectType})` : ""}${tail}`;
518
+ const provenance = r.recorded ? "" : " (not in snapshot)";
519
+ return `${r.name}: ${r.state}${r.objectType !== undefined ? ` (${r.objectType})` : ""}${provenance}${tail}`;
515
520
  })
516
521
  .join("\n");
517
522
  return {
@@ -5,7 +5,7 @@
5
5
  * story); the *CLI* is a local developer tool, so reading files from disk here
6
6
  * is fine. Both call the identical engine — one definition of "clean".
7
7
  */
8
- import { readdirSync, readFileSync, statSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
8
+ import { readdirSync, readFileSync, realpathSync, statSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
9
9
  import { basename, extname, join } from "node:path";
10
10
  import { ABAP_VERSIONS, MAX_FILES, runAbaplint } from "./abap/engine.js";
11
11
  import { outlineAbap } from "./abap/outline.js";
@@ -17,11 +17,18 @@ const ABAP_FILE_RE = /\.(clas\.abap|clas\.locals_imp\.abap|clas\.locals_def\.aba
17
17
  /** Recursively collect analyzable sources from file/dir paths. */
18
18
  export function collectFiles(paths, io) {
19
19
  const found = [];
20
+ const visitedDirs = new Set();
20
21
  const visit = (p) => {
21
22
  const st = statSync(p);
22
23
  if (st.isDirectory()) {
23
24
  if (basename(p) === ".git" || basename(p) === "node_modules")
24
25
  return;
26
+ // statSync follows symlinks — track real paths so a symlink cycle
27
+ // can't recurse forever.
28
+ const real = realpathSync(p);
29
+ if (visitedDirs.has(real))
30
+ return;
31
+ visitedDirs.add(real);
25
32
  for (const entry of readdirSync(p))
26
33
  visit(join(p, entry));
27
34
  return;
@@ -214,7 +221,13 @@ export function cmdScaffold(argv, io) {
214
221
  writeFileSync(target, f.content, "utf8");
215
222
  io.out(`wrote ${target} [${f.validated}]`);
216
223
  }
217
- writeFileSync(join(outDir, `${sqlTable.toLowerCase()}.tabl.suggestion.txt`), result.suggestedTableDdl, "utf8");
224
+ const suggestionTarget = join(outDir, `${sqlTable.toLowerCase()}.tabl.suggestion.txt`);
225
+ if (existsSync(suggestionTarget) && !flags.has("force")) {
226
+ io.err(`refusing to overwrite ${suggestionTarget} (use --force)`);
227
+ return 1;
228
+ }
229
+ writeFileSync(suggestionTarget, result.suggestedTableDdl, "utf8");
230
+ io.out(`wrote ${suggestionTarget}`);
218
231
  }
219
232
  else {
220
233
  for (const f of result.files) {
@@ -288,7 +301,8 @@ export function cmdReleased(argv, io) {
288
301
  io.out(`Released-API status (SAP Cloudification snapshot ${RELEASED_API_SNAPSHOT.snapshotDate}):`);
289
302
  for (const r of results) {
290
303
  const tail = r.successor !== undefined ? ` → use ${r.successor}` : "";
291
- io.out(` ${r.name.padEnd(34)} ${r.state.padEnd(13)} ${(r.objectType ?? "").padEnd(9)}${tail}`);
304
+ const provenance = r.recorded ? "" : " (not in snapshot)";
305
+ io.out(` ${r.name.padEnd(34)} ${r.state.padEnd(13)} ${(r.objectType ?? "").padEnd(9)}${provenance}${tail}`);
292
306
  }
293
307
  return 0;
294
308
  }
package/dist/errors.js CHANGED
@@ -21,9 +21,15 @@ export function errorResult(err) {
21
21
  const e = err instanceof McpToolError
22
22
  ? err
23
23
  : new McpToolError("internal", err instanceof Error ? err.message : String(err));
24
+ // IMPORTANT: do NOT attach `structuredContent` here. Every tool declares an `outputSchema`
25
+ // describing its *success* shape, and the MCP SDK validates `structuredContent` against that
26
+ // schema even when `isError: true`. An error payload ({ error: … }) cannot satisfy a success
27
+ // schema, so strict clients (OpenClaw, the MCP inspector, the VSCode/ADT integrations) reject
28
+ // the whole result with `-32602` and the model never sees the real, actionable message —
29
+ // it just gives up and answers from memory. The spec permits an absent `structuredContent`
30
+ // on error results, so the human-readable `code: message` text carries the error instead.
24
31
  return {
25
32
  isError: true,
26
33
  content: [{ type: "text", text: `${e.code}: ${e.message}` }],
27
- structuredContent: { error: { code: e.code, message: e.message, details: e.details ?? null } },
28
34
  };
29
35
  }
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "abap-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
+ "mcpName": "io.github.palimkarakshay/abap-mcp",
4
5
  "description": "MCP server for SAP ABAP: offline static analysis (abaplint), ABAP Cloud / Clean Core readiness checks, and RAP scaffolding — no SAP system or credentials required.",
5
6
  "license": "MIT",
6
7
  "author": "Akshay Palimkar",