arc-1 0.9.5 → 0.9.7

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 (107) hide show
  1. package/README.md +21 -3
  2. package/dist/adt/abapgit.d.ts +2 -1
  3. package/dist/adt/abapgit.d.ts.map +1 -1
  4. package/dist/adt/abapgit.js +2 -2
  5. package/dist/adt/abapgit.js.map +1 -1
  6. package/dist/adt/btp.d.ts.map +1 -1
  7. package/dist/adt/btp.js +7 -3
  8. package/dist/adt/btp.js.map +1 -1
  9. package/dist/adt/class-structure.d.ts +176 -0
  10. package/dist/adt/class-structure.d.ts.map +1 -0
  11. package/dist/adt/class-structure.js +317 -0
  12. package/dist/adt/class-structure.js.map +1 -0
  13. package/dist/adt/client.d.ts +150 -8
  14. package/dist/adt/client.d.ts.map +1 -1
  15. package/dist/adt/client.js +345 -12
  16. package/dist/adt/client.js.map +1 -1
  17. package/dist/adt/config.d.ts +7 -1
  18. package/dist/adt/config.d.ts.map +1 -1
  19. package/dist/adt/config.js.map +1 -1
  20. package/dist/adt/crud.d.ts +38 -0
  21. package/dist/adt/crud.d.ts.map +1 -1
  22. package/dist/adt/crud.js +73 -1
  23. package/dist/adt/crud.js.map +1 -1
  24. package/dist/adt/errors.d.ts +2 -2
  25. package/dist/adt/errors.d.ts.map +1 -1
  26. package/dist/adt/errors.js +50 -6
  27. package/dist/adt/errors.js.map +1 -1
  28. package/dist/adt/features.d.ts.map +1 -1
  29. package/dist/adt/features.js +27 -3
  30. package/dist/adt/features.js.map +1 -1
  31. package/dist/adt/gcts.d.ts +3 -2
  32. package/dist/adt/gcts.d.ts.map +1 -1
  33. package/dist/adt/gcts.js +4 -4
  34. package/dist/adt/gcts.js.map +1 -1
  35. package/dist/adt/http.d.ts +41 -0
  36. package/dist/adt/http.d.ts.map +1 -1
  37. package/dist/adt/http.js +132 -45
  38. package/dist/adt/http.js.map +1 -1
  39. package/dist/adt/package-hierarchy.d.ts +67 -0
  40. package/dist/adt/package-hierarchy.d.ts.map +1 -0
  41. package/dist/adt/package-hierarchy.js +100 -0
  42. package/dist/adt/package-hierarchy.js.map +1 -0
  43. package/dist/adt/release.d.ts +35 -0
  44. package/dist/adt/release.d.ts.map +1 -0
  45. package/dist/adt/release.js +48 -0
  46. package/dist/adt/release.js.map +1 -0
  47. package/dist/adt/safety.d.ts +39 -3
  48. package/dist/adt/safety.d.ts.map +1 -1
  49. package/dist/adt/safety.js +136 -15
  50. package/dist/adt/safety.js.map +1 -1
  51. package/dist/adt/types.d.ts +74 -0
  52. package/dist/adt/types.d.ts.map +1 -1
  53. package/dist/adt/xml-parser.d.ts +68 -1
  54. package/dist/adt/xml-parser.d.ts.map +1 -1
  55. package/dist/adt/xml-parser.js +263 -0
  56. package/dist/adt/xml-parser.js.map +1 -1
  57. package/dist/authz/policy.d.ts.map +1 -1
  58. package/dist/authz/policy.js +12 -0
  59. package/dist/authz/policy.js.map +1 -1
  60. package/dist/context/grep.d.ts +48 -0
  61. package/dist/context/grep.d.ts.map +1 -0
  62. package/dist/context/grep.js +146 -0
  63. package/dist/context/grep.js.map +1 -0
  64. package/dist/handlers/intent.d.ts +2 -1
  65. package/dist/handlers/intent.d.ts.map +1 -1
  66. package/dist/handlers/intent.js +614 -50
  67. package/dist/handlers/intent.js.map +1 -1
  68. package/dist/handlers/schemas.d.ts +52 -6
  69. package/dist/handlers/schemas.d.ts.map +1 -1
  70. package/dist/handlers/schemas.js +90 -9
  71. package/dist/handlers/schemas.js.map +1 -1
  72. package/dist/handlers/tools.d.ts.map +1 -1
  73. package/dist/handlers/tools.js +73 -12
  74. package/dist/handlers/tools.js.map +1 -1
  75. package/dist/lint/lint.d.ts.map +1 -1
  76. package/dist/lint/lint.js +6 -0
  77. package/dist/lint/lint.js.map +1 -1
  78. package/dist/lint/pre-write-hints.d.ts +45 -0
  79. package/dist/lint/pre-write-hints.d.ts.map +1 -0
  80. package/dist/lint/pre-write-hints.js +145 -0
  81. package/dist/lint/pre-write-hints.js.map +1 -0
  82. package/dist/server/audit.d.ts +27 -1
  83. package/dist/server/audit.d.ts.map +1 -1
  84. package/dist/server/audit.js.map +1 -1
  85. package/dist/server/auth-rate-limit.d.ts +78 -0
  86. package/dist/server/auth-rate-limit.d.ts.map +1 -0
  87. package/dist/server/auth-rate-limit.js +95 -0
  88. package/dist/server/auth-rate-limit.js.map +1 -0
  89. package/dist/server/config.d.ts.map +1 -1
  90. package/dist/server/config.js +32 -0
  91. package/dist/server/config.js.map +1 -1
  92. package/dist/server/http.d.ts.map +1 -1
  93. package/dist/server/http.js +73 -2
  94. package/dist/server/http.js.map +1 -1
  95. package/dist/server/mcp-rate-limit.d.ts +69 -0
  96. package/dist/server/mcp-rate-limit.d.ts.map +1 -0
  97. package/dist/server/mcp-rate-limit.js +92 -0
  98. package/dist/server/mcp-rate-limit.js.map +1 -0
  99. package/dist/server/server.d.ts +26 -6
  100. package/dist/server/server.d.ts.map +1 -1
  101. package/dist/server/server.js +87 -28
  102. package/dist/server/server.js.map +1 -1
  103. package/dist/server/types.d.ts +20 -1
  104. package/dist/server/types.d.ts.map +1 -1
  105. package/dist/server/types.js +2 -0
  106. package/dist/server/types.js.map +1 -1
  107. package/package.json +14 -12
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Layer 2 — Per-user MCP tool-call rate limiter.
3
+ *
4
+ * Applied at the top of `handleToolCall` in `src/handlers/intent.ts`. Returns an MCP
5
+ * tool error (NOT HTTP 429) on denial so the LLM client surfaces it as a tool failure
6
+ * and the agent loop backs off correctly. Per-user token bucket keyed on the resolved
7
+ * user identity (userName / clientId / __anon__).
8
+ *
9
+ * Design choices:
10
+ * - Per-instance, in-memory only. Multi-instance attackers cost `limit × instances` —
11
+ * acceptable trade-off, matches stateless-DCR philosophy from PR #212.
12
+ * - Stdio mode is exempt because there's no authInfo to key on; the caller is
13
+ * responsible for skipping the consume in that case.
14
+ * - When `perMinute === 0`, the factory returns a stub whose `consume` resolves
15
+ * immediately with `{ allowed: true }` — no allocation, no per-key bookkeeping.
16
+ * This is the clean opt-out for single-user deployments.
17
+ * - Cost weighting per tool is intentionally deferred to v2 — every consume call is
18
+ * one point. See ADR-0004 for the rationale.
19
+ */
20
+ import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
21
+ export type RateLimitDecision = {
22
+ allowed: true;
23
+ } | {
24
+ allowed: false;
25
+ retryAfterMs: number;
26
+ limitPerMinute: number;
27
+ };
28
+ /**
29
+ * Resolve the per-user rate-limit key from an `AuthInfo`, walking the most-
30
+ * specific identity claims first so distinct users never share a quota when
31
+ * they share an auth client / application.
32
+ *
33
+ * Order, by descending specificity:
34
+ * 1. `extra.userName` — XSUAA logon name (`securityContext.getLogonName()`)
35
+ * 2. `extra.email` — XSUAA / OIDC email when populated
36
+ * 3. `extra.sub` — OIDC subject claim (guaranteed unique per user within issuer)
37
+ * 4. `extra.preferred_username` — sometimes set on OIDC tokens
38
+ * 5. `clientId` — last resort. Note for OIDC this is `azp`
39
+ * (the app's client id), shared by all users of that app — so falling here
40
+ * collapses them into one bucket. The earlier checks exist specifically
41
+ * to avoid that. Acceptable only for the API-key path where the clientId
42
+ * is `api-key:<profile>` and the operator has chosen the profile granularity.
43
+ * 6. `'__anon__'` — token with no usable identity claim. Single
44
+ * shared bucket for anonymous traffic. Operators should configure auth so
45
+ * this branch is never reached in production.
46
+ *
47
+ * Why not just `sub`? Because XSUAA tokens don't put `sub` on `extra`; they put
48
+ * the SAP logon name on `extra.userName`. OIDC does the inverse. We accept both
49
+ * shapes rather than forcing every auth provider to align on one claim.
50
+ */
51
+ export declare function resolveRateLimitUserKey(authInfo: AuthInfo | undefined): string;
52
+ export interface McpRateLimiter {
53
+ /**
54
+ * Try to consume one point for `userKey`. Resolves `{ allowed: true }` when the
55
+ * bucket has tokens, `{ allowed: false, retryAfterMs, limitPerMinute }` when it
56
+ * doesn't. Never throws — internal RateLimiterRes rejection is caught here.
57
+ *
58
+ * `tool` is recorded for the audit event at the call site; it doesn't affect
59
+ * the bucket.
60
+ */
61
+ consume(userKey: string, tool: string): Promise<RateLimitDecision>;
62
+ }
63
+ /**
64
+ * Build a per-user MCP rate limiter.
65
+ *
66
+ * @param perMinute Per-user requests per minute. `0` returns a no-op stub.
67
+ */
68
+ export declare function createMcpRateLimiter(perMinute: number): McpRateLimiter;
69
+ //# sourceMappingURL=mcp-rate-limit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mcp-rate-limit.d.ts","sourceRoot":"","sources":["../../src/server/mcp-rate-limit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gDAAgD,CAAC;AAG/E,MAAM,MAAM,iBAAiB,GAAG;IAAE,OAAO,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,KAAK,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC;IAAC,cAAc,EAAE,MAAM,CAAA;CAAE,CAAC;AAErH;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,QAAQ,GAAG,SAAS,GAAG,MAAM,CAa9E;AAED,MAAM,WAAW,cAAc;IAC7B;;;;;;;OAOG;IACH,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;CACpE;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,GAAG,cAAc,CAgCtE"}
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Layer 2 — Per-user MCP tool-call rate limiter.
3
+ *
4
+ * Applied at the top of `handleToolCall` in `src/handlers/intent.ts`. Returns an MCP
5
+ * tool error (NOT HTTP 429) on denial so the LLM client surfaces it as a tool failure
6
+ * and the agent loop backs off correctly. Per-user token bucket keyed on the resolved
7
+ * user identity (userName / clientId / __anon__).
8
+ *
9
+ * Design choices:
10
+ * - Per-instance, in-memory only. Multi-instance attackers cost `limit × instances` —
11
+ * acceptable trade-off, matches stateless-DCR philosophy from PR #212.
12
+ * - Stdio mode is exempt because there's no authInfo to key on; the caller is
13
+ * responsible for skipping the consume in that case.
14
+ * - When `perMinute === 0`, the factory returns a stub whose `consume` resolves
15
+ * immediately with `{ allowed: true }` — no allocation, no per-key bookkeeping.
16
+ * This is the clean opt-out for single-user deployments.
17
+ * - Cost weighting per tool is intentionally deferred to v2 — every consume call is
18
+ * one point. See ADR-0004 for the rationale.
19
+ */
20
+ import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible';
21
+ /**
22
+ * Resolve the per-user rate-limit key from an `AuthInfo`, walking the most-
23
+ * specific identity claims first so distinct users never share a quota when
24
+ * they share an auth client / application.
25
+ *
26
+ * Order, by descending specificity:
27
+ * 1. `extra.userName` — XSUAA logon name (`securityContext.getLogonName()`)
28
+ * 2. `extra.email` — XSUAA / OIDC email when populated
29
+ * 3. `extra.sub` — OIDC subject claim (guaranteed unique per user within issuer)
30
+ * 4. `extra.preferred_username` — sometimes set on OIDC tokens
31
+ * 5. `clientId` — last resort. Note for OIDC this is `azp`
32
+ * (the app's client id), shared by all users of that app — so falling here
33
+ * collapses them into one bucket. The earlier checks exist specifically
34
+ * to avoid that. Acceptable only for the API-key path where the clientId
35
+ * is `api-key:<profile>` and the operator has chosen the profile granularity.
36
+ * 6. `'__anon__'` — token with no usable identity claim. Single
37
+ * shared bucket for anonymous traffic. Operators should configure auth so
38
+ * this branch is never reached in production.
39
+ *
40
+ * Why not just `sub`? Because XSUAA tokens don't put `sub` on `extra`; they put
41
+ * the SAP logon name on `extra.userName`. OIDC does the inverse. We accept both
42
+ * shapes rather than forcing every auth provider to align on one claim.
43
+ */
44
+ export function resolveRateLimitUserKey(authInfo) {
45
+ if (!authInfo)
46
+ return '__anon__';
47
+ const extra = (authInfo.extra ?? {});
48
+ const candidates = [extra.userName, extra.email, extra.sub, extra.preferred_username, authInfo.clientId];
49
+ for (const c of candidates) {
50
+ if (typeof c === 'string' && c.length > 0)
51
+ return c;
52
+ }
53
+ return '__anon__';
54
+ }
55
+ /**
56
+ * Build a per-user MCP rate limiter.
57
+ *
58
+ * @param perMinute Per-user requests per minute. `0` returns a no-op stub.
59
+ */
60
+ export function createMcpRateLimiter(perMinute) {
61
+ if (perMinute === 0) {
62
+ return {
63
+ async consume(_userKey, _tool) {
64
+ return { allowed: true };
65
+ },
66
+ };
67
+ }
68
+ const limiter = new RateLimiterMemory({ points: perMinute, duration: 60 });
69
+ return {
70
+ async consume(userKey, _tool) {
71
+ try {
72
+ await limiter.consume(userKey, 1);
73
+ return { allowed: true };
74
+ }
75
+ catch (rejected) {
76
+ // RateLimiterRes is thrown on overflow; anything else is unexpected.
77
+ if (rejected instanceof RateLimiterRes) {
78
+ return {
79
+ allowed: false,
80
+ retryAfterMs: rejected.msBeforeNext,
81
+ limitPerMinute: perMinute,
82
+ };
83
+ }
84
+ // Defensive: treat unexpected errors as "allowed" so a misbehaving limiter
85
+ // can never wedge legitimate traffic. The exception itself bubbles up via
86
+ // logging when the limiter is fixed; in the meantime users still get through.
87
+ return { allowed: true };
88
+ }
89
+ },
90
+ };
91
+ }
92
+ //# sourceMappingURL=mcp-rate-limit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mcp-rate-limit.js","sourceRoot":"","sources":["../../src/server/mcp-rate-limit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAGH,OAAO,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAI1E;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,UAAU,uBAAuB,CAAC,QAA8B;IACpE,IAAI,CAAC,QAAQ;QAAE,OAAO,UAAU,CAAC;IACjC,MAAM,KAAK,GAAG,CAAC,QAAQ,CAAC,KAAK,IAAI,EAAE,CAKlC,CAAC;IACF,MAAM,UAAU,GAAG,CAAC,KAAK,CAAC,QAAQ,EAAE,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,GAAG,EAAE,KAAK,CAAC,kBAAkB,EAAE,QAAQ,CAAC,QAAQ,CAAC,CAAC;IACzG,KAAK,MAAM,CAAC,IAAI,UAAU,EAAE,CAAC;QAC3B,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC;YAAE,OAAO,CAAC,CAAC;IACtD,CAAC;IACD,OAAO,UAAU,CAAC;AACpB,CAAC;AAcD;;;;GAIG;AACH,MAAM,UAAU,oBAAoB,CAAC,SAAiB;IACpD,IAAI,SAAS,KAAK,CAAC,EAAE,CAAC;QACpB,OAAO;YACL,KAAK,CAAC,OAAO,CAAC,QAAgB,EAAE,KAAa;gBAC3C,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;YAC3B,CAAC;SACF,CAAC;IACJ,CAAC;IAED,MAAM,OAAO,GAAG,IAAI,iBAAiB,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,EAAE,EAAE,CAAC,CAAC;IAE3E,OAAO;QACL,KAAK,CAAC,OAAO,CAAC,OAAe,EAAE,KAAa;YAC1C,IAAI,CAAC;gBACH,MAAM,OAAO,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC;gBAClC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;YAC3B,CAAC;YAAC,OAAO,QAAQ,EAAE,CAAC;gBAClB,qEAAqE;gBACrE,IAAI,QAAQ,YAAY,cAAc,EAAE,CAAC;oBACvC,OAAO;wBACL,OAAO,EAAE,KAAK;wBACd,YAAY,EAAE,QAAQ,CAAC,YAAY;wBACnC,cAAc,EAAE,SAAS;qBAC1B,CAAC;gBACJ,CAAC;gBACD,2EAA2E;gBAC3E,0EAA0E;gBAC1E,8EAA8E;gBAC9E,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;YAC3B,CAAC;QACH,CAAC;KACF,CAAC;AACJ,CAAC"}
@@ -7,13 +7,15 @@
7
7
  * - http-streamable: for remote/containerized deployments
