arc-1 0.9.27 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (299) hide show
  1. package/README.md +30 -11
  2. package/dist/adt/abapgit.d.ts +19 -6
  3. package/dist/adt/abapgit.d.ts.map +1 -1
  4. package/dist/adt/abapgit.js +141 -36
  5. package/dist/adt/abapgit.js.map +1 -1
  6. package/dist/adt/client.d.ts +21 -8
  7. package/dist/adt/client.d.ts.map +1 -1
  8. package/dist/adt/client.js +47 -7
  9. package/dist/adt/client.js.map +1 -1
  10. package/dist/adt/config.d.ts +15 -0
  11. package/dist/adt/config.d.ts.map +1 -1
  12. package/dist/adt/config.js.map +1 -1
  13. package/dist/adt/crud.d.ts +7 -2
  14. package/dist/adt/crud.d.ts.map +1 -1
  15. package/dist/adt/crud.js +12 -4
  16. package/dist/adt/crud.js.map +1 -1
  17. package/dist/adt/ddic-xml.d.ts +27 -17
  18. package/dist/adt/ddic-xml.d.ts.map +1 -1
  19. package/dist/adt/ddic-xml.js +45 -34
  20. package/dist/adt/ddic-xml.js.map +1 -1
  21. package/dist/adt/devtools.d.ts +13 -0
  22. package/dist/adt/devtools.d.ts.map +1 -1
  23. package/dist/adt/devtools.js +120 -50
  24. package/dist/adt/devtools.js.map +1 -1
  25. package/dist/adt/errors.d.ts +1 -1
  26. package/dist/adt/errors.d.ts.map +1 -1
  27. package/dist/adt/errors.js +18 -0
  28. package/dist/adt/errors.js.map +1 -1
  29. package/dist/adt/flp.d.ts +14 -4
  30. package/dist/adt/flp.d.ts.map +1 -1
  31. package/dist/adt/flp.js +17 -31
  32. package/dist/adt/flp.js.map +1 -1
  33. package/dist/adt/http.d.ts +17 -1
  34. package/dist/adt/http.d.ts.map +1 -1
  35. package/dist/adt/http.js +102 -19
  36. package/dist/adt/http.js.map +1 -1
  37. package/dist/adt/server-driven.d.ts +154 -34
  38. package/dist/adt/server-driven.d.ts.map +1 -1
  39. package/dist/adt/server-driven.js +178 -53
  40. package/dist/adt/server-driven.js.map +1 -1
  41. package/dist/adt/transport.d.ts +30 -11
  42. package/dist/adt/transport.d.ts.map +1 -1
  43. package/dist/adt/transport.js +119 -58
  44. package/dist/adt/transport.js.map +1 -1
  45. package/dist/adt/types.d.ts +54 -15
  46. package/dist/adt/types.d.ts.map +1 -1
  47. package/dist/adt/xml-parser.d.ts +52 -12
  48. package/dist/adt/xml-parser.d.ts.map +1 -1
  49. package/dist/adt/xml-parser.js +120 -10
  50. package/dist/adt/xml-parser.js.map +1 -1
  51. package/dist/authz/policy.d.ts +8 -0
  52. package/dist/authz/policy.d.ts.map +1 -1
  53. package/dist/authz/policy.js +23 -0
  54. package/dist/authz/policy.js.map +1 -1
  55. package/dist/cache/cache.d.ts +2 -34
  56. package/dist/cache/cache.d.ts.map +1 -1
  57. package/dist/cache/cache.js +2 -3
  58. package/dist/cache/cache.js.map +1 -1
  59. package/dist/cache/caching-layer.d.ts +3 -19
  60. package/dist/cache/caching-layer.d.ts.map +1 -1
  61. package/dist/cache/caching-layer.js +2 -26
  62. package/dist/cache/caching-layer.js.map +1 -1
  63. package/dist/cache/memory.d.ts +1 -12
  64. package/dist/cache/memory.d.ts.map +1 -1
  65. package/dist/cache/memory.js +0 -69
  66. package/dist/cache/memory.js.map +1 -1
  67. package/dist/cache/sqlite.d.ts +2 -9
  68. package/dist/cache/sqlite.d.ts.map +1 -1
  69. package/dist/cache/sqlite.js +8 -84
  70. package/dist/cache/sqlite.js.map +1 -1
  71. package/dist/cli.js +6 -3
  72. package/dist/cli.js.map +1 -1
  73. package/dist/context/unit-surgery.d.ts +28 -0
  74. package/dist/context/unit-surgery.d.ts.map +1 -0
  75. package/dist/context/unit-surgery.js +150 -0
  76. package/dist/context/unit-surgery.js.map +1 -0
  77. package/dist/handlers/activate.d.ts.map +1 -1
  78. package/dist/handlers/activate.js +44 -6
  79. package/dist/handlers/activate.js.map +1 -1
  80. package/dist/handlers/context.d.ts +1 -1
  81. package/dist/handlers/context.d.ts.map +1 -1
  82. package/dist/handlers/context.js +98 -28
  83. package/dist/handlers/context.js.map +1 -1
  84. package/dist/handlers/diagnose.d.ts.map +1 -1
  85. package/dist/handlers/diagnose.js +55 -31
  86. package/dist/handlers/diagnose.js.map +1 -1
  87. package/dist/handlers/dispatch.d.ts +12 -1
  88. package/dist/handlers/dispatch.d.ts.map +1 -1
  89. package/dist/handlers/dispatch.js +160 -25
  90. package/dist/handlers/dispatch.js.map +1 -1
  91. package/dist/handlers/feature-cache.d.ts +27 -14
  92. package/dist/handlers/feature-cache.d.ts.map +1 -1
  93. package/dist/handlers/feature-cache.js +63 -25
  94. package/dist/handlers/feature-cache.js.map +1 -1
  95. package/dist/handlers/function-processing.d.ts +61 -0
  96. package/dist/handlers/function-processing.d.ts.map +1 -0
  97. package/dist/handlers/function-processing.js +45 -0
  98. package/dist/handlers/function-processing.js.map +1 -0
  99. package/dist/handlers/git.d.ts.map +1 -1
  100. package/dist/handlers/git.js +44 -19
  101. package/dist/handlers/git.js.map +1 -1
  102. package/dist/handlers/hyperfocused.d.ts.map +1 -1
  103. package/dist/handlers/hyperfocused.js +2 -3
  104. package/dist/handlers/hyperfocused.js.map +1 -1
  105. package/dist/handlers/lint.d.ts.map +1 -1
  106. package/dist/handlers/lint.js +7 -7
  107. package/dist/handlers/lint.js.map +1 -1
  108. package/dist/handlers/manage.d.ts.map +1 -1
  109. package/dist/handlers/manage.js +58 -31
  110. package/dist/handlers/manage.js.map +1 -1
  111. package/dist/handlers/navigate.d.ts.map +1 -1
  112. package/dist/handlers/navigate.js +27 -90
  113. package/dist/handlers/navigate.js.map +1 -1
  114. package/dist/handlers/object-types.d.ts +2 -0
  115. package/dist/handlers/object-types.d.ts.map +1 -1
  116. package/dist/handlers/object-types.js +43 -4
  117. package/dist/handlers/object-types.js.map +1 -1
  118. package/dist/handlers/query.d.ts.map +1 -1
  119. package/dist/handlers/query.js +2 -2
  120. package/dist/handlers/query.js.map +1 -1
  121. package/dist/handlers/read.d.ts.map +1 -1
  122. package/dist/handlers/read.js +64 -41
  123. package/dist/handlers/read.js.map +1 -1
  124. package/dist/handlers/schemas.d.ts +68 -16
  125. package/dist/handlers/schemas.d.ts.map +1 -1
  126. package/dist/handlers/schemas.js +124 -32
  127. package/dist/handlers/schemas.js.map +1 -1
  128. package/dist/handlers/search.d.ts.map +1 -1
  129. package/dist/handlers/search.js +8 -7
  130. package/dist/handlers/search.js.map +1 -1
  131. package/dist/handlers/shared.d.ts +13 -0
  132. package/dist/handlers/shared.d.ts.map +1 -1
  133. package/dist/handlers/shared.js +15 -0
  134. package/dist/handlers/shared.js.map +1 -1
  135. package/dist/handlers/tool-descriptions.d.ts +4 -0
  136. package/dist/handlers/tool-descriptions.d.ts.map +1 -0
  137. package/dist/handlers/tool-descriptions.js +52 -0
  138. package/dist/handlers/tool-descriptions.js.map +1 -0
  139. package/dist/handlers/tool-registry.d.ts +18 -4
  140. package/dist/handlers/tool-registry.d.ts.map +1 -1
  141. package/dist/handlers/tool-registry.js +18 -0
  142. package/dist/handlers/tool-registry.js.map +1 -1
  143. package/dist/handlers/tools.d.ts.map +1 -1
  144. package/dist/handlers/tools.js +126 -103
  145. package/dist/handlers/tools.js.map +1 -1
  146. package/dist/handlers/transport.d.ts +2 -2
  147. package/dist/handlers/transport.d.ts.map +1 -1
  148. package/dist/handlers/transport.js +81 -23
  149. package/dist/handlers/transport.js.map +1 -1
  150. package/dist/handlers/where-used.d.ts +25 -0
  151. package/dist/handlers/where-used.d.ts.map +1 -0
  152. package/dist/handlers/where-used.js +94 -0
  153. package/dist/handlers/where-used.js.map +1 -0
  154. package/dist/handlers/write/class-surgery.d.ts.map +1 -1
  155. package/dist/handlers/write/class-surgery.js +11 -12
  156. package/dist/handlers/write/class-surgery.js.map +1 -1
  157. package/dist/handlers/write/create.d.ts.map +1 -1
  158. package/dist/handlers/write/create.js +194 -84
  159. package/dist/handlers/write/create.js.map +1 -1
  160. package/dist/handlers/write/rap.d.ts.map +1 -1
  161. package/dist/handlers/write/rap.js +9 -9
  162. package/dist/handlers/write/rap.js.map +1 -1
  163. package/dist/handlers/write/unit-surgery.d.ts +5 -0
  164. package/dist/handlers/write/unit-surgery.d.ts.map +1 -0
  165. package/dist/handlers/write/unit-surgery.js +47 -0
  166. package/dist/handlers/write/unit-surgery.js.map +1 -0
  167. package/dist/handlers/write/update-delete.js +8 -8
  168. package/dist/handlers/write/update-delete.js.map +1 -1
  169. package/dist/handlers/write-helpers.d.ts +7 -4
  170. package/dist/handlers/write-helpers.d.ts.map +1 -1
  171. package/dist/handlers/write-helpers.js +118 -63
  172. package/dist/handlers/write-helpers.js.map +1 -1
  173. package/dist/handlers/write.d.ts.map +1 -1
  174. package/dist/handlers/write.js +35 -13
  175. package/dist/handlers/write.js.map +1 -1
  176. package/dist/lint/lint.d.ts.map +1 -1
  177. package/dist/lint/lint.js +4 -2
  178. package/dist/lint/lint.js.map +1 -1
  179. package/dist/probe/catalog.js +1 -1
  180. package/dist/probe/catalog.js.map +1 -1
  181. package/dist/public/ui/app.js +1 -14
  182. package/dist/server/audit.d.ts +27 -15
  183. package/dist/server/audit.d.ts.map +1 -1
  184. package/dist/server/audit.js.map +1 -1
  185. package/dist/server/auth-rate-limit.d.ts +18 -9
  186. package/dist/server/auth-rate-limit.d.ts.map +1 -1
  187. package/dist/server/auth-rate-limit.js +31 -6
  188. package/dist/server/auth-rate-limit.js.map +1 -1
  189. package/dist/server/auth-summary.d.ts +4 -0
  190. package/dist/server/auth-summary.d.ts.map +1 -0
  191. package/dist/server/auth-summary.js +67 -0
  192. package/dist/server/auth-summary.js.map +1 -0
  193. package/dist/server/config.d.ts.map +1 -1
  194. package/dist/server/config.js +106 -3
  195. package/dist/server/config.js.map +1 -1
  196. package/dist/server/context.d.ts +12 -0
  197. package/dist/server/context.d.ts.map +1 -1
  198. package/dist/server/context.js.map +1 -1
  199. package/dist/server/destination-discovery.d.ts +36 -0
  200. package/dist/server/destination-discovery.d.ts.map +1 -0
  201. package/dist/server/destination-discovery.js +130 -0
  202. package/dist/server/destination-discovery.js.map +1 -0
  203. package/dist/server/destination-registry.d.ts +111 -0
  204. package/dist/server/destination-registry.d.ts.map +1 -0
  205. package/dist/server/destination-registry.js +449 -0
  206. package/dist/server/destination-registry.js.map +1 -0
  207. package/dist/server/http.d.ts +43 -1
  208. package/dist/server/http.d.ts.map +1 -1
  209. package/dist/server/http.js +283 -73
  210. package/dist/server/http.js.map +1 -1
  211. package/dist/server/logger.d.ts.map +1 -1
  212. package/dist/server/logger.js +58 -13
  213. package/dist/server/logger.js.map +1 -1
  214. package/dist/server/mcp-rate-limit.d.ts +6 -4
  215. package/dist/server/mcp-rate-limit.d.ts.map +1 -1
  216. package/dist/server/mcp-rate-limit.js +6 -4
  217. package/dist/server/mcp-rate-limit.js.map +1 -1
  218. package/dist/server/multi-target-basic-auth.d.ts +49 -0
  219. package/dist/server/multi-target-basic-auth.d.ts.map +1 -0
  220. package/dist/server/multi-target-basic-auth.js +246 -0
  221. package/dist/server/multi-target-basic-auth.js.map +1 -0
  222. package/dist/server/multi-target-catalog.d.ts +33 -0
  223. package/dist/server/multi-target-catalog.d.ts.map +1 -0
  224. package/dist/server/multi-target-catalog.js +130 -0
  225. package/dist/server/multi-target-catalog.js.map +1 -0
  226. package/dist/server/multi-target-destination-config.d.ts +12 -0
  227. package/dist/server/multi-target-destination-config.d.ts.map +1 -0
  228. package/dist/server/multi-target-destination-config.js +37 -0
  229. package/dist/server/multi-target-destination-config.js.map +1 -0
  230. package/dist/server/multi-target-destination-runtime.d.ts +20 -0
  231. package/dist/server/multi-target-destination-runtime.d.ts.map +1 -0
  232. package/dist/server/multi-target-destination-runtime.js +191 -0
  233. package/dist/server/multi-target-destination-runtime.js.map +1 -0
  234. package/dist/server/multi-target-feature-state.d.ts +6 -0
  235. package/dist/server/multi-target-feature-state.d.ts.map +1 -0
  236. package/dist/server/multi-target-feature-state.js +41 -0
  237. package/dist/server/multi-target-feature-state.js.map +1 -0
  238. package/dist/server/multi-target-identity.d.ts +10 -0
  239. package/dist/server/multi-target-identity.d.ts.map +1 -0
  240. package/dist/server/multi-target-identity.js +18 -0
  241. package/dist/server/multi-target-identity.js.map +1 -0
  242. package/dist/server/multi-target-runtime.d.ts +27 -0
  243. package/dist/server/multi-target-runtime.d.ts.map +1 -0
  244. package/dist/server/multi-target-runtime.js +110 -0
  245. package/dist/server/multi-target-runtime.js.map +1 -0
  246. package/dist/server/multi-target-server.d.ts +53 -0
  247. package/dist/server/multi-target-server.d.ts.map +1 -0
  248. package/dist/server/multi-target-server.js +434 -0
  249. package/dist/server/multi-target-server.js.map +1 -0
  250. package/dist/server/multi-target-shared-auth-state.d.ts +82 -0
  251. package/dist/server/multi-target-shared-auth-state.d.ts.map +1 -0
  252. package/dist/server/multi-target-shared-auth-state.js +226 -0
  253. package/dist/server/multi-target-shared-auth-state.js.map +1 -0
  254. package/dist/server/multi-target-tools.d.ts +20 -0
  255. package/dist/server/multi-target-tools.d.ts.map +1 -0
  256. package/dist/server/multi-target-tools.js +191 -0
  257. package/dist/server/multi-target-tools.js.map +1 -0
  258. package/dist/server/oauth-session-recovery.d.ts +22 -0
  259. package/dist/server/oauth-session-recovery.d.ts.map +1 -0
  260. package/dist/server/oauth-session-recovery.js +69 -0
  261. package/dist/server/oauth-session-recovery.js.map +1 -0
  262. package/dist/server/plugin-loader.d.ts.map +1 -1
  263. package/dist/server/plugin-loader.js +8 -6
  264. package/dist/server/plugin-loader.js.map +1 -1
  265. package/dist/server/server.d.ts +39 -27
  266. package/dist/server/server.d.ts.map +1 -1
  267. package/dist/server/server.js +433 -300
  268. package/dist/server/server.js.map +1 -1
  269. package/dist/server/sinks/btp-auditlog.d.ts.map +1 -1
  270. package/dist/server/sinks/btp-auditlog.js +83 -11
  271. package/dist/server/sinks/btp-auditlog.js.map +1 -1
  272. package/dist/server/tool-auth.d.ts +10 -0
  273. package/dist/server/tool-auth.d.ts.map +1 -0
  274. package/dist/server/tool-auth.js +75 -0
  275. package/dist/server/tool-auth.js.map +1 -0
  276. package/dist/server/trace-context.d.ts +51 -0
  277. package/dist/server/trace-context.d.ts.map +1 -0
  278. package/dist/server/trace-context.js +100 -0
  279. package/dist/server/trace-context.js.map +1 -0
  280. package/dist/server/types.d.ts +27 -6
  281. package/dist/server/types.d.ts.map +1 -1
  282. package/dist/server/types.js +5 -2
  283. package/dist/server/types.js.map +1 -1
  284. package/dist/server/ui-state.d.ts +0 -2
  285. package/dist/server/ui-state.d.ts.map +1 -1
  286. package/dist/server/ui-state.js +0 -4
  287. package/dist/server/ui-state.js.map +1 -1
  288. package/dist/server/ui.d.ts.map +1 -1
  289. package/dist/server/ui.js +0 -7
  290. package/dist/server/ui.js.map +1 -1
  291. package/package.json +17 -14
  292. package/dist/cache/warmup.d.ts +0 -41
  293. package/dist/cache/warmup.d.ts.map +0 -1
  294. package/dist/cache/warmup.js +0 -337
  295. package/dist/cache/warmup.js.map +0 -1
  296. package/dist/server/elicit.d.ts +0 -43
  297. package/dist/server/elicit.d.ts.map +0 -1
  298. package/dist/server/elicit.js +0 -183
  299. package/dist/server/elicit.js.map +0 -1
@@ -26,7 +26,9 @@
26
26
  */
27
27
  import type { XsuaaCredentials } from '@arc-mcp/xsuaa-auth';
28
28
  import type { Server as McpServer } from '@modelcontextprotocol/sdk/server/index.js';
29
+ import type { Request, Response } from 'express';
29
30
  import express from 'express';
31
+ import type { DestinationRegistry, TargetDescriptor } from './destination-registry.js';
30
32
  import type { ServerConfig } from './types.js';
31
33
  import { type UiServerDeps } from './ui.js';
32
34
  /**
@@ -53,10 +55,50 @@ import { type UiServerDeps } from './ui.js';
53
55
  * Exported for unit tests; also called from `startHttpServer` below.
54
56
  */
55
57
  export declare function applySecurityMiddleware(app: express.Application, allowedOrigins: string[]): void;
58
+ /**
59
+ * Create an Express handler that processes MCP requests.
60
+ * Each request gets a fresh Server + Transport pair.
61
+ * Exported for the era-contract regression tests (tests/unit/server/mcp-era-contract.test.ts).
62
+ */
63
+ export declare function createMcpHandler(serverFactory: () => McpServer): (req: Request, res: Response) => Promise<void>;
64
+ export interface MultiTargetRouting {
65
+ registry: DestinationRegistry;
66
+ aggregateFactory: () => McpServer;
67
+ createPinnedServer: (target: TargetDescriptor) => McpServer;
68
+ }
69
+ export declare function createPinnedTargetMcpHandler(multi: MultiTargetRouting): (req: Request, res: Response) => Promise<void>;
70
+ export declare function createAggregateMcpHandler(multi: MultiTargetRouting): (req: Request, res: Response) => Promise<void>;
71
+ export declare function resolveMcpHttpRateLimit(config: Pick<ServerConfig, 'authRateLimit' | 'mcpHttpRateLimit'>): number;
72
+ export declare function multiTargetHealthStatus(registry: DestinationRegistry): 'ready' | 'error';
73
+ export declare const MULTI_TARGET_SCOPES_SUPPORTED: readonly string[];
56
74
  /**
57
75
  * Start the HTTP Streamable server.
58
76
  */
59
- export declare function startHttpServer(serverFactory: () => McpServer, config: ServerConfig, xsuaaCredentials?: XsuaaCredentials, uiDeps?: UiServerDeps): Promise<void>;
77
+ export declare function startHttpServer(serverFactory: (() => McpServer) | undefined, config: ServerConfig, xsuaaCredentials?: XsuaaCredentials, uiDeps?: UiServerDeps, multiTargets?: MultiTargetRouting): Promise<void>;
78
+ /**
79
+ * Build the RFC 9728 document ARC-1 publishes in OIDC mode.
80
+ *
81
+ * ARC-1 is only the resource server here: `authorization_servers` points at the
82
+ * external IdP, and clients discover its endpoints themselves (RFC 8414 with the
83
+ * OIDC-discovery fallbacks — Entra only answers the append form). `resource` is
84
+ * the canonical MCP endpoint URI; MCP clients reject a document whose `resource`
85
+ * is not same-origin with the server they called.
86
+ *
87
+ * `scopes_supported` is IdP-specific (`api://<client-id>/access_as_user` on Entra,
88
+ * arbitrary elsewhere) and cannot be derived from `SAP_OIDC_AUDIENCE` — it is
89
+ * omitted unless the admin sets `SAP_OIDC_SCOPES`.
90
+ */
91
+ export declare function buildOidcResourceMetadata(config: ServerConfig, publicBase: string): import('@modelcontextprotocol/sdk/shared/auth.js').OAuthProtectedResourceMetadata;
92
+ /**
93
+ * Mount the protected-resource metadata routes and return the URL that belongs in
94
+ * the `WWW-Authenticate` challenge. Caller gates on `oidcIssuer && oidcDiscovery`.
95
+ *
96
+ * URLs come from `getAppUrl()` (ARC1_PUBLIC_URL / VCAP), never from the request
97
+ * `Host` header, so a spoofed Host cannot redirect clients at an attacker's IdP.
98
+ * The SDK's `metadataHandler` supplies `cors()` + GET/OPTIONS-only, the same
99
+ * treatment the XSUAA branch's metadata gets — the document is public by design.
100
+ */
101
+ export declare function mountOidcResourceMetadata(app: express.Application, config: ServerConfig, bindHost: string, port: number): Promise<string>;
60
102
  /**
61
103
  * Create a token verifier for standard auth mode (API key + OIDC).
62
104
  *
@@ -1 +1 @@
1
- {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/server/http.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAe,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AACzE,OAAO,KAAK,EAAE,MAAM,IAAI,SAAS,EAAE,MAAM,2CAA2C,CAAC;AAIrF,OAAO,OAAO,MAAM,SAAS,CAAC;AAM9B,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,EAAiB,KAAK,YAAY,EAAE,MAAM,SAAS,CAAC;AA2B3D;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,OAAO,CAAC,WAAW,EAAE,cAAc,EAAE,MAAM,EAAE,GAAG,IAAI,CA6DhG;AAuCD;;GAEG;AACH,wBAAsB,eAAe,CACnC,aAAa,EAAE,MAAM,SAAS,EAC9B,MAAM,EAAE,YAAY,EACpB,gBAAgB,CAAC,EAAE,gBAAgB,EACnC,MAAM,CAAC,EAAE,YAAY,GACpB,OAAO,CAAC,IAAI,CAAC,CAoaf;AAkDD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,sBAAsB,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,OAAO,qBAAqB,EAAE,QAAQ,CAAC,CAOlH"}
1
+ {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/server/http.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAe,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AACzE,OAAO,KAAK,EAAE,MAAM,IAAI,SAAS,EAAE,MAAM,2CAA2C,CAAC;AAGrF,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACjD,OAAO,OAAO,MAAM,SAAS,CAAC;AAK9B,OAAO,KAAK,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAevF,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,EAAiB,KAAK,YAAY,EAAE,MAAM,SAAS,CAAC;AA2B3D;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,OAAO,CAAC,WAAW,EAAE,cAAc,EAAE,MAAM,EAAE,GAAG,IAAI,CAiEhG;AA4CD;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,aAAa,EAAE,MAAM,SAAS,SAC1C,OAAO,OAAO,QAAQ,mBAU1C;AAID,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,mBAAmB,CAAC;IAC9B,gBAAgB,EAAE,MAAM,SAAS,CAAC;IAClC,kBAAkB,EAAE,CAAC,MAAM,EAAE,gBAAgB,KAAK,SAAS,CAAC;CAC7D;AAED,wBAAgB,4BAA4B,CAAC,KAAK,EAAE,kBAAkB,SACjD,OAAO,OAAO,QAAQ,mBAmB1C;AAED,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,kBAAkB,SAC9C,OAAO,OAAO,QAAQ,mBAM1C;AAED,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,IAAI,CAAC,YAAY,EAAE,eAAe,GAAG,kBAAkB,CAAC,GAAG,MAAM,CAMhH;AAED,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,mBAAmB,GAAG,OAAO,GAAG,OAAO,CAIxF;AAED,eAAO,MAAM,6BAA6B,mBAAkD,CAAC;AAE7F;;GAEG;AACH,wBAAsB,eAAe,CACnC,aAAa,EAAE,CAAC,MAAM,SAAS,CAAC,GAAG,SAAS,EAC5C,MAAM,EAAE,YAAY,EACpB,gBAAgB,CAAC,EAAE,gBAAgB,EACnC,MAAM,CAAC,EAAE,YAAY,EACrB,YAAY,CAAC,EAAE,kBAAkB,GAChC,OAAO,CAAC,IAAI,CAAC,CAggBf;AAkDD;;;;;;;;;;;;GAYG;AACH,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,YAAY,EACpB,UAAU,EAAE,MAAM,GACjB,OAAO,0CAA0C,EAAE,8BAA8B,CAQnF;AAED;;;;;;;;GAQG;AACH,wBAAsB,yBAAyB,CAC7C,GAAG,EAAE,OAAO,CAAC,WAAW,EACxB,MAAM,EAAE,YAAY,EACpB,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,MAAM,CAAC,CA0BjB;AAID;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,sBAAsB,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,OAAO,qBAAqB,EAAE,QAAQ,CAAC,CAOlH"}
@@ -28,10 +28,14 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
28
28
  import cors from 'cors';
29
29
  import express from 'express';
30
30
  import helmet from 'helmet';
31
- import { expandScopes } from '../authz/policy.js';
31
+ import { expandScopes, hasRequiredScope } from '../authz/policy.js';
32
32
  import { API_KEY_PROFILES } from './config.js';
33
+ import { generateRequestId, requestContext } from './context.js';
33
34
  import { authLibLogger, logger } from './logger.js';
35
+ import { PINNED_MCP_PATH_PATTERN, PINNED_RESOURCE_METADATA_PATH_PATTERN, targetFromPinnedMcpPath, } from './multi-target-identity.js';
36
+ import { buildXsuaaSessionRefreshUrl, createOAuthLoggedOutHandler, OAUTH_LOGGED_OUT_PATH, withInvalidScopeSessionRecovery, } from './oauth-session-recovery.js';
34
37
  import { VERSION } from './server.js';
38
+ import { sanitizeClientAgent, validateTraceparent, validateTracestate } from './trace-context.js';
35
39
  import { mountUiRoutes } from './ui.js';
36
40
  // ─── API Key Entry Mapping ───────────────────────────────────────────
37
41
  /**
@@ -113,7 +117,11 @@ export function applySecurityMiddleware(app, allowedOrigins) {
113
117
  callback(null, allowed.has(origin));
114
118
  },
115
119
  methods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
116
- allowedHeaders: ['Content-Type', 'Authorization', 'mcp-session-id'],
120
+ // mcp-protocol-version: sent by every SDK client on post-initialize requests (spec 2025-06-18+).
121
+ // last-event-id: sent on SSE reconnects (fetch-based, so preflighted). 2026-07-28 headers
122
+ // (Mcp-Method/Mcp-Name/Mcp-Param-*) deliberately absent — v2 clients default to legacy mode,
123
+ // and Mcp-Param-* is dynamically named (can't be allow-listed under credentials). ADR-0006.
124
+ allowedHeaders: ['Content-Type', 'Authorization', 'mcp-session-id', 'mcp-protocol-version', 'last-event-id'],
117
125
  exposedHeaders: ['mcp-session-id'],
118
126
  credentials: true,
119
127
  }));
@@ -138,11 +146,49 @@ export function applySecurityMiddleware(app, allowedOrigins) {
138
146
  }
139
147
  }
140
148
  // ─── MCP Request Handler ─────────────────────────────────────────────
149
+ /**
150
+ * Serve one MCP request with a fresh Server + Transport pair.
151
+ *
152
+ * IMPORTANT: Passes req.body as pre-parsed body (3rd argument).
153
+ * express.json() middleware consumes the raw request stream. Without this,
154
+ * the MCP SDK's transport tries to re-read the stream, gets nothing, and
155
+ * returns "Parse error: Invalid JSON" (-32700). The SDK explicitly supports
156
+ * this pattern — see their docs/comments in
157
+ * StreamableHTTPServerTransport.handleRequest().
158
+ */
159
+ async function serveMcpRequest(serverFactory, req, res) {
160
+ try {
161
+ const server = serverFactory();
162
+ const transport = new StreamableHTTPServerTransport({
163
+ sessionIdGenerator: undefined, // Stateless mode
164
+ });
165
+ await server.connect(transport);
166
+ // Seed the request context from the HTTP edge: trace context to forward to SAP, and the
167
+ // calling agent. handleToolCall opens its own nested context for the tool call and inherits
168
+ // both — but events emitted before it (auth, multi-target prep) are covered only here.
169
+ // `User-Agent` is the only agent signal available on this transport: stateless mode means the
170
+ // per-request Server never saw `initialize`, so `getClientVersion()` is always undefined.
171
+ const traceparent = validateTraceparent(req.headers.traceparent);
172
+ await requestContext.run({
173
+ requestId: generateRequestId(),
174
+ traceparent,
175
+ tracestate: validateTracestate(req.headers.tracestate, traceparent),
176
+ clientAgent: sanitizeClientAgent(req.headers['user-agent']),
177
+ }, () => transport.handleRequest(req, res, req.body));
178
+ }
179
+ catch (err) {
180
+ logger.error('MCP request error', { error: err instanceof Error ? err.message : String(err) });
181
+ if (!res.headersSent) {
182
+ res.status(500).json({ error: 'Internal server error' });
183
+ }
184
+ }
185
+ }
141
186
  /**
142
187
  * Create an Express handler that processes MCP requests.
143
188
  * Each request gets a fresh Server + Transport pair.
189
+ * Exported for the era-contract regression tests (tests/unit/server/mcp-era-contract.test.ts).
144
190
  */
145
- function createMcpHandler(serverFactory) {
191
+ export function createMcpHandler(serverFactory) {
146
192
  return async (req, res) => {
147
193
  logger.debug('MCP handler invoked', {
148
194
  method: req.method,
@@ -151,32 +197,56 @@ function createMcpHandler(serverFactory) {
151
197
  bodyMethod: req.body?.method,
152
198
  bodyId: req.body?.id,
153
199
  });
154
- try {
155
- const server = serverFactory();
156
- const transport = new StreamableHTTPServerTransport({
157
- sessionIdGenerator: undefined, // Stateless mode
158
- });
159
- await server.connect(transport);
160
- // IMPORTANT: Pass req.body as pre-parsed body (3rd argument).
161
- // express.json() middleware (line 91) consumes the raw request stream.
162
- // Without this, the MCP SDK's transport tries to re-read the stream,
163
- // gets nothing, and returns "Parse error: Invalid JSON" (-32700).
164
- // The SDK explicitly supports this pattern — see their docs/comments
165
- // in StreamableHTTPServerTransport.handleRequest().
166
- await transport.handleRequest(req, res, req.body);
200
+ await serveMcpRequest(serverFactory, req, res);
201
+ };
202
+ }
203
+ export function createPinnedTargetMcpHandler(multi) {
204
+ return async (req, res) => {
205
+ if (!multi.registry.available) {
206
+ res.status(503).json({ error: 'Multi-target registry unavailable' });
207
+ return;
167
208
  }
168
- catch (err) {
169
- logger.error('MCP request error', { error: err instanceof Error ? err.message : String(err) });
170
- if (!res.headersSent) {
171
- res.status(500).json({ error: 'Internal server error' });
172
- }
209
+ const targetId = targetFromPinnedMcpPath(req.path);
210
+ const target = targetId ? multi.registry.get(targetId) : undefined;
211
+ if (!target) {
212
+ res.status(404).json({ error: 'Not found' });
213
+ return;
173
214
  }
215
+ logger.debug('MCP handler invoked (pinned target)', {
216
+ target: target.target,
217
+ method: req.method,
218
+ bodyMethod: req.body?.method,
219
+ bodyId: req.body?.id,
220
+ });
221
+ await serveMcpRequest(() => multi.createPinnedServer(target), req, res);
174
222
  };
175
223
  }
224
+ export function createAggregateMcpHandler(multi) {
225
+ return async (req, res) => {
226
+ // Keep the aggregate MCP transport reachable when discovery fails so an
227
+ // administrator can call SAPTargets and inspect secret-safe diagnostics.
228
+ // Other tool calls return a structured registry-unavailable result.
229
+ await serveMcpRequest(multi.aggregateFactory, req, res);
230
+ };
231
+ }
232
+ export function resolveMcpHttpRateLimit(config) {
233
+ if (config.mcpHttpRateLimit !== undefined)
234
+ return config.mcpHttpRateLimit;
235
+ // OAuth and MCP traffic are separate edge buckets. Disabling only the OAuth
236
+ // bucket must not also remove MCP protection; ARC1_MCP_HTTP_RATE_LIMIT=0 is
237
+ // the explicit opt-out for MCP traffic.
238
+ return Math.max(config.authRateLimit * 30, 600);
239
+ }
240
+ export function multiTargetHealthStatus(registry) {
241
+ // Zero targets and individually quarantined destinations are valid snapshots.
242
+ // Only a registry-wide failure makes multi-target routing unavailable.
243
+ return registry.available ? 'ready' : 'error';
244
+ }
245
+ export const MULTI_TARGET_SCOPES_SUPPORTED = Object.freeze(['read', 'data', 'sql', 'admin']);
176
246
  /**
177
247
  * Start the HTTP Streamable server.
178
248
  */
179
- export async function startHttpServer(serverFactory, config, xsuaaCredentials, uiDeps) {
249
+ export async function startHttpServer(serverFactory, config, xsuaaCredentials, uiDeps, multiTargets) {
180
250
  const [host, portStr] = config.httpAddr.split(':');
181
251
  const port = Number.parseInt(portStr || '8080', 10);
182
252
  const bindHost = host || '0.0.0.0';
@@ -186,28 +256,41 @@ export async function startHttpServer(serverFactory, config, xsuaaCredentials, u
186
256
  app.set('trust proxy', 1);
187
257
  applySecurityMiddleware(app, config.allowedOrigins);
188
258
  // ─── Layer 1: HTTP-edge rate limiter helper ──────────────────────────
189
- // One operator-facing knob (`ARC1_AUTH_RATE_LIMIT`, default 20/min/IP) controls all
190
- // OAuth endpoints uniformly. `/mcp` gets `max(value × 30, 600)/min/IP` so legitimate
191
- // batched tool-call traffic isn't choked while pre-bearer-auth probing is still gated.
192
- // Per-endpoint differentiation lives here, not in env, so the operator surface stays tiny.
259
+ // `ARC1_AUTH_RATE_LIMIT` (default 20/min/IP) controls OAuth endpoints uniformly.
260
+ // MCP traffic derives `max(value × 30, 600)/min/IP` unless the operator sets the
261
+ // independent `ARC1_MCP_HTTP_RATE_LIMIT`; this keeps pre-bearer probing gated even
262
+ // when OAuth limiting is delegated to an upstream proxy.
193
263
  // See docs_page/rate-limiting.md (Layer 1) and ADR-0004.
194
264
  //
195
265
  // Implementation note: the limiter is mounted DIRECTLY via createAuthRateLimiter →
196
266
  // express-rate-limit. The disabled path skips the mount entirely rather than going
197
267
  // through a noop indirection — this keeps the dataflow `rateLimit({...}) → app.use`
198
268
  // direct and makes CodeQL's `js/missing-rate-limiting` query close cleanly.
199
- const { createAuthRateLimiter, isCopilotJsonRpc } = await import('./auth-rate-limit.js');
200
- const rateLimitEnabled = config.authRateLimit > 0;
201
- const mcpRatePerMinute = rateLimitEnabled ? Math.max(config.authRateLimit * 30, 600) : 0;
269
+ const { createAuthRateLimiter, isCopilotJsonRpc, isMcpHttpTraffic } = await import('./auth-rate-limit.js');
270
+ const authRateLimitEnabled = config.authRateLimit > 0;
271
+ const mcpRatePerMinute = resolveMcpHttpRateLimit(config);
272
+ const mcpRateLimitEnabled = mcpRatePerMinute > 0;
202
273
  logger.info('Auth rate limiting', {
203
274
  perMinute: config.authRateLimit,
204
275
  mcpPerMinute: mcpRatePerMinute,
205
- endpoints: rateLimitEnabled ? ['/register', '/authorize', '/token', '/revoke', '/mcp'] : [],
206
- disabled: !rateLimitEnabled,
276
+ endpoints: [
277
+ ...(authRateLimitEnabled ? ['/register', '/authorize', '/token', '/revoke'] : []),
278
+ ...(mcpRateLimitEnabled ? ['/mcp'] : []),
279
+ ],
280
+ disabled: !authRateLimitEnabled && !mcpRateLimitEnabled,
207
281
  });
208
282
  app.use(express.json());
209
283
  app.use(express.urlencoded({ extended: false }));
210
- const mcpHandler = createMcpHandler(serverFactory);
284
+ if (mcpRateLimitEnabled) {
285
+ // One middleware instance means one MemoryStore and therefore one per-IP
286
+ // bucket across single-target, pinned, aggregate, and Copilot routes.
287
+ // Mount after body parsing so the Copilot JSON-RPC predicate can inspect
288
+ // `req.body`, but before every authentication and route handler.
289
+ app.use(createAuthRateLimiter('/mcp', mcpRatePerMinute, { skip: (req) => !isMcpHttpTraffic(req) }));
290
+ }
291
+ const mcpHandler = serverFactory ? createMcpHandler(serverFactory) : undefined;
292
+ const pinnedMcpHandler = multiTargets ? createPinnedTargetMcpHandler(multiTargets) : undefined;
293
+ const aggregateMcpHandler = multiTargets ? createAggregateMcpHandler(multiTargets) : undefined;
211
294
  const hasAdminApiKey = config.apiKeys?.some((entry) => entry.profile === 'admin') ?? false;
212
295
  if (uiDeps && !(hasAdminApiKey || config.oidcIssuer || (config.xsuaaAuth && xsuaaCredentials))) {
213
296
  throw new Error('ARC1_UI=web requires an admin API key, OIDC, or XSUAA HTTP authentication.');
@@ -230,7 +313,15 @@ export async function startHttpServer(serverFactory, config, xsuaaCredentials, u
230
313
  // they're talking to the CORRECT process (not a zombie from a previous deploy).
231
314
  const startedAt = new Date().toISOString();
232
315
  app.get('/health', (_req, res) => {
233
- res.json({ status: 'ok', version: VERSION, startedAt, pid: process.pid });
316
+ res.json({
317
+ status: 'ok',
318
+ version: VERSION,
319
+ startedAt,
320
+ pid: process.pid,
321
+ ...(multiTargets
322
+ ? { components: { multiTarget: { status: multiTargetHealthStatus(multiTargets.registry) } } }
323
+ : {}),
324
+ });
234
325
  });
235
326
  // ─── XSUAA OAuth Proxy Mode ──────────────────────────────
236
327
  if (config.xsuaaAuth && xsuaaCredentials) {
@@ -252,6 +343,8 @@ export async function startHttpServer(serverFactory, config, xsuaaCredentials, u
252
343
  // URL sent to XSUAA as redirect_uri; the Express route is mounted at the root
253
344
  // `/oauth/callback` below since the proxy strips the prefix before forwarding.
254
345
  const oauthCallbackUrl = `${oauthFullBase}/oauth/callback`;
346
+ const oauthLoggedOutUrl = `${oauthFullBase}${OAUTH_LOGGED_OUT_PATH}`;
347
+ const xsuaaSessionRefreshUrl = buildXsuaaSessionRefreshUrl(xsuaaCredentials, oauthLoggedOutUrl);
255
348
  // Create XSUAA provider + chained verifier.
256
349
  //
257
350
  // The package defaults its client_id prefix + KDF labels to `mcp-*`. ARC-1
@@ -286,6 +379,42 @@ export async function startHttpServer(serverFactory, config, xsuaaCredentials, u
286
379
  verifier: { verifyAccessToken: chainedVerifier },
287
380
  resourceMetadataUrl,
288
381
  });
382
+ // Multi-target routes accept XSUAA identities only. Define this once and
383
+ // reuse it for explicit routes and the Copilot `/authorize` compatibility
384
+ // alias so multi mode cannot fall back to API-key/OIDC or a single target.
385
+ const multiBearerAuth = multiTargets && pinnedMcpHandler && aggregateMcpHandler
386
+ ? (req, res, next) => {
387
+ // `app.use('/authorize', ...)` temporarily trims req.path to `/`.
388
+ // originalUrl preserves the public route, so the Copilot alias can
389
+ // advertise the aggregate PRM instead of a non-existent root PRM.
390
+ const originalPath = req.originalUrl?.split('?', 1)[0] || req.path;
391
+ const lowerPath = originalPath.toLowerCase();
392
+ const resourcePath = lowerPath === '/authorize' ? '/multi/mcp' : originalPath;
393
+ const multiResourceMetadataUrl = `${oauthFullBase}/.well-known/oauth-protected-resource${resourcePath}`;
394
+ const authenticate = requireBearerAuth({
395
+ verifier: { verifyAccessToken: xsuaaVerifier },
396
+ resourceMetadataUrl: multiResourceMetadataUrl,
397
+ });
398
+ authenticate(req, res, (error) => {
399
+ if (error) {
400
+ next(error);
401
+ return;
402
+ }
403
+ // Keep route membership behind the global read scope without putting
404
+ // `scope=read` in the initial 401 challenge. MCP clients can therefore
405
+ // request the four mutation-free scopes advertised by the route PRM;
406
+ // XSUAA still grants only the subset assigned to the authenticated user.
407
+ if (!req.auth || !hasRequiredScope(req.auth.scopes, 'read')) {
408
+ res
409
+ .set('WWW-Authenticate', `Bearer error="insufficient_scope", error_description="Insufficient scope", scope="read", resource_metadata="${multiResourceMetadataUrl}"`)
410
+ .status(403)
411
+ .json({ error: 'insufficient_scope', error_description: 'Insufficient scope' });
412
+ return;
413
+ }
414
+ next();
415
+ });
416
+ }
417
+ : undefined;
289
418
  if (uiDeps) {
290
419
  const uiBearerAuth = requireBearerAuth({
291
420
  verifier: { verifyAccessToken: chainedVerifier },
@@ -300,33 +429,19 @@ export async function startHttpServer(serverFactory, config, xsuaaCredentials, u
300
429
  // rate-limited — they're cheap, cacheable, and legitimate clients hit them on
301
430
  // every reconnect. See docs_page/rate-limiting.md.
302
431
  //
303
- // Every `app.use(path, …)` here receives a fresh `rateLimit({...})` middleware
432
+ // Every OAuth `app.use(path, …)` here receives a fresh `rateLimit({...})` middleware
304
433
  // DIRECTLY. No conditional dispatchers, no helper wrappers. CodeQL's
305
434
  // `js/missing-rate-limiting` query only recognises that exact pattern; going
306
435
  // through an inline arrow function with branch-based delegation makes it
307
436
  // re-open the alert (verified — see PR #276 review history).
308
437
  //
309
- // Copilot Studio quirk: that client POSTs MCP JSON-RPC bodies to `/authorize`
310
- // (see routing handler below). To stop those tool calls being choked at the
311
- // low OAuth cap, we mount TWO limiters on `/authorize`:
312
- // 1. OAuth cap, with `skip` returning true for Copilot JSON-RPC traffic.
313
- // 2. /mcp cap, with `skip` returning true for everything BUT Copilot JSON-RPC.
314
- // Each request hits one bucket — the OAuth bucket for real OAuth flows, the
315
- // higher /mcp bucket for Copilot. The `isCopilotJsonRpc` predicate is shared
316
- // with auth-rate-limit.ts so the two mounts can never drift.
317
- //
318
- // Trade-off: the /authorize-JSON-RPC bucket is a separate store from the
319
- // direct /mcp bucket. An attacker alternating routes effectively gets
320
- // `mcpCap + mcpCap = 2 × mcpCap`/min/IP. At default config that's still
321
- // 1200/min, well below abuse thresholds. Sharing the store would require
322
- // injecting a custom MemoryStore into both `rateLimit({...})` calls — not
323
- // worth the complexity for a 2× headroom on an already loose cap.
324
- if (rateLimitEnabled) {
438
+ // Copilot Studio JSON-RPC on `/authorize` skips the low OAuth bucket. The
439
+ // root-mounted MCP limiter above counts it in the same higher-cap bucket as
440
+ // every other MCP endpoint style.
441
+ if (authRateLimitEnabled) {
325
442
  app.use('/register', createAuthRateLimiter('/register', config.authRateLimit));
326
443
  // /authorize OAuth limiter — skips Copilot Studio MCP JSON-RPC traffic.
327
444
  app.use('/authorize', createAuthRateLimiter('/authorize', config.authRateLimit, { skip: isCopilotJsonRpc }));
328
- // /authorize MCP limiter — only applies to Copilot Studio JSON-RPC; uses /mcp cap.
329
- app.use('/authorize', createAuthRateLimiter('/mcp', mcpRatePerMinute, { skip: (req) => !isCopilotJsonRpc(req) }));
330
445
  app.use('/token', createAuthRateLimiter('/token', config.authRateLimit));
331
446
  app.use('/revoke', createAuthRateLimiter('/revoke', config.authRateLimit));
332
447
  // /oauth/callback is unauthenticated and does an HMAC verify per hit —
@@ -337,7 +452,7 @@ export async function startHttpServer(serverFactory, config, xsuaaCredentials, u
337
452
  // Copilot Studio sends MCP JSON-RPC requests to /authorize instead of
338
453
  // /mcp after completing the OAuth flow. When we detect a JSON-RPC body
339
454
  // (has "jsonrpc" field) on POST /authorize, we bypass the OAuth handler
340
- // and route directly to bearerAuth + mcpHandler.
455
+ // and route directly to the applicable bearer verifier + MCP handler.
341
456
  //
342
457
  // For normal OAuth requests, merge query params into body as fallback
343
458
  // (some clients send POST /authorize with params in query string).
@@ -352,13 +467,24 @@ export async function startHttpServer(serverFactory, config, xsuaaCredentials, u
352
467
  id: req.body.id,
353
468
  userAgent: req.headers['user-agent']?.slice(0, 60),
354
469
  });
355
- // Run bearerAuth, then mcpHandler — skip the OAuth authorize handler
356
- bearerAuth(req, res, (err) => {
470
+ // The compatibility alias is necessarily target-ambiguous. Whenever
471
+ // multi-target mode is enabled, resolve it to the mutation-free
472
+ // aggregate route and its XSUAA-only verifier, even if a separately
473
+ // configured (and potentially writable) single-target /mcp coexists.
474
+ // Only a single-target-only deployment may use the chained verifier.
475
+ const useAggregateAlias = !!(aggregateMcpHandler && multiBearerAuth);
476
+ const authorizeBearerAuth = useAggregateAlias ? multiBearerAuth : bearerAuth;
477
+ authorizeBearerAuth(req, res, (err) => {
357
478
  if (err) {
358
479
  next(err);
359
480
  return;
360
481
  }
361
- mcpHandler(req, res);
482
+ const handler = useAggregateAlias ? aggregateMcpHandler : mcpHandler;
483
+ if (!handler) {
484
+ res.status(404).json({ error: 'Not found' });
485
+ return;
486
+ }
487
+ handler(req, res);
362
488
  });
363
489
  return;
364
490
  }
@@ -408,7 +534,8 @@ export async function startHttpServer(serverFactory, config, xsuaaCredentials, u
408
534
  // XSUAA as oauthCallbackUrl. A strip-prefix proxy maps the public path
409
535
  // back to this root route. Handler is extracted (exported) so the
410
536
  // state-round-trip contract is unit-testable without a live XSUAA.
411
- app.get('/oauth/callback', createOAuthCallbackHandler(stateCodec, clientStore, { logger: authLibLogger }));
537
+ app.get(OAUTH_LOGGED_OUT_PATH, createOAuthLoggedOutHandler());
538
+ app.get('/oauth/callback', withInvalidScopeSessionRecovery(createOAuthCallbackHandler(stateCodec, clientStore, { logger: authLibLogger }), xsuaaSessionRefreshUrl));
412
539
  // ─── Path-prefix-aware OAuth metadata override ────────────────
413
540
  // The MCP SDK's `mcpAuthRouter` builds endpoint URLs with
414
541
  // `new URL("/authorize", baseUrl).href`, which strips any path component
@@ -464,6 +591,27 @@ export async function startHttpServer(serverFactory, config, xsuaaCredentials, u
464
591
  basePath,
465
592
  });
466
593
  }
594
+ // Registry-independent multi-target PRM routes must precede the SDK router.
595
+ // Syntactically valid unknown targets intentionally receive the same metadata as known ones.
596
+ if (multiTargets && pinnedMcpHandler && aggregateMcpHandler) {
597
+ app.get(/^\/\.well-known\/oauth-protected-resource\/multi\/mcp$/, (_req, res) => {
598
+ res.json({
599
+ resource: `${oauthFullBase}/multi/mcp`,
600
+ authorization_servers: [`${oauthFullBase}/`],
601
+ scopes_supported: MULTI_TARGET_SCOPES_SUPPORTED,
602
+ resource_name: 'ARC-1 SAP MCP Server (multi-target)',
603
+ });
604
+ });
605
+ app.get(PINNED_RESOURCE_METADATA_PATH_PATTERN, (req, res) => {
606
+ const match = req.path.match(PINNED_RESOURCE_METADATA_PATH_PATTERN);
607
+ res.json({
608
+ resource: `${oauthFullBase}/${match?.[1]}/${match?.[2]}/mcp`,
609
+ authorization_servers: [`${oauthFullBase}/`],
610
+ scopes_supported: MULTI_TARGET_SCOPES_SUPPORTED,
611
+ resource_name: 'ARC-1 SAP MCP Server (pinned target)',
612
+ });
613
+ });
614
+ }
467
615
  // Install MCP SDK auth router at root (OAuth endpoints + DCR).
468
616
  // For root-path deployments (no basePath) the SDK's metadata is correct
469
617
  // as-is and serves both well-known endpoints. For prefix deployments the
@@ -478,14 +626,13 @@ export async function startHttpServer(serverFactory, config, xsuaaCredentials, u
478
626
  scopesSupported,
479
627
  resourceName: 'ARC-1 SAP MCP Server',
480
628
  }));
481
- // Layer 1: rate-limit /mcp BEFORE bearer auth so anonymous probing is gated.
482
- // Direct `app.use(path, rateLimit({...}))` mount — no helper indirection —
483
- // so CodeQL's `js/missing-rate-limiting` query sees the dataflow cleanly.
484
- if (rateLimitEnabled) {
485
- app.use('/mcp', createAuthRateLimiter('/mcp', mcpRatePerMinute));
629
+ if (multiTargets && pinnedMcpHandler && aggregateMcpHandler && multiBearerAuth) {
630
+ app.all(/^\/multi\/mcp$/, multiBearerAuth, aggregateMcpHandler);
631
+ app.all(PINNED_MCP_PATH_PATTERN, multiBearerAuth, pinnedMcpHandler);
486
632
  }
487
633
  // Protected MCP endpoint with chained token verification
488
- app.all('/mcp', bearerAuth, mcpHandler);
634
+ if (mcpHandler)
635
+ app.all('/mcp', bearerAuth, mcpHandler);
489
636
  logger.info('XSUAA OAuth proxy enabled', {
490
637
  xsappname: xsuaaCredentials.xsappname,
491
638
  appUrl,
@@ -496,30 +643,34 @@ export async function startHttpServer(serverFactory, config, xsuaaCredentials, u
496
643
  // No JWKS pre-warm needed: the package's OIDC verifier lazy-imports jose and
497
644
  // memoizes the remote JWKS on the first verify (and retries on a transient
498
645
  // discovery failure instead of caching the rejection).
499
- // Layer 1 on /mcp also applies outside XSUAA mode — API-key / OIDC / no-auth
500
- // deployments get the same anonymous-probing protection. OAuth endpoints don't
501
- // exist in non-XSUAA mode so only /mcp needs mounting here.
502
- if (rateLimitEnabled) {
503
- app.use('/mcp', createAuthRateLimiter('/mcp', mcpRatePerMinute));
504
- }
505
646
  if (config.apiKeys || config.oidcIssuer) {
506
647
  // Use requireBearerAuth so that authInfo is populated on the MCP request context.
507
648
  // This enables scope enforcement, per-request safety, and principal propagation.
508
649
  const { requireBearerAuth } = await import('@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js');
509
650
  const verifier = await createStandardVerifier(config);
510
- const bearerAuth = requireBearerAuth({ verifier: { verifyAccessToken: verifier } });
651
+ // ─── RFC 9728 protected-resource metadata (OIDC mode) ─────────
652
+ // ARC-1 is a pure resource server here: it publishes WHERE the external
653
+ // IdP lives and mints no tokens. Skipped for api-key-only mode (no
654
+ // authorization server to advertise) and when the admin opted out.
655
+ const resourceMetadataUrl = config.oidcIssuer && config.oidcDiscovery
656
+ ? await mountOidcResourceMetadata(app, config, bindHost, port)
657
+ : undefined;
658
+ const bearerAuth = requireBearerAuth({ verifier: { verifyAccessToken: verifier }, resourceMetadataUrl });
511
659
  if (uiDeps) {
512
660
  const uiBearerAuth = requireBearerAuth({
513
661
  verifier: { verifyAccessToken: verifier },
514
662
  requiredScopes: ['admin'],
663
+ resourceMetadataUrl,
515
664
  });
516
665
  mountUiRoutes(app, uiDeps, uiBearerAuth);
517
666
  }
518
- app.all('/mcp', bearerAuth, mcpHandler);
667
+ if (mcpHandler)
668
+ app.all('/mcp', bearerAuth, mcpHandler);
519
669
  }
520
670
  else {
521
671
  // No auth configured — open access
522
- app.all('/mcp', mcpHandler);
672
+ if (mcpHandler)
673
+ app.all('/mcp', mcpHandler);
523
674
  }
524
675
  }
525
676
  // ─── 404 for anything else ─────────────────────────────────
@@ -541,7 +692,8 @@ export async function startHttpServer(serverFactory, config, xsuaaCredentials, u
541
692
  logger.info('ARC-1 HTTP server started', {
542
693
  addr: `${bindHost}:${port}`,
543
694
  health: `http://${bindHost}:${port}/health`,
544
- mcp: `http://${bindHost}:${port}/mcp`,
695
+ mcp: serverFactory ? `http://${bindHost}:${port}/mcp` : undefined,
696
+ multi: multiTargets ? `http://${bindHost}:${port}/multi/mcp` : undefined,
545
697
  ui: uiDeps ? `http://${bindHost}:${port}/ui/` : undefined,
546
698
  auth: authMode,
547
699
  });
@@ -598,6 +750,64 @@ function buildPackageOidcVerifier(config, createOidcVerifier) {
598
750
  logger: authLibLogger,
599
751
  });
600
752
  }
753
+ // ─── OIDC Protected-Resource Metadata (RFC 9728) ────────────────────
754
+ /**
755
+ * Build the RFC 9728 document ARC-1 publishes in OIDC mode.
756
+ *
757
+ * ARC-1 is only the resource server here: `authorization_servers` points at the
758
+ * external IdP, and clients discover its endpoints themselves (RFC 8414 with the
759
+ * OIDC-discovery fallbacks — Entra only answers the append form). `resource` is
760
+ * the canonical MCP endpoint URI; MCP clients reject a document whose `resource`
761
+ * is not same-origin with the server they called.
762
+ *
763
+ * `scopes_supported` is IdP-specific (`api://<client-id>/access_as_user` on Entra,
764
+ * arbitrary elsewhere) and cannot be derived from `SAP_OIDC_AUDIENCE` — it is
765
+ * omitted unless the admin sets `SAP_OIDC_SCOPES`.
766
+ */
767
+ export function buildOidcResourceMetadata(config, publicBase) {
768
+ return {
769
+ resource: `${publicBase}/mcp`,
770
+ authorization_servers: [config.oidcIssuer],
771
+ bearer_methods_supported: ['header'],
772
+ resource_name: 'ARC-1 SAP MCP Server',
773
+ ...(config.oidcScopes?.length ? { scopes_supported: config.oidcScopes } : {}),
774
+ };
775
+ }
776
+ /**
777
+ * Mount the protected-resource metadata routes and return the URL that belongs in
778
+ * the `WWW-Authenticate` challenge. Caller gates on `oidcIssuer && oidcDiscovery`.
779
+ *
780
+ * URLs come from `getAppUrl()` (ARC1_PUBLIC_URL / VCAP), never from the request
781
+ * `Host` header, so a spoofed Host cannot redirect clients at an attacker's IdP.
782
+ * The SDK's `metadataHandler` supplies `cors()` + GET/OPTIONS-only, the same
783
+ * treatment the XSUAA branch's metadata gets — the document is public by design.
784
+ */
785
+ export async function mountOidcResourceMetadata(app, config, bindHost, port) {
786
+ const { metadataHandler } = await import('@modelcontextprotocol/sdk/server/auth/handlers/metadata.js');
787
+ const { getAppUrl } = await import('./app-url.js');
788
+ const parsed = new URL(getAppUrl() ?? `http://${bindHost}:${port}`);
789
+ const basePath = parsed.pathname.replace(/\/$/, ''); // '' for root, '/arc1' behind a prefix proxy
790
+ const publicBase = `${parsed.origin}${basePath}`;
791
+ const metadata = buildOidcResourceMetadata(config, publicBase);
792
+ // Order matters: '/.well-known/oauth-protected-resource' is a mount PREFIX, so
793
+ // it must come after the '/mcp' route or it would swallow it.
794
+ app.use('/.well-known/oauth-protected-resource/mcp', metadataHandler(metadata)); // RFC 9728 §3.1 path insertion
795
+ if (basePath)
796
+ app.use(`/.well-known/oauth-protected-resource${basePath}/mcp`, metadataHandler(metadata));
797
+ // Root fallback (spec 2025-11-25 lists it; the SDK client probes sub-path → root).
798
+ // As a prefix mount it also answers unknown sub-paths — harmless: clients validate
799
+ // `resource` against the URL they called, and OIDC mode has no other MCP routes.
800
+ app.use('/.well-known/oauth-protected-resource', metadataHandler(metadata));
801
+ const resourceMetadataUrl = `${publicBase}/.well-known/oauth-protected-resource/mcp`;
802
+ // Key is `issuer`, not `authorizationServer` — the logger redacts any key
803
+ // containing "authorization", which would hide the one value operators need.
804
+ logger.info('OIDC protected-resource metadata enabled', {
805
+ resource: metadata.resource,
806
+ issuer: config.oidcIssuer,
807
+ scopesSupported: config.oidcScopes?.length ?? 0,
808
+ });
809
+ return resourceMetadataUrl;
810
+ }
601
811
  // ─── Standard Mode Verifier ─────────────────────────────────────────
602
812
  /**
603
813
  * Create a token verifier for standard auth mode (API key + OIDC).