arc-1 0.9.4 → 0.9.6

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.
Files changed (80) hide show
  1. package/README.md +19 -1
  2. package/dist/adt/client.d.ts +61 -7
  3. package/dist/adt/client.d.ts.map +1 -1
  4. package/dist/adt/client.js +238 -9
  5. package/dist/adt/client.js.map +1 -1
  6. package/dist/adt/config.d.ts +7 -1
  7. package/dist/adt/config.d.ts.map +1 -1
  8. package/dist/adt/config.js.map +1 -1
  9. package/dist/adt/features.d.ts.map +1 -1
  10. package/dist/adt/features.js +27 -3
  11. package/dist/adt/features.js.map +1 -1
  12. package/dist/adt/http.d.ts +23 -0
  13. package/dist/adt/http.d.ts.map +1 -1
  14. package/dist/adt/http.js +82 -2
  15. package/dist/adt/http.js.map +1 -1
  16. package/dist/adt/rap-handlers.d.ts +41 -0
  17. package/dist/adt/rap-handlers.d.ts.map +1 -1
  18. package/dist/adt/rap-handlers.js +31 -8
  19. package/dist/adt/rap-handlers.js.map +1 -1
  20. package/dist/adt/types.d.ts +8 -0
  21. package/dist/adt/types.d.ts.map +1 -1
  22. package/dist/adt/xml-parser.d.ts +22 -0
  23. package/dist/adt/xml-parser.d.ts.map +1 -1
  24. package/dist/adt/xml-parser.js +32 -0
  25. package/dist/adt/xml-parser.js.map +1 -1
  26. package/dist/authz/policy.d.ts.map +1 -1
  27. package/dist/authz/policy.js +8 -0
  28. package/dist/authz/policy.js.map +1 -1
  29. package/dist/handlers/intent.d.ts +2 -1
  30. package/dist/handlers/intent.d.ts.map +1 -1
  31. package/dist/handlers/intent.js +383 -51
  32. package/dist/handlers/intent.js.map +1 -1
  33. package/dist/handlers/schemas.d.ts +48 -28
  34. package/dist/handlers/schemas.d.ts.map +1 -1
  35. package/dist/handlers/schemas.js +30 -0
  36. package/dist/handlers/schemas.js.map +1 -1
  37. package/dist/handlers/tools.d.ts.map +1 -1
  38. package/dist/handlers/tools.js +16 -0
  39. package/dist/handlers/tools.js.map +1 -1
  40. package/dist/lint/lint.d.ts.map +1 -1
  41. package/dist/lint/lint.js +6 -0
  42. package/dist/lint/lint.js.map +1 -1
  43. package/dist/lint/pre-write-hints.d.ts +45 -0
  44. package/dist/lint/pre-write-hints.d.ts.map +1 -0
  45. package/dist/lint/pre-write-hints.js +145 -0
  46. package/dist/lint/pre-write-hints.js.map +1 -0
  47. package/dist/server/audit.d.ts +27 -1
  48. package/dist/server/audit.d.ts.map +1 -1
  49. package/dist/server/audit.js.map +1 -1
  50. package/dist/server/auth-rate-limit.d.ts +78 -0
  51. package/dist/server/auth-rate-limit.d.ts.map +1 -0
  52. package/dist/server/auth-rate-limit.js +95 -0
  53. package/dist/server/auth-rate-limit.js.map +1 -0
  54. package/dist/server/config.d.ts.map +1 -1
  55. package/dist/server/config.js +56 -8
  56. package/dist/server/config.js.map +1 -1
  57. package/dist/server/http.d.ts.map +1 -1
  58. package/dist/server/http.js +74 -2
  59. package/dist/server/http.js.map +1 -1
  60. package/dist/server/mcp-rate-limit.d.ts +69 -0
  61. package/dist/server/mcp-rate-limit.d.ts.map +1 -0
  62. package/dist/server/mcp-rate-limit.js +92 -0
  63. package/dist/server/mcp-rate-limit.js.map +1 -0
  64. package/dist/server/server.d.ts +7 -5
  65. package/dist/server/server.d.ts.map +1 -1
  66. package/dist/server/server.js +43 -18
  67. package/dist/server/server.js.map +1 -1
  68. package/dist/server/stateless-client-store.d.ts +11 -3
  69. package/dist/server/stateless-client-store.d.ts.map +1 -1
  70. package/dist/server/stateless-client-store.js +39 -9
  71. package/dist/server/stateless-client-store.js.map +1 -1
  72. package/dist/server/types.d.ts +37 -6
  73. package/dist/server/types.d.ts.map +1 -1
  74. package/dist/server/types.js +3 -1
  75. package/dist/server/types.js.map +1 -1
  76. package/dist/server/xsuaa.d.ts +10 -1
  77. package/dist/server/xsuaa.d.ts.map +1 -1
  78. package/dist/server/xsuaa.js +38 -5
  79. package/dist/server/xsuaa.js.map +1 -1
  80. package/package.json +15 -13
@@ -37,6 +37,7 @@ import { detectFilename, lintAbapSource, lintAndFix, validateBeforeWrite } from
37
37
  import { sanitizeArgs } from '../server/audit.js';
38
38
  import { generateRequestId, requestContext } from '../server/context.js';
39
39
  import { logger } from '../server/logger.js';
40
+ import { resolveRateLimitUserKey } from '../server/mcp-rate-limit.js';
40
41
  import { expandHyperfocusedArgs } from './hyperfocused.js';
41
42
  import { getToolSchema } from './schemas.js';
42
43
  import { formatZodError } from './zod-errors.js';
@@ -225,7 +226,7 @@ function buildBaseErrorMessage(err, message, tool, args, config) {
225
226
  if (err instanceof AdtApiError) {
226
227
  // Append additional SAP messages (line numbers, secondary errors) if available
227
228
  const enriched = enrichWithSapDetails(err, message);
228
- const argType = String(args.type ?? '').toUpperCase();
229
+ const argType = canonicalTablType(String(args.type ?? '').toUpperCase());
229
230
  const classification = classifySapDomainError(err.statusCode, err.responseBody, err.path);
230
231
  if (classification) {
231
232
  const transactionLine = classification.transaction ? `\nSAP Transaction: ${classification.transaction}` : '';
@@ -295,7 +296,7 @@ function buildBaseErrorMessage(err, message, tool, args, config) {
295
296
  return enriched;
296
297
  }
297
298
  if (err instanceof AdtSafetyError) {
298
- const argType = String(args.type ?? '').toUpperCase();
299
+ const argType = canonicalTablType(String(args.type ?? '').toUpperCase());
299
300
  if (tool === 'SAPRead' && argType === 'TABLE_CONTENTS') {
300
301
  return (`${message}\n\nHint: TABLE_CONTENTS is blocked by safety configuration or missing data scope. ` +
301
302
  'Set SAP_ALLOW_DATA_PREVIEW=true at the server level and, in authenticated HTTP mode, ' +
@@ -658,7 +659,7 @@ async function inactiveSyntaxDiagnostic(client, type, name) {
658
659
  }
659
660
  }
660
661
  async function tryPostSaveSyntaxCheck(client, type, name) {
661
- if (!DDIC_POST_SAVE_CHECK_TYPES.has(type.toUpperCase()))
662
+ if (!DDIC_POST_SAVE_CHECK_TYPES.has(canonicalTablType(type.toUpperCase())))
662
663
  return '';
663
664
  return inactiveSyntaxDiagnostic(client, type, name);
664
665
  }
@@ -746,7 +747,7 @@ function classifyError(err) {
746
747
  * all tools are allowed (backward compatibility).
747
748
  * @param server - MCP Server instance for elicitation support.
748
749
  */
749
- export async function handleToolCall(client, config, toolName, args, authInfo, _server, cachingLayer, isPerUserClient) {
750
+ export async function handleToolCall(client, config, toolName, args, authInfo, _server, cachingLayer, isPerUserClient, mcpRateLimiter) {
750
751
  const reqId = generateRequestId();
751
752
  const start = Date.now();
752
753
  // Build user context for audit logging
@@ -763,17 +764,70 @@ export async function handleToolCall(client, config, toolName, args, authInfo, _
763
764
  tool: toolName,
764
765
  args: sanitizeArgs(args),
765
766
  });
767
+ // ─── Layer 2: per-user MCP tool-call rate limit ─────────────────────
768
+ // Applied immediately so we don't waste any work on denied calls. Stdio mode
769
+ // (no authInfo) is exempt — there's no user identity to key on. On denial we
770
+ // return an MCP tool error (not HTTP 429) so the LLM client surfaces it as a
771
+ // tool failure and the agent loop backs off via its own retry policy.
772
+ // See docs_page/rate-limiting.md (Layer 2). Cost weighting per tool is deferred
773
+ // to v2 — every consume call counts as one point.
774
+ if (mcpRateLimiter && authInfo) {
775
+ // Walks the most-specific identity claim first (userName → email → sub →
776
+ // preferred_username → clientId) so OIDC users sharing one `azp` clientId
777
+ // don't collapse into a single bucket. See resolveRateLimitUserKey.
778
+ const userKey = resolveRateLimitUserKey(authInfo);
779
+ const decision = await mcpRateLimiter.consume(userKey, toolName);
780
+ if (!decision.allowed) {
781
+ const retryAfter = Math.ceil(decision.retryAfterMs / 1000);
782
+ logger.emitAudit({
783
+ timestamp: new Date().toISOString(),
784
+ level: 'warn',
785
+ event: 'mcp_rate_limited',
786
+ requestId: reqId,
787
+ clientId,
788
+ user: userKey,
789
+ tool: toolName,
790
+ limitPerMinute: decision.limitPerMinute,
791
+ retryAfterMs: decision.retryAfterMs,
792
+ });
793
+ return {
794
+ content: [
795
+ {
796
+ type: 'text',
797
+ text: JSON.stringify({
798
+ error: 'rate_limited',
799
+ retryAfter,
800
+ message: `Rate limit exceeded (${decision.limitPerMinute}/min per user). Retry after ${retryAfter} seconds.`,
801
+ }),
802
+ },
803
+ ],
804
+ isError: true,
805
+ };
806
+ }
807
+ }
766
808
  // Unified scope enforcement via ACTION_POLICY — routes through action/type-aware lookup.
767
809
  // For SAPRead, the policy key is Tool.{type}; for other action-bearing tools, Tool.{action};
768
810
  // for tools without an action/type enum (SAPSearch, SAPQuery), the tool-level default applies.
811
+ // For SAPSearch.tadir_lookup with source='db'|'both', synthesize a sub-action key so the
812
+ // sql-scoped policy entry kicks in (otherwise viewer-only profiles could piggyback on the
813
+ // ADT info-system route to issue freestyle SQL).
769
814
  // Runs BEFORE Zod validation so scope errors don't leak schema details to unauthorized callers.
770
- const actionOrType = toolName === 'SAPRead'
815
+ let actionOrType = toolName === 'SAPRead'
771
816
  ? typeof args.type === 'string'
772
817
  ? args.type
773
818
  : undefined
774
819
  : typeof args.action === 'string'
775
820
  ? args.action
776
821
  : undefined;
822
+ if (toolName === 'SAPSearch' &&
823
+ typeof args.searchType === 'string' &&
824
+ args.searchType === 'tadir_lookup' &&
825
+ typeof args.source === 'string') {
826
+ const src = args.source.toLowerCase();
827
+ if (src === 'db' || src === 'both') {
828
+ actionOrType = `tadir_lookup_${src}`;
829
+ }
830
+ }
777
831
  const policy = getActionPolicy(toolName, actionOrType);
778
832
  if (authInfo && policy) {
779
833
  if (!hasRequiredScope(authInfo, policy.scope)) {
@@ -933,6 +987,28 @@ export async function handleToolCall(client, config, toolName, args, authInfo, _
933
987
  function isBtpSystem() {
934
988
  return cachedFeatures?.systemType === 'btp';
935
989
  }
990
+ /** Return whether the SAP ADT discovery feed advertises the /sap/bc/adt/ddic/tables
991
+ * collection (the transparent-table editor endpoint). Absent on NW 7.50/7.51 —
992
+ * SAP added it in NW 7.52 along with the new database-table editor. When the
993
+ * discovery cache is empty (e.g. probe never ran, tests that bypass SAPManage),
994
+ * returns `undefined` so callers can decide whether to default-allow.
995
+ * See issue #285. */
996
+ function isTablesEndpointAvailable() {
997
+ const map = cachedFeatures?.discoveryMap ?? cachedDiscovery;
998
+ if (!map || map.size === 0)
999
+ return undefined;
1000
+ return map.has('/sap/bc/adt/ddic/tables');
1001
+ }
1002
+ /** Stable hint surfaced when ARC-1 refuses a TABL/DT write because the connected
1003
+ * system does not expose /sap/bc/adt/ddic/tables/. Shared between the
1004
+ * resolver-driven update/delete/activate paths and the discovery-gated create
1005
+ * paths so the LLM always sees the same recovery instructions. */
1006
+ const TABL_DT_WRITE_UNAVAILABLE_HINT = 'Transparent table writes via ADT REST are not available on this system ' +
1007
+ '(/sap/bc/adt/ddic/tables/ is not exposed — NW 7.50/7.51 ship the DDIC ' +
1008
+ 'structures endpoint only; the table editor was added in NW 7.52). ' +
1009
+ 'Use SE11 in SAPGUI, or connect ARC-1 to an SAP_BASIS ≥ 7.52 system. ' +
1010
+ 'Writing the source via /sap/bc/adt/ddic/structures/ would silently flip ' +
1011
+ 'DD02L-TABCLASS to INTTAB and corrupt the table.';
936
1012
  /** BTP-specific error messages for unavailable operations */
937
1013
  const BTP_HINTS = {
938
1014
  PROG: 'Executable programs (reports) are not available on BTP ABAP Environment. Use CLAS with IF_OO_ADT_CLASSRUN for console applications.',
@@ -1425,16 +1501,92 @@ async function handleSAPSearch(client, args) {
1425
1501
  return errorResult('SAPSearch(searchType="tadir_lookup") requires names[] or query with at least one name.');
1426
1502
  }
1427
1503
  const objectTypes = extractLookupObjectTypes(args.objectType, args.objectTypes);
1428
- const lookups = await client.lookupObjects(names, { maxResults, objectTypes });
1429
- const missing = lookups.filter((l) => !l.found).map((l) => l.name);
1430
- const matchCount = lookups.reduce((count, lookup) => count + lookup.matches.length, 0);
1504
+ const rawSource = typeof args.source === 'string' ? args.source.toLowerCase() : 'adt';
1505
+ const source = rawSource === 'db' || rawSource === 'both' ? rawSource : 'adt';
1506
+ // Stamp each match with provenance so a merged 'both' result is unambiguous and
1507
+ // viewer tooling can colour-code ghost rows. The DB path already stamps `_origin:'db'`
1508
+ // (see `lookupObjectsViaDb`); we stamp ADT matches here.
1509
+ const tagOrigin = (lookups, origin) => lookups.map((l) => ({
1510
+ ...l,
1511
+ matches: l.matches.map((m) => ({ ...m, _origin: m._origin ?? origin })),
1512
+ }));
1513
+ let finalLookups;
1431
1514
  const wildcardNames = names.filter((name) => name.includes('*'));
1432
- const warnings = wildcardNames.length > 0
1433
- ? [
1434
- `tadir_lookup performs exact-name lookup; wildcard characters are treated literally for: ${wildcardNames.join(', ')}`,
1435
- ]
1436
- : undefined;
1437
- return textResult(JSON.stringify({ count: matchCount, lookups, missing, ...(warnings ? { warnings } : {}) }, null, 2));
1515
+ const warnings = [];
1516
+ let splitBrain = [];
1517
+ if (source === 'adt') {
1518
+ finalLookups = tagOrigin(await client.lookupObjects(names, { maxResults, objectTypes }), 'adt');
1519
+ }
1520
+ else if (source === 'db') {
1521
+ // The 'db' path bypasses ADT info-system entirely; `lookupObjectsViaDb` already
1522
+ // tags matches with `_origin:'db'`. Safety/scope gating runs at handleToolCall
1523
+ // and in client.runQuery (FreeSQL operation), so unauthorized callers never reach here.
1524
+ finalLookups = await client.lookupObjectsViaDb(names, { maxResults, objectTypes });
1525
+ }
1526
+ else {
1527
+ // 'both' — parallel ADT + DB, merge per name with dedupe.
1528
+ const [adtLookups, dbLookups] = await Promise.all([
1529
+ client.lookupObjects(names, { maxResults, objectTypes }).then((r) => tagOrigin(r, 'adt')),
1530
+ client.lookupObjectsViaDb(names, { maxResults, objectTypes }),
1531
+ ]);
1532
+ const dbByName = new Map(dbLookups.map((l) => [l.name.toUpperCase(), l]));
1533
+ const adtByName = new Map(adtLookups.map((l) => [l.name.toUpperCase(), l]));
1534
+ finalLookups = names.map((rawName) => {
1535
+ const upper = rawName.toUpperCase();
1536
+ const adt = adtByName.get(upper);
1537
+ const db = dbByName.get(upper);
1538
+ const adtMatches = adt?.matches ?? [];
1539
+ const dbMatches = db?.matches ?? [];
1540
+ // Dedupe by (baseObjectType, objectName) — TADIR stores bare types ('DDLS')
1541
+ // while ADT info-system returns slash-form ('DDLS/DF'). Stripping the suffix
1542
+ // keeps the same logical object from appearing twice in the merged matches.
1543
+ // Preserve the more-specific slash form when both originate from ADT+DB.
1544
+ const seen = new Map();
1545
+ const baseKey = (m) => `${(m.objectType.split('/')[0] || m.objectType).toUpperCase()}${m.objectName.toUpperCase()}`;
1546
+ for (const m of adtMatches)
1547
+ seen.set(baseKey(m), m);
1548
+ for (const m of dbMatches) {
1549
+ const k = baseKey(m);
1550
+ if (!seen.has(k))
1551
+ seen.set(k, m);
1552
+ }
1553
+ const mergedMatches = [...seen.values()];
1554
+ // Split-brain detection: an object is divergent if exactly one source has matches.
1555
+ // (Zero matches on both sides = consistent absence; matches on both = consistent presence.)
1556
+ if (adtMatches.length > 0 !== dbMatches.length > 0) {
1557
+ splitBrain.push(rawName);
1558
+ }
1559
+ return { name: rawName, found: mergedMatches.length > 0, matches: mergedMatches };
1560
+ });
1561
+ // Compose human-friendly warnings per split-brain name. Keep them grounded in
1562
+ // the most common cause (TADIR ghost from aborted create/delete) so LLM clients
1563
+ // can suggest the right cleanup path without inventing a new pointer.
1564
+ for (const name of splitBrain) {
1565
+ const adt = adtByName.get(name.toUpperCase());
1566
+ const db = dbByName.get(name.toUpperCase());
1567
+ const adtHas = (adt?.matches.length ?? 0) > 0;
1568
+ const dbHas = (db?.matches.length ?? 0) > 0;
1569
+ if (dbHas && !adtHas) {
1570
+ warnings.push(`${name} exists in TADIR (DB) but ADT cannot resolve it — likely a TADIR ghost from an aborted create/delete cycle. Consider RS_DD_TADIR_CLEANUP or manual SE03 cleanup.`);
1571
+ }
1572
+ else if (adtHas && !dbHas) {
1573
+ warnings.push(`${name} resolves via ADT but is not present in the TADIR row scan — likely a release-time mismatch or a type filter excluding the row. Re-run with broader objectTypes or no filter to confirm.`);
1574
+ }
1575
+ }
1576
+ }
1577
+ // Dedupe split-brain names (defensive; merge loop should already avoid duplicates).
1578
+ splitBrain = [...new Set(splitBrain)];
1579
+ if (wildcardNames.length > 0) {
1580
+ warnings.push(`tadir_lookup performs exact-name lookup; wildcard characters are treated literally for: ${wildcardNames.join(', ')}`);
1581
+ }
1582
+ const missing = finalLookups.filter((l) => !l.found).map((l) => l.name);
1583
+ const matchCount = finalLookups.reduce((count, lookup) => count + lookup.matches.length, 0);
1584
+ const payload = { count: matchCount, lookups: finalLookups, missing };
1585
+ if (splitBrain.length > 0)
1586
+ payload.splitBrain = splitBrain;
1587
+ if (warnings.length > 0)
1588
+ payload.warnings = warnings;
1589
+ return textResult(JSON.stringify(payload, null, 2));
1438
1590
  }
1439
1591
  if (searchType === 'source_code') {
1440
1592
  // Source code search: do NOT transliterate — source can contain umlauts in strings/comments
@@ -2165,18 +2317,24 @@ export function buildCreateXml(type, name, pkg, description, properties) {
2165
2317
  <adtcore:packageRef adtcore:name="${escapeXml(pkg)}"/>
2166
2318
  </dcl:dclSource>`;
2167
2319
  case 'TABL':
2168
- // TABL creation also uses SAP's "blue" framework envelope, then source is written via /source/main.
2320
+ case 'TABL/DT':
2321
+ case 'TABL/DS': {
2322
+ // Bare TABL is the legacy alias for TABL/DT (transparent table). The same
2323
+ // <blue:blueSource> envelope works for both subtypes — only adtcore:type
2324
+ // and the POST URL differ. See docs/plans/completed/fix-tabl-ds-create-routing.md.
2325
+ const adtType = type === 'TABL/DS' ? 'TABL/DS' : 'TABL/DT';
2169
2326
  return `<?xml version="1.0" encoding="UTF-8"?>
2170
2327
  <blue:blueSource xmlns:blue="http://www.sap.com/wbobj/blue"
2171
2328
  xmlns:adtcore="http://www.sap.com/adt/core"
2172
2329
  adtcore:description="${escapeXml(description)}"
2173
2330
  adtcore:name="${escapeXml(name)}"
2174
- adtcore:type="TABL/DT"
2331
+ adtcore:type="${adtType}"
2175
2332
  adtcore:masterLanguage="EN"
2176
2333
  adtcore:masterSystem="H00"
2177
2334
  adtcore:responsible="DEVELOPER">
2178
2335
  <adtcore:packageRef adtcore:name="${escapeXml(pkg)}"/>
2179
2336
  </blue:blueSource>`;
2337
+ }
2180
2338
  case 'BDEF':
2181
2339
  // BDEF uses SAP's "blue" framework — blue:blueSource with http://www.sap.com/wbobj/blue namespace.
2182
2340
  // Confirmed by vibing-steampunk (Go) and fr0ster (TypeScript) reference implementations.
@@ -2481,6 +2639,37 @@ export function normalizeObjectType(type) {
2481
2639
  return '';
2482
2640
  return SLASH_TYPE_MAP[normalized] ?? normalized;
2483
2641
  }
2642
+ /** TABL subtypes that SAPWrite preserves (instead of collapsing to bare 'TABL' via
2643
+ * SLASH_TYPE_MAP) so the create path can route TABL/DT → /ddic/tables and
2644
+ * TABL/DS → /ddic/structures. See docs/plans/completed/fix-tabl-ds-create-routing.md. */
2645
+ const TABL_WRITE_SUBTYPES = new Set(['TABL/DT', 'TABL/DS']);
2646
+ /** Legacy slash-form aliases SAPWrite remaps to a canonical subtype before
2647
+ * SLASH_TYPE_MAP runs — otherwise STRU/DS would collapse to bare 'TABL' and
2648
+ * route the structure create to /ddic/tables. */
2649
+ const SAPWRITE_TABL_ALIAS = {
2650
+ 'STRU/DS': 'TABL/DS',
2651
+ };
2652
+ /** SAPWrite-only normalizer: preserves TABL/DT and TABL/DS and remaps STRU/DS
2653
+ * to TABL/DS. Every other tool keeps the global collapsing behaviour of
2654
+ * `normalizeObjectType`. */
2655
+ function normalizeWriteObjectType(type) {
2656
+ const normalized = String(type).trim().toUpperCase();
2657
+ if (!normalized)
2658
+ return '';
2659
+ const aliased = SAPWRITE_TABL_ALIAS[normalized];
2660
+ if (aliased)
2661
+ return aliased;
2662
+ if (TABL_WRITE_SUBTYPES.has(normalized))
2663
+ return normalized;
2664
+ return SLASH_TYPE_MAP[normalized] ?? normalized;
2665
+ }
2666
+ /** Collapse TABL/DT and TABL/DS back to bare 'TABL' for downstream Set-membership
2667
+ * checks (DDIC hints, RAP preflight, CDS dependency hints, cache invalidation)
2668
+ * that only know about canonical types. The slash form survives at URL routing
2669
+ * + XML envelope sites. */
2670
+ function canonicalTablType(type) {
2671
+ return type === 'TABL/DT' || type === 'TABL/DS' ? 'TABL' : type;
2672
+ }
2484
2673
  /** Normalize type fields before schema validation so slash/case aliases are accepted. */
2485
2674
  function normalizeTypeArgsForValidation(toolName, args) {
2486
2675
  switch (toolName) {
@@ -2491,14 +2680,15 @@ function normalizeTypeArgsForValidation(toolName, args) {
2491
2680
  objectType: args.objectType === undefined ? undefined : normalizeObjectType(String(args.objectType ?? '')),
2492
2681
  };
2493
2682
  case 'SAPWrite':
2683
+ // SAPWrite preserves TABL/DT and TABL/DS so the create path can route by subtype.
2494
2684
  return {
2495
2685
  ...args,
2496
- type: args.type === undefined ? undefined : normalizeObjectType(String(args.type ?? '')),
2686
+ type: args.type === undefined ? undefined : normalizeWriteObjectType(String(args.type ?? '')),
2497
2687
  objects: Array.isArray(args.objects)
2498
2688
  ? args.objects.map((obj) => typeof obj === 'object' && obj !== null
2499
2689
  ? {
2500
2690
  ...obj,
2501
- type: normalizeObjectType(String(obj.type ?? '')),
2691
+ type: normalizeWriteObjectType(String(obj.type ?? '')),
2502
2692
  }
2503
2693
  : obj)
2504
2694
  : args.objects,
@@ -2600,10 +2790,13 @@ export function objectBasePath(type) {
2600
2790
  case 'SRVB':
2601
2791
  return '/sap/bc/adt/businessservices/bindings/';
2602
2792
  case 'TABL':
2603
- // Default URL prefix for TABL: /tables/ (transparent tables). DDIC structures
2604
- // live at /sap/bc/adt/ddic/structures/<name>; for those, callers must use
2605
- // AdtClient.resolveTablObjectUrl(name) which falls back on 404.
2793
+ case 'TABL/DT':
2794
+ // Bare TABL defaults to transparent table. For reads, callers should use
2795
+ // AdtClient.resolveTablObjectUrl(name) which falls back to /structures/ on 404.
2606
2796
  return '/sap/bc/adt/ddic/tables/';
2797
+ case 'TABL/DS':
2798
+ // DDIC structures only route through this collection; see follow-up to #285.
2799
+ return '/sap/bc/adt/ddic/structures/';
2607
2800
  case 'DOMA':
2608
2801
  return '/sap/bc/adt/ddic/domains/';
2609
2802
  case 'DTEL':
@@ -2733,7 +2926,7 @@ function stripIncludeHeader(source) {
2733
2926
  // ─── SAPWrite Handler ────────────────────────────────────────────────
2734
2927
  async function handleSAPWrite(client, args, config, cachingLayer) {
2735
2928
  const action = String(args.action ?? '');
2736
- const type = normalizeObjectType(String(args.type ?? ''));
2929
+ const type = normalizeWriteObjectType(String(args.type ?? ''));
2737
2930
  const name = String(args.name ?? '');
2738
2931
  const source = String(args.source ?? '');
2739
2932
  const hasSource = typeof args.source === 'string';
@@ -2772,8 +2965,22 @@ async function handleSAPWrite(client, args, config, cachingLayer) {
2772
2965
  // `buildCreateXml('FUNC', …, properties)` finds it.
2773
2966
  let objectUrl;
2774
2967
  let srcUrl;
2775
- if (type === 'TABL' && action !== 'create' && action !== 'batch_create') {
2776
- objectUrl = await client.resolveTablObjectUrl(name);
2968
+ if ((type === 'TABL' || type === 'TABL/DT' || type === 'TABL/DS') &&
2969
+ action !== 'create' &&
2970
+ action !== 'batch_create') {
2971
+ // All TABL forms route through the search-first resolver on update/delete/activate
2972
+ // so the PR #286 SE11-hint refusal applies even when callers pass an explicit slash form.
2973
+ try {
2974
+ objectUrl = await client.resolveTablObjectUrlForWrite(name, {
2975
+ tablesEndpointAvailable: isTablesEndpointAvailable(),
2976
+ });
2977
+ }
2978
+ catch (resolveErr) {
2979
+ if (resolveErr instanceof AdtSafetyError) {
2980
+ return errorResult(resolveErr.message);
2981
+ }
2982
+ throw resolveErr;
2983
+ }
2777
2984
  srcUrl = `${objectUrl}/source/main`;
2778
2985
  }
2779
2986
  else if (type === 'FUNC') {
@@ -2798,11 +3005,20 @@ async function handleSAPWrite(client, args, config, cachingLayer) {
2798
3005
  args.group = group;
2799
3006
  }
2800
3007
  else {
3008
+ // Discovery gate: refuse transparent-table creates upfront on systems that
3009
+ // don't expose /ddic/tables/ (NW 7.50/7.51). TABL/DS skips this — /structures/
3010
+ // is always available. See issue #285.
3011
+ if ((type === 'TABL' || type === 'TABL/DT') && (action === 'create' || action === 'batch_create')) {
3012
+ if (isTablesEndpointAvailable() === false) {
3013
+ return errorResult(TABL_DT_WRITE_UNAVAILABLE_HINT);
3014
+ }
3015
+ }
2801
3016
  objectUrl = objectUrlForType(type, name);
2802
3017
  srcUrl = sourceUrlForType(type, name);
2803
3018
  }
2804
3019
  const invalidateWrittenObject = (objType = type, objName = name) => {
2805
- cachingLayer?.invalidate(objType, objName, 'all');
3020
+ // Source cache is keyed by canonical type (SAPRead collapses TABL/DT, TABL/DS).
3021
+ cachingLayer?.invalidate(canonicalTablType(objType), objName, 'all');
2806
3022
  cachingLayer?.inactiveLists.invalidate(client.username);
2807
3023
  };
2808
3024
  // Helper: enforce allowedPackages for existing objects (update/delete/edit_method/scaffold_rap_handlers).
@@ -3047,7 +3263,7 @@ async function handleSAPWrite(client, args, config, cachingLayer) {
3047
3263
  // 'application/*' — the wildcard lets the SAP server resolve the correct
3048
3264
  // handler (matching how ADT Eclipse and abap-adt-api send requests).
3049
3265
  const contentType = createContentTypeForType(type);
3050
- const needsPackageParam = type === 'BDEF' || type === 'TABL';
3266
+ const needsPackageParam = type === 'BDEF' || type === 'TABL' || type === 'TABL/DT' || type === 'TABL/DS';
3051
3267
  let result;
3052
3268
  try {
3053
3269
  result = await createObject(client.http, client.safety, createUrl, body, contentType, effectiveTransport, needsPackageParam ? pkg : undefined, cachedFeatures?.abapRelease);
@@ -3493,7 +3709,9 @@ async function handleSAPWrite(client, args, config, cachingLayer) {
3493
3709
  });
3494
3710
  }
3495
3711
  catch (err) {
3496
- if (err instanceof AdtApiError && CDS_DEPENDENCY_SENSITIVE_TYPES.has(type) && isDeleteDependencyError(err)) {
3712
+ if (err instanceof AdtApiError &&
3713
+ CDS_DEPENDENCY_SENSITIVE_TYPES.has(canonicalTablType(type)) &&
3714
+ isDeleteDependencyError(err)) {
3497
3715
  const hint = await buildCdsDeleteDependencyHint(client, type, name, objectUrl);
3498
3716
  if (hint) {
3499
3717
  // Attach via extraHint so the LLM-facing formatter renders it after
@@ -3513,9 +3731,14 @@ async function handleSAPWrite(client, args, config, cachingLayer) {
3513
3731
  if (!objects || !Array.isArray(objects) || objects.length === 0) {
3514
3732
  return errorResult('"objects" array is required and must be non-empty for batch_create action.');
3515
3733
  }
3734
+ // Opt-in deferred-activation: writes every object as an inactive draft first,
3735
+ // then issues a single terminal activateBatch over the written subset. Use case:
3736
+ // composition-linked DDLS / interdependent RAP graphs where per-object inline
3737
+ // activate() can't resolve cross-references to not-yet-active siblings.
3738
+ const activateAtEnd = args.activateAtEnd === true || String(args.activateAtEnd) === 'true';
3516
3739
  const defaultPackage = normalizePackageOverride(args.package, '$TMP');
3517
3740
  const batchPlan = objects.map((obj) => {
3518
- const objType = normalizeObjectType(String(obj.type ?? ''));
3741
+ const objType = normalizeWriteObjectType(String(obj.type ?? ''));
3519
3742
  const objName = String(obj.name ?? '');
3520
3743
  const objPackage = normalizePackageOverride(obj.package, defaultPackage);
3521
3744
  const explicitTransport = normalizeTransportOverride(obj.transport) ?? transport;
@@ -3574,6 +3797,9 @@ async function handleSAPWrite(client, args, config, cachingLayer) {
3574
3797
  // guard fires for every MSAG entry, but a batch typically shares one transport — cache
3575
3798
  // the lookup result to avoid one HTTP roundtrip per object.
3576
3799
  const transportLookupCache = new Map();
3800
+ // Accumulated objects whose create + source-write phase succeeded — used by the
3801
+ // terminal activateBatch when activateAtEnd=true. Order matches the input order.
3802
+ const writtenObjects = [];
3577
3803
  for (const plan of batchPlan) {
3578
3804
  const { obj, type: objType, name: objName, packageName: objPackage } = plan;
3579
3805
  const objTransport = plan.explicitTransport ?? autoTransportByPackage.get(objPackage);
@@ -3653,13 +3879,24 @@ async function handleSAPWrite(client, args, config, cachingLayer) {
3653
3879
  break;
3654
3880
  }
3655
3881
  }
3656
- // Step 1: Create the object
3882
+ // Step 1: Create the object (per-entry transparent-table discovery gate;
3883
+ // mirrors the single-create site above. TABL/DS skips it — /structures/ always exists.)
3884
+ if ((objType === 'TABL' || objType === 'TABL/DT') && isTablesEndpointAvailable() === false) {
3885
+ results.push({
3886
+ type: objType,
3887
+ name: objName,
3888
+ packageName: objPackage,
3889
+ status: 'failed',
3890
+ error: TABL_DT_WRITE_UNAVAILABLE_HINT,
3891
+ });
3892
+ break;
3893
+ }
3657
3894
  const objUrl = objectUrlForType(objType, objName);
3658
3895
  const createUrl = objUrl.replace(/\/[^/]+$/, '');
3659
3896
  const objMetadataProps = getMetadataWriteProperties(obj);
3660
3897
  const body = buildCreateXml(objType, objName, objPackage, objDescription, objMetadataProps);
3661
3898
  const contentType = createContentTypeForType(objType);
3662
- const needsPackageParam = objType === 'BDEF' || objType === 'TABL';
3899
+ const needsPackageParam = objType === 'BDEF' || objType === 'TABL' || objType === 'TABL/DT' || objType === 'TABL/DS';
3663
3900
  try {
3664
3901
  await createObject(client.http, client.safety, createUrl, body, contentType, objTransport, needsPackageParam ? objPackage : undefined, cachedFeatures?.abapRelease);
3665
3902
  }
@@ -3690,20 +3927,50 @@ async function handleSAPWrite(client, args, config, cachingLayer) {
3690
3927
  const srcUrl = sourceUrlForType(objType, objName);
3691
3928
  await safeUpdateSource(client.http, client.safety, objUrl, srcUrl, objSource, objTransport, cachedFeatures?.abapRelease);
3692
3929
  }
3693
- // Step 3: Activate the object
3694
- const activationResult = await activate(client.http, client.safety, objUrl);
3695
- if (!activationResult.success) {
3696
- results.push({
3697
- type: objType,
3698
- name: objName,
3699
- packageName: objPackage,
3700
- status: 'failed',
3701
- error: `activation failed: ${activationResult.messages.join('; ')}`,
3702
- });
3703
- break;
3930
+ // Resolve the activation URL up front so both the inline path and the
3931
+ // deferred terminal-activate path use the same URL. FUNC needs the parent
3932
+ // function-group baked into the path (issue #250); objectUrlForType throws
3933
+ // for FUNC so we mirror the FUNC-aware resolver from handleSAPActivate. For
3934
+ // TABL we keep objUrl (already resolved to /tables/) — DDIC-structure FMs
3935
+ // aren't a real concept and the create path doesn't expose one.
3936
+ let activationUrl = objUrl;
3937
+ if (objType === 'FUNC') {
3938
+ let group = String(obj.group ?? args.group ?? '').trim();
3939
+ if (!group) {
3940
+ const resolved = cachingLayer
3941
+ ? await cachingLayer.resolveFuncGroup(client, objName)
3942
+ : await client.resolveFunctionGroup(objName);
3943
+ if (!resolved) {
3944
+ throw new Error(`Cannot resolve function group for FM "${objName}" in batch_create activation step. Provide "group" on the FUNC entry.`);
3945
+ }
3946
+ group = resolved;
3947
+ }
3948
+ const groupLc = encodeURIComponent(group.toLowerCase());
3949
+ activationUrl = `/sap/bc/adt/functions/groups/${groupLc}/fmodules/${encodeURIComponent(objName.toLowerCase())}`;
3950
+ }
3951
+ if (activateAtEnd) {
3952
+ // Step 3 deferred: track this object for the terminal activateBatch call.
3953
+ // Cache invalidation also moves to AFTER the terminal activate succeeds —
3954
+ // invalidating now would let the next read see a draft we couldn't activate.
3955
+ writtenObjects.push({ type: objType, name: objName, url: activationUrl });
3956
+ results.push({ type: objType, name: objName, packageName: objPackage, status: 'success' });
3957
+ }
3958
+ else {
3959
+ // Step 3: Activate the object (inline, default behavior).
3960
+ const activationResult = await activate(client.http, client.safety, activationUrl);
3961
+ if (!activationResult.success) {
3962
+ results.push({
3963
+ type: objType,
3964
+ name: objName,
3965
+ packageName: objPackage,
3966
+ status: 'failed',
3967
+ error: `activation failed: ${activationResult.messages.join('; ')}`,
3968
+ });
3969
+ break;
3970
+ }
3971
+ invalidateWrittenObject(objType, objName);
3972
+ results.push({ type: objType, name: objName, packageName: objPackage, status: 'success' });
3704
3973
  }
3705
- invalidateWrittenObject(objType, objName);
3706
- results.push({ type: objType, name: objName, packageName: objPackage, status: 'success' });
3707
3974
  }
3708
3975
  catch (err) {
3709
3976
  results.push({
@@ -3728,6 +3995,52 @@ async function handleSAPWrite(client, args, config, cachingLayer) {
3728
3995
  error: 'skipped — stopped after previous failure',
3729
3996
  });
3730
3997
  }
3998
+ // ── Terminal activateBatch (activateAtEnd=true) ─────────────────────
3999
+ // After every write-phase succeeded (or broke off early), issue ONE batch
4000
+ // activate over the already-written subset. This is the killer feature
4001
+ // for composition-linked DDLS and RAP behavior stacks — SAP's activator
4002
+ // sees the whole graph in a single POST and resolves cross-references
4003
+ // internally, so parent → child siblings activate cleanly.
4004
+ let terminalActivationFailure;
4005
+ if (activateAtEnd && writtenObjects.length > 0) {
4006
+ const activationOutcome = await activateBatch(client.http, client.safety, writtenObjects);
4007
+ if (activationOutcome.success) {
4008
+ // Defensive: per-object status was already 'success' from the write phase.
4009
+ // Cache invalidation moves here so a failed terminal activate doesn't strand
4010
+ // a stale 'active' cache entry. Invalidate inactive-lists once for the user.
4011
+ for (const o of writtenObjects) {
4012
+ cachingLayer?.invalidate(o.type, o.name, 'all');
4013
+ }
4014
+ cachingLayer?.inactiveLists.invalidate(client.username);
4015
+ }
4016
+ else {
4017
+ // Flip every written-but-not-yet-activated entry to 'failed', preserving the
4018
+ // "create + source-write succeeded" context. Reuse the existing per-object
4019
+ // diagnostic mapper so callers see the activation messages keyed by object name.
4020
+ const batchStatuses = buildBatchActivationStatuses(writtenObjects, activationOutcome);
4021
+ const statusDetails = formatBatchActivationStatuses(batchStatuses);
4022
+ terminalActivationFailure = statusDetails;
4023
+ const statusByName = new Map(batchStatuses.map((s) => [`${s.type}${s.name}`, s]));
4024
+ for (const result of results) {
4025
+ if (result.status !== 'success')
4026
+ continue;
4027
+ const key = `${result.type}${result.name}`;
4028
+ const matched = statusByName.get(key);
4029
+ if (!matched)
4030
+ continue;
4031
+ // Some entries may still report status 'active' if the activator returned
4032
+ // success: false but had no per-object error details — keep them as 'success'.
4033
+ if (matched.status === 'active')
4034
+ continue;
4035
+ result.status = 'failed';
4036
+ const detail = matched.messages.length > 0 ? ` — ${matched.messages.join('; ')}` : '';
4037
+ // Preserve the "create + source-write succeeded" context so the user sees that
4038
+ // the failure was specifically the activation step, not the write step.
4039
+ result.error = `${writtenObjects.length}/${writtenObjects.length} written, batch activation failed${detail}`;
4040
+ }
4041
+ }
4042
+ }
4043
+ // ────────────────────────────────────────────────────────────────────
3731
4044
  const summary = results
3732
4045
  .map((r) => r.status === 'success'
3733
4046
  ? `${r.name} (${r.type}) ✓ [${r.packageName}]`
@@ -3736,19 +4049,21 @@ async function handleSAPWrite(client, args, config, cachingLayer) {
3736
4049
  const successCount = results.filter((r) => r.status === 'success').length;
3737
4050
  const hasFailure = results.some((r) => r.status === 'failed');
3738
4051
  const warningSuffix = batchWarnings.length > 0 ? `\n\nRAP preflight warnings:\n- ${batchWarnings.join('\n- ')}` : '';
4052
+ const activateAtEndSuffix = terminalActivationFailure !== undefined ? `\n\nBatch activation diagnostics:${terminalActivationFailure}` : '';
3739
4053
  const packageNames = [...new Set(batchPlan.map((item) => item.packageName))];
3740
4054
  const packageSummary = packageNames.length === 1
3741
4055
  ? `in package ${packageNames[0]}`
3742
4056
  : packageNames.length <= 3
3743
4057
  ? `across packages [${packageNames.join(', ')}]`
3744
4058
  : `across ${packageNames.length} packages`;
4059
+ const activateAtEndPrefix = activateAtEnd ? '; activated as a single batch' : '';
3745
4060
  if (hasFailure) {
3746
4061
  const cleanupHint = successCount > 0
3747
4062
  ? ` Note: ${successCount} already-created object(s) remain on the SAP system and may need manual cleanup.`
3748
4063
  : '';
3749
- return errorResult(`Batch created ${successCount}/${objects.length} objects ${packageSummary}: ${summary}${cleanupHint}${warningSuffix}`);
4064
+ return errorResult(`Batch created ${successCount}/${objects.length} objects ${packageSummary}${activateAtEndPrefix}: ${summary}${cleanupHint}${warningSuffix}${activateAtEndSuffix}`);
3750
4065
  }
3751
- return textResult(`Batch created ${successCount} objects ${packageSummary}: ${summary}${warningSuffix}`);
4066
+ return textResult(`Batch created ${successCount} objects ${packageSummary}${activateAtEndPrefix}: ${summary}${warningSuffix}${activateAtEndSuffix}`);
3752
4067
  }
3753
4068
  default:
3754
4069
  return errorResult(`Unknown SAPWrite action: ${action}. Supported: create, update, delete, edit_method, batch_create, scaffold_rap_handlers, generate_behavior_implementation`);
@@ -3768,7 +4083,8 @@ function runRapPreflightValidation(source, type, name, features, configSystemTyp
3768
4083
  return { blocked: false };
3769
4084
  }
3770
4085
  const systemType = features?.systemType ?? (configSystemType !== 'auto' ? configSystemType : undefined);
3771
- const result = validateRapSource(type, source, {
4086
+ // Canonicalize so validateRapSource's 'TABL' case matches TABL/DT and TABL/DS.
4087
+ const result = validateRapSource(canonicalTablType(type), source, {
3772
4088
  systemType,
3773
4089
  abapRelease: features?.abapRelease,
3774
4090
  });
@@ -4022,7 +4338,12 @@ async function handleSAPActivate(client, args, cachingLayer) {
4022
4338
  const objName = String(o.name ?? '');
4023
4339
  let url;
4024
4340
  if (objType === 'TABL') {
4025
- url = await client.resolveTablObjectUrl(objName);
4341
+ // Use the write-path resolver: refuses TABL/DT activation on systems
4342
+ // that don't expose /sap/bc/adt/ddic/tables/ (NW 7.50/7.51), where
4343
+ // activate would hit the wrong endpoint. See issue #285.
4344
+ url = await client.resolveTablObjectUrlForWrite(objName, {
4345
+ tablesEndpointAvailable: isTablesEndpointAvailable(),
4346
+ });
4026
4347
  }
4027
4348
  else if (objType === 'FUNC') {
4028
4349
  let group = String(o.group ?? args.group ?? '').trim();
@@ -4064,15 +4385,26 @@ async function handleSAPActivate(client, args, cachingLayer) {
4064
4385
  .join('');
4065
4386
  return errorResult(`Batch activation failed for: ${names}.${statusDetails}\n${formatActivationMessages(result)}${combinedDiag}`);
4066
4387
  }
4067
- // Single activation (existing behavior). For TABL we resolve the URL because
4068
- // the existing object may live at /tables/ (transparent) or /structures/
4069
- // (DDIC structure); using the wrong one would produce a confusing 404.
4388
+ // Single activation (existing behavior). For TABL we use the write-path
4389
+ // resolver so transparent-table activations on NW 7.50/7.51 are refused
4390
+ // with the SE11 hint instead of silently activating against /structures/
4391
+ // (which would not even be the right object). See issue #285.
4070
4392
  // For FUNC the URL needs the parent function group baked into the path
4071
4393
  // (issue #250) — `objectBasePath('FUNC')` deliberately throws so generic
4072
4394
  // builders fail loudly. Auto-resolve the group when omitted.
4073
4395
  let objectUrl;
4074
4396
  if (type === 'TABL') {
4075
- objectUrl = await client.resolveTablObjectUrl(name);
4397
+ try {
4398
+ objectUrl = await client.resolveTablObjectUrlForWrite(name, {
4399
+ tablesEndpointAvailable: isTablesEndpointAvailable(),
4400
+ });
4401
+ }
4402
+ catch (resolveErr) {
4403
+ if (resolveErr instanceof AdtSafetyError) {
4404
+ return errorResult(resolveErr.message);
4405
+ }
4406
+ throw resolveErr;
4407
+ }
4076
4408
  }
4077
4409
  else if (type === 'FUNC') {
4078
4410
  let group = String(args.group ?? '').trim();