8
8
  */
9
9
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
10
- import type { BTPConfig, BTPProxyConfig } from '../adt/btp.js';
10
+ import type { BTPConfig, BTPProxyConfig, PerUserAuthTokens } from '../adt/btp.js';
11
11
  import type { AdtClientConfig } from '../adt/config.js';
12
+ import { Semaphore } from '../adt/semaphore.js';
12
13
  import { CachingLayer } from '../cache/caching-layer.js';
13
14
  import { type ToolDefinition } from '../handlers/tools.js';
15
+ import { type McpRateLimiter } from './mcp-rate-limit.js';
14
16
  import type { ServerConfig } from './types.js';
15
17
  /** ARC-1 version */
16
- export declare const VERSION = "0.9.5";
18
+ export declare const VERSION = "0.9.7";
17
19
  /**
18
20
  * Filter tools by user scope + server deny list.
19
21
  *
@@ -27,7 +29,25 @@ export declare function logAuthSummary(config: ServerConfig): void;
27
29
  /** Build the base ADT client config (without per-user auth) */
28
30
  export declare function buildAdtConfig(config: ServerConfig, btpProxy?: BTPProxyConfig, bearerTokenProvider?: () => Promise<string>, opts?: {
29
31
  perUser?: boolean;
30
- }): Partial<AdtClientConfig>;
32
+ }, adtSemaphore?: Semaphore): Partial<AdtClientConfig>;
33
+ /**
34
+ * Map per-user auth tokens from the BTP Destination Service onto an AdtClientConfig.
35
+ * Mutates and returns `adtConfig`. Exported for unit testing.
36
+ *
37
+ * Precedence (most-specific first):
38
+ * 1. ppProxyAuth — Option 1: jwt-bearer exchanged token → Proxy-Authorization (Cloud Connector)
39
+ * 2. sapConnectivityAuth — Option 2: SAML assertion → SAP-Connectivity-Authentication (Cloud Connector)
40
+ * 3. bearerToken — OAuth2UserTokenExchange / OAuth2SAMLBearerAssertion: a user-context Bearer
41
+ * token minted at the target's XSUAA → `Authorization: Bearer` (cloud-to-cloud,
42
+ * e.g. a BTP ABAP Environment over the Internet — no Cloud Connector / proxy).
43
+ *
44
+ * Throws when none is present (PP could not produce a usable per-user credential).
45
+ *
46
+ * In every success branch the SAP password is cleared and `username` is set to a display-only
47
+ * value — it is never used for auth or access control; the real SAP identity rides in the
48
+ * chosen token/assertion.
49
+ */
50
+ export declare function applyPerUserAuthTokens(adtConfig: Partial<AdtClientConfig>, authTokens: PerUserAuthTokens, displayUsername: string | undefined, destName: string): Partial<AdtClientConfig>;
31
51
  /**
32
52
  * Run a one-time feature probe against the SAP system using the shared/default client.
33
53
  * Returns a promise that resolves once probe results are stored in cachedFeatures.
@@ -36,7 +56,7 @@ export declare function buildAdtConfig(config: ServerConfig, btpProxy?: BTPProxy
36
56
  * source_code from users who might have authorization. Without btpConfig, PP cannot
37
57
  * create per-user clients, so shared-client auth failures are definitive.
38
58
  */
39
- export declare function runStartupProbe(config: ServerConfig, btpProxy?: BTPProxyConfig, bearerTokenProvider?: () => Promise<string>, btpConfig?: BTPConfig): Promise<void>;
59
+ export declare function runStartupProbe(config: ServerConfig, btpProxy?: BTPProxyConfig, bearerTokenProvider?: () => Promise<string>, btpConfig?: BTPConfig, adtSemaphore?: Semaphore): Promise<void>;
40
60
  export interface StartupAuthPreflightResult {
41
61
  status: 'ok' | 'failed' | 'inconclusive' | 'skipped';
42
62
  /** When true, shared-client SAP tool calls must be blocked to prevent repeated auth failures. */
@@ -58,7 +78,7 @@ export interface StartupAuthPreflightResult {
58
78
  * - 401/403 are blocking failures
59
79
  * - Network/other failures are inconclusive (non-blocking)
60
80
  */
61
- export declare function runStartupAuthPreflight(config: ServerConfig, btpProxy?: BTPProxyConfig, bearerTokenProvider?: () => Promise<string>): Promise<StartupAuthPreflightResult>;
81
+ export declare function runStartupAuthPreflight(config: ServerConfig, btpProxy?: BTPProxyConfig, bearerTokenProvider?: () => Promise<string>, adtSemaphore?: Semaphore): Promise<StartupAuthPreflightResult>;
62
82
  export declare function formatStartupAuthPreflightToolError(preflight: StartupAuthPreflightResult): string;
63
83
  /**
64
84
  * Create the MCP server with registered tool handlers.
@@ -70,7 +90,7 @@ export declare function formatStartupAuthPreflightToolError(preflight: StartupAu
70
90
  * @param startupProbePromise Promise from runStartupProbe() — ListTools waits on this
71
91
  * @param startupAuthPreflightPromise Promise from runStartupAuthPreflight() — CallTool blocks on auth failure in shared mode
72
92
  */
73
- export declare function createServer(config: ServerConfig, btpProxy?: BTPProxyConfig, btpConfig?: BTPConfig, bearerTokenProvider?: () => Promise<string>, cachingLayer?: CachingLayer, startupProbePromise?: Promise<void>, startupAuthPreflightPromise?: Promise<StartupAuthPreflightResult>): Server;
93
+ export declare function createServer(config: ServerConfig, btpProxy?: BTPProxyConfig, btpConfig?: BTPConfig, bearerTokenProvider?: () => Promise<string>, cachingLayer?: CachingLayer, startupProbePromise?: Promise<void>, startupAuthPreflightPromise?: Promise<StartupAuthPreflightResult>, adtSemaphore?: Semaphore, mcpRateLimiter?: McpRateLimiter): Server;
74
94
  /**
75
95
  * Create and start the MCP server.
76
96
  */
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/server/server.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AAGnE,OAAO,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAE/D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAMxD,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AASzD,OAAO,EAAsB,KAAK,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAK/E,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE/C,oBAAoB;AACpB,eAAO,MAAM,OAAO,UAAU,CAAC;AAuD/B;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,cAAc,EAAE,EACvB,MAAM,EAAE,MAAM,EAAE,EAChB,WAAW,GAAE,MAAM,EAAO,GACzB,cAAc,EAAE,CA0BlB;AAED,wBAAgB,cAAc,CAAC,MAAM,EAAE,YAAY,GAAG,IAAI,CAgCzD;AAED,+DAA+D;AAG/D,wBAAgB,cAAc,CAC5B,MAAM,EAAE,YAAY,EACpB,QAAQ,CAAC,EAAE,cAAc,EACzB,mBAAmB,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,EAC3C,IAAI,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,GAC3B,OAAO,CAAC,eAAe,CAAC,CAkC1B;AAqFD;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAC7B,MAAM,EAAE,YAAY,EACpB,QAAQ,CAAC,EAAE,cAAc,EACzB,mBAAmB,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,EAC3C,SAAS,CAAC,EAAE,SAAS,GACpB,OAAO,CAAC,IAAI,CAAC,CA8Cf;AAED,MAAM,WAAW,0BAA0B;IACzC,MAAM,EAAE,IAAI,GAAG,QAAQ,GAAG,cAAc,GAAG,SAAS,CAAC;IACrD,iGAAiG;IACjG,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;CAChB;AAqCD;;;;;;;;;;;GAWG;AACH,wBAAsB,uBAAuB,CAC3C,MAAM,EAAE,YAAY,EACpB,QAAQ,CAAC,EAAE,cAAc,EACzB,mBAAmB,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,GAC1C,OAAO,CAAC,0BAA0B,CAAC,CAmDrC;AAED,wBAAgB,mCAAmC,CAAC,SAAS,EAAE,0BAA0B,GAAG,MAAM,CASjG;AAED;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAC1B,MAAM,EAAE,YAAY,EACpB,QAAQ,CAAC,EAAE,cAAc,EACzB,SAAS,CAAC,EAAE,SAAS,EACrB,mBAAmB,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,EAC3C,YAAY,CAAC,EAAE,YAAY,EAC3B,mBAAmB,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,EACnC,2BAA2B,CAAC,EAAE,OAAO,CAAC,0BAA0B,CAAC,GAChE,MAAM,CA4JR;AAoCD;;GAEG;AACH,wBAAsB,oBAAoB,CACxC,MAAM,EAAE,YAAY,EACpB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,YAAY,EAAE,YAAY,CAAC,GAC1D,OAAO,CAAC,MAAM,CAAC,CA4QjB"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/server/server.ts"],"names":[],"mappings":"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"}
@@ -12,7 +12,9 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
12
12
  import { AdtClient } from '../adt/client.js';
13
13
  import { resolveCookies } from '../adt/cookies.js';
14
14
  import { AdtApiError } from '../adt/errors.js';
15
+ import { shouldWarnPreStatefulRelease } from '../adt/release.js';
15
16
  import { deriveUserSafety, deriveUserSafetyFromProfile } from '../adt/safety.js';
17
+ import { Semaphore } from '../adt/semaphore.js';
16
18
  import { getActionPolicy, hasRequiredScope } from '../authz/policy.js';
17
19
  import { CachingLayer } from '../cache/caching-layer.js';
18
20
  import { MemoryCache } from '../cache/memory.js';
@@ -21,9 +23,10 @@ import { getToolDefinitions } from '../handlers/tools.js';
21
23
  import { API_KEY_PROFILES } from './config.js';
22
24
  import { isActionDenied } from './deny-actions.js';
23
25
  import { initLogger, logger } from './logger.js';
26
+ import { createMcpRateLimiter } from './mcp-rate-limit.js';
24
27
  import { FileSink } from './sinks/file.js';
25
28
  /** ARC-1 version */
26
- export const VERSION = '0.9.5'; // x-release-please-version
29
+ export const VERSION = '0.9.7'; // x-release-please-version
27
30
  /**
28
31
  * Prune a tool's action OR type enum (or both) based on the user's scopes and
29
32
  * the server's denyActions list. Uses ACTION_POLICY as the single source of truth.
@@ -159,7 +162,11 @@ export function logAuthSummary(config) {
159
162
  /** Build the base ADT client config (without per-user auth) */
160
163
  // When perUser=true, strips shared credentials (username/password/cookies)
161
164
  // so per-user PP clients never inherit admin auth.
162
- export function buildAdtConfig(config, btpProxy, bearerTokenProvider, opts) {
165
+ //
166
+ // adtSemaphore (Layer 3): when provided, the constructed AdtClient shares this single
167
+ // server-wide semaphore with every other client built from this server. This is what
168
+ // makes ARC1_MAX_CONCURRENT a true server-wide cap rather than per-client.
169
+ export function buildAdtConfig(config, btpProxy, bearerTokenProvider, opts, adtSemaphore) {
163
170
  const adtConfig = {
164
171
  baseUrl: config.url,
165
172
  client: config.client,
@@ -169,6 +176,7 @@ export function buildAdtConfig(config, btpProxy, bearerTokenProvider, opts) {
169
176
  btpProxy,
170
177
  bearerTokenProvider,
171
178
  maxConcurrent: config.maxConcurrent,
179
+ adtSemaphore,
172
180
  safety: {
173
181
  allowWrites: config.allowWrites,
174
182
  allowDataPreview: config.allowDataPreview,
@@ -203,7 +211,7 @@ export function buildAdtConfig(config, btpProxy, bearerTokenProvider, opts) {
203
211
  * The Cloud Connector uses this header to generate an X.509 cert
204
212
  * mapped to the SAP user via CERTRULE.
205
213
  */
206
- async function createPerUserClient(config, btpConfig, btpProxy, userJwt) {
214
+ async function createPerUserClient(config, btpConfig, btpProxy, userJwt, adtSemaphore) {
207
215
  const { lookupDestinationWithUserToken } = await import('../adt/btp.js');
208
216
  // Use SAP_BTP_PP_DESTINATION if set, otherwise fall back to SAP_BTP_DESTINATION.
209
217
  // This enables a dual-destination approach:
@@ -222,7 +230,7 @@ async function createPerUserClient(config, btpConfig, btpProxy, userJwt) {
222
230
  const effectiveProxy = btpProxy && destination.CloudConnectorLocationId !== undefined
223
231
  ? { ...btpProxy, locationId: destination.CloudConnectorLocationId }
224
232
  : btpProxy;
225
- const adtConfig = buildAdtConfig(config, effectiveProxy, undefined, { perUser: true });
233
+ const adtConfig = buildAdtConfig(config, effectiveProxy, undefined, { perUser: true }, adtSemaphore);
226
234
  // Override URL from destination (in case it differs from startup-resolved URL)
227
235
  adtConfig.baseUrl = destination.URL;
228
236
  // Set per-user auth for principal propagation.
@@ -240,22 +248,42 @@ async function createPerUserClient(config, btpConfig, btpProxy, userJwt) {
240
248
  catch {
241
249
  displayUsername = undefined;
242
250
  }
251
+ applyPerUserAuthTokens(adtConfig, authTokens, displayUsername, destName);
252
+ return new AdtClient(adtConfig);
253
+ }
254
+ /**
255
+ * Map per-user auth tokens from the BTP Destination Service onto an AdtClientConfig.
256
+ * Mutates and returns `adtConfig`. Exported for unit testing.
257
+ *
258
+ * Precedence (most-specific first):
259
+ * 1. ppProxyAuth — Option 1: jwt-bearer exchanged token → Proxy-Authorization (Cloud Connector)
260
+ * 2. sapConnectivityAuth — Option 2: SAML assertion → SAP-Connectivity-Authentication (Cloud Connector)
261
+ * 3. bearerToken — OAuth2UserTokenExchange / OAuth2SAMLBearerAssertion: a user-context Bearer
262
+ * token minted at the target's XSUAA → `Authorization: Bearer` (cloud-to-cloud,
263
+ * e.g. a BTP ABAP Environment over the Internet — no Cloud Connector / proxy).
264
+ *
265
+ * Throws when none is present (PP could not produce a usable per-user credential).
266
+ *
267
+ * In every success branch the SAP password is cleared and `username` is set to a display-only
268
+ * value — it is never used for auth or access control; the real SAP identity rides in the
269
+ * chosen token/assertion.
270
+ */
271
+ export function applyPerUserAuthTokens(adtConfig, authTokens, displayUsername, destName) {
243
272
  if (authTokens.ppProxyAuth) {
244
- // Option 1: exchanged token replaces Proxy-Authorization
245
273
  adtConfig.ppProxyAuth = authTokens.ppProxyAuth;
246
- adtConfig.username = displayUsername;
247
- adtConfig.password = undefined;
248
274
  }
249
275
  else if (authTokens.sapConnectivityAuth) {
250
- // Option 2: SAML assertion from Destination Service
251
276
  adtConfig.sapConnectivityAuth = authTokens.sapConnectivityAuth;
252
- adtConfig.username = displayUsername;
253
- adtConfig.password = undefined;
254
277
  }
255
278
  else if (authTokens.bearerToken) {
256
- // TODO: Bearer token auth for OAuth2SAMLBearerAssertion destinations
257
- // This would replace basic auth with Bearer token
258
- logger.warn('Bearer token auth from destination not yet implemented — falling back to basic auth');
279
+ // createPerUserClient runs per request and the Cloud SDK caches the exchanged token per
280
+ // user (TTL-bounded), so a provider returning the already-resolved token is fresh for the
281
+ // request's lifetime.
282
+ const bearer = authTokens.bearerToken;
283
+ adtConfig.bearerTokenProvider = async () => bearer;
284
+ logger.debug('PP: using destination-exchanged Bearer token (OAuth2UserTokenExchange)', {
285
+ destination: destName,
286
+ });
259
287
  }
260
288
  else {
261
289
  // No per-user auth token received.
@@ -263,7 +291,9 @@ async function createPerUserClient(config, btpConfig, btpProxy, userJwt) {
263
291
  'no SAP-Connectivity-Authentication header, Bearer token, or jwt-bearer exchange token returned. ' +
264
292
  'Check Cloud Connector status, destination configuration, and user JWT validity.');
265
293
  }
266
- return new AdtClient(adtConfig);
294
+ adtConfig.username = displayUsername;
295
+ adtConfig.password = undefined;
296
+ return adtConfig;
267
297
  }
268
298
  /**
269
299
  * Run a one-time feature probe against the SAP system using the shared/default client.
@@ -273,8 +303,8 @@ async function createPerUserClient(config, btpConfig, btpProxy, userJwt) {
273
303
  * source_code from users who might have authorization. Without btpConfig, PP cannot
274
304
  * create per-user clients, so shared-client auth failures are definitive.
275
305
  */
276
- export function runStartupProbe(config, btpProxy, bearerTokenProvider, btpConfig) {
277
- const client = new AdtClient(buildAdtConfig(config, btpProxy, bearerTokenProvider));
306
+ export function runStartupProbe(config, btpProxy, bearerTokenProvider, btpConfig, adtSemaphore) {
307
+ const client = new AdtClient(buildAdtConfig(config, btpProxy, bearerTokenProvider, undefined, adtSemaphore));
278
308
  return (async () => {
279
309
  try {
280
310
  const { defaultFeatureConfig } = await import('../adt/config.js');
@@ -313,6 +343,17 @@ export function runStartupProbe(config, btpProxy, bearerTokenProvider, btpConfig
313
343
  }
314
344
  }
315
345
  setCachedFeatures(features);
346
+ // Proactive warning: on SAP_BASIS < 7.51 the ADT REST handler does not honor the
347
+ // stateful-session header over HTTP, so object writes fail with 423 "invalid lock
348
+ // handle" until the abapfs_extensions enhancement is installed. Warn at startup —
349
+ // before the first cryptic 423 — but only when writes are enabled (issue #293).
350
+ if (shouldWarnPreStatefulRelease(config.allowWrites, features.abapRelease)) {
351
+ logger.warn(`SAP_BASIS ${features.abapRelease} is below 7.51 and does not natively honor stateful ADT ` +
352
+ 'HTTP sessions — object writes will fail with 423 "invalid lock handle" UNLESS the ' +
353
+ 'abapfs_extensions enhancement is installed on the SAP system ' +
354
+ '(https://github.com/marcellourbani/abapfs_extensions). If writes already work, this is ' +
355
+ 'installed and you can ignore this. See docs/sap-trial-setup.md (423 troubleshooting).');
356
+ }
316
357
  setCachedDiscovery(features.discoveryMap ?? new Map());
317
358
  }
318
359
  catch {
@@ -358,7 +399,7 @@ function buildStartupAuthFailureReason(statusCode, config) {
358
399
  * - 401/403 are blocking failures
359
400
  * - Network/other failures are inconclusive (non-blocking)
360
401
  */
361
- export async function runStartupAuthPreflight(config, btpProxy, bearerTokenProvider) {
402
+ export async function runStartupAuthPreflight(config, btpProxy, bearerTokenProvider, adtSemaphore) {
362
403
  const checkedAt = new Date().toISOString();
363
404
  const endpoint = STARTUP_AUTH_ENDPOINT;
364
405
  if (config.ppEnabled) {
@@ -372,7 +413,7 @@ export async function runStartupAuthPreflight(config, btpProxy, bearerTokenProvi
372
413
  return { status: 'skipped', blocking: false, endpoint, checkedAt, reason };
373
414
  }
374
415
  try {
375
- const client = new AdtClient(buildAdtConfig(config, btpProxy, bearerTokenProvider));
416
+ const client = new AdtClient(buildAdtConfig(config, btpProxy, bearerTokenProvider, undefined, adtSemaphore));
376
417
  await client.http.get(endpoint);
377
418
  const reason = 'Startup auth preflight succeeded for shared SAP credentials.';
378
419
  logger.info(reason, { endpoint });
@@ -424,10 +465,12 @@ export function formatStartupAuthPreflightToolError(preflight) {
424
465
  * @param startupProbePromise Promise from runStartupProbe() — ListTools waits on this
425
466
  * @param startupAuthPreflightPromise Promise from runStartupAuthPreflight() — CallTool blocks on auth failure in shared mode
426
467
  */
427
- export function createServer(config, btpProxy, btpConfig, bearerTokenProvider, cachingLayer, startupProbePromise, startupAuthPreflightPromise) {
468
+ export function createServer(config, btpProxy, btpConfig, bearerTokenProvider, cachingLayer, startupProbePromise, startupAuthPreflightPromise, adtSemaphore, mcpRateLimiter) {
428
469
  const server = new Server({ name: 'arc-1', version: VERSION }, { capabilities: { tools: {} } });
429
- // Create default ADT client (shared, uses startup-time credentials or OAuth bearer)
430
- const defaultClient = new AdtClient(buildAdtConfig(config, btpProxy, bearerTokenProvider));
470
+ // Create default ADT client (shared, uses startup-time credentials or OAuth bearer).
471
+ // Passes the shared server-wide semaphore so per-user PP clients (created at request
472
+ // time) share the same Layer 3 concurrency cap.
473
+ const defaultClient = new AdtClient(buildAdtConfig(config, btpProxy, bearerTokenProvider, undefined, adtSemaphore));
431
474
  // Cookie-auth preflight propagation: when startup preflight returned a non-blocking
432
475
  // 401 in SAP_COOKIE_FILE mode, the throwaway preflight client marked itself stale —
433
476
  // but the long-lived defaultClient was constructed independently with cookies read at
@@ -487,7 +530,7 @@ export function createServer(config, btpProxy, btpConfig, bearerTokenProvider, c
487
530
  const ppUser = (extra.authInfo?.extra?.userName ?? extra.authInfo?.clientId);
488
531
  const ppDest = process.env.SAP_BTP_PP_DESTINATION ?? process.env.SAP_BTP_DESTINATION ?? '';
489
532
  try {
490
- client = await createPerUserClient(config, btpConfig, btpProxy, token);
533
+ client = await createPerUserClient(config, btpConfig, btpProxy, token, adtSemaphore);
491
534
  isPerUserClient = true;
492
535
  logger.emitAudit({
493
536
  timestamp: new Date().toISOString(),
@@ -558,7 +601,7 @@ export function createServer(config, btpProxy, btpConfig, bearerTokenProvider, c
558
601
  effectiveClient = client.withSafety(effectiveSafety);
559
602
  }
560
603
  effectiveClient.http.setDiscoveryMap(getCachedDiscovery());
561
- const result = await handleToolCall(effectiveClient, config, toolName, args, extra.authInfo, server, cachingLayer, isPerUserClient);
604
+ const result = await handleToolCall(effectiveClient, config, toolName, args, extra.authInfo, server, cachingLayer, isPerUserClient, mcpRateLimiter);
562
605
  return { ...result };
563
606
  });
564
607
  return server;
@@ -705,6 +748,22 @@ export async function createAndStartServer(config, sources) {
705
748
  ppEnabled: config.ppEnabled,
706
749
  });
707
750
  }
751
+ // ─── Layer 3: shared SAP-bound Semaphore (server-wide cap) ────────
752
+ // One Semaphore for the whole process. Threaded into the shared startup client AND
753
+ // every per-user PP client built at request time, so ARC1_MAX_CONCURRENT is a true
754
+ // server-wide ceiling rather than a per-client one (the latter would multiply the cap
755
+ // by the number of active PP users — see ADR-0004).
756
+ const adtSemaphore = new Semaphore(config.maxConcurrent);
757
+ logger.info('SAP semaphore', { maxConcurrent: config.maxConcurrent, scope: 'server-wide' });
758
+ // ─── Layer 2: per-user MCP tool-call rate limiter ─────────────────
759
+ // Applied inside handleToolCall. Stdio (no authInfo) is exempt — there's no user
760
+ // identity to key on. When rateLimit=0 the factory returns a no-op stub.
761
+ // See docs_page/rate-limiting.md.
762
+ const mcpRateLimiter = createMcpRateLimiter(config.rateLimit);
763
+ logger.info('MCP rate limiting', {
764
+ perMinute: config.rateLimit,
765
+ disabled: config.rateLimit === 0,
766
+ });
708
767
  // ─── Cache Setup ───────────────────────────────────────────────────
709
768
  const cachingLayer = await createCachingLayer(config);
710
769
  if (cachingLayer) {
@@ -720,7 +779,7 @@ export async function createAndStartServer(config, sources) {
720
779
  if (config.cacheWarmup && cachingLayer && config.url) {
721
780
  try {
722
781
  const { runWarmup } = await import('../cache/warmup.js');
723
- const warmupClient = new AdtClient(buildAdtConfig(config, btpProxy, bearerTokenProvider));
782
+ const warmupClient = new AdtClient(buildAdtConfig(config, btpProxy, bearerTokenProvider, undefined, adtSemaphore));
724
783
  const result = await runWarmup(warmupClient, cachingLayer, config.cacheWarmupPackages || undefined, config.systemType);
725
784
  logger.info('Cache warmup completed', {
726
785
  objects: result.totalObjects,
@@ -740,7 +799,7 @@ export async function createAndStartServer(config, sources) {
740
799
  // Run feature probe once at startup — shared across all requests (stdio and HTTP).
741
800
  // First run startup auth preflight in shared mode. If it blocks (401/403), skip feature probe
742
801
  // to avoid firing many failing requests with invalid technical credentials.
743
- const startupAuthPreflightPromise = runStartupAuthPreflight(config, btpProxy, bearerTokenProvider);
802
+ const startupAuthPreflightPromise = runStartupAuthPreflight(config, btpProxy, bearerTokenProvider, adtSemaphore);
744
803
  const startupProbePromise = (async () => {
745
804
  const authPreflight = await startupAuthPreflightPromise;
746
805
  if (authPreflight.blocking) {
@@ -748,9 +807,9 @@ export async function createAndStartServer(config, sources) {
748
807
  setCachedDiscovery(new Map());
749
808
  return;
750
809
  }
751
- await runStartupProbe(config, btpProxy, bearerTokenProvider, btpConfig);
810
+ await runStartupProbe(config, btpProxy, bearerTokenProvider, btpConfig, adtSemaphore);
752
811
  })();
753
- const server = createServer(config, btpProxy, btpConfig, bearerTokenProvider, cachingLayer, startupProbePromise, startupAuthPreflightPromise);
812
+ const server = createServer(config, btpProxy, btpConfig, bearerTokenProvider, cachingLayer, startupProbePromise, startupAuthPreflightPromise, adtSemaphore, mcpRateLimiter);
754
813
  // Shutdown hook for SQLite cache cleanup (guard against double-close from multiple signals).
755
814
  // IMPORTANT: registering a SIGINT/SIGTERM listener suppresses Node's default exit behavior,
756
815
  // so we must call process.exit() explicitly after cleanup — otherwise Ctrl+C hangs the process.
@@ -820,7 +879,7 @@ export async function createAndStartServer(config, sources) {
820
879
  }
821
880
  }
822
881
  const { startHttpServer } = await import('./http.js');
823
- await startHttpServer(() => createServer(config, btpProxy, btpConfig, bearerTokenProvider, cachingLayer, startupProbePromise, startupAuthPreflightPromise), config, xsuaaCredentials);
882
+ await startHttpServer(() => createServer(config, btpProxy, btpConfig, bearerTokenProvider, cachingLayer, startupProbePromise, startupAuthPreflightPromise, adtSemaphore, mcpRateLimiter), config, xsuaaCredentials);
824
883
  }
825
884
  return server;
826
885
  }