@thotischner/observability-mcp 1.8.1 → 3.0.0

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 (169) hide show
  1. package/dist/analysis/history.d.ts +70 -0
  2. package/dist/analysis/history.js +170 -0
  3. package/dist/analysis/history.test.d.ts +1 -0
  4. package/dist/analysis/history.test.js +141 -0
  5. package/dist/audit/log.d.ts +9 -0
  6. package/dist/audit/log.js +20 -0
  7. package/dist/audit/redaction-bypass.d.ts +67 -0
  8. package/dist/audit/redaction-bypass.js +64 -0
  9. package/dist/audit/redaction-bypass.test.d.ts +1 -0
  10. package/dist/audit/redaction-bypass.test.js +72 -0
  11. package/dist/audit/sinks/types.d.ts +18 -0
  12. package/dist/audit/sinks/types.js +1 -0
  13. package/dist/audit/sinks/webhook.d.ts +45 -0
  14. package/dist/audit/sinks/webhook.js +111 -0
  15. package/dist/audit/sinks/webhook.test.d.ts +1 -0
  16. package/dist/audit/sinks/webhook.test.js +162 -0
  17. package/dist/auth/credentials.d.ts +11 -0
  18. package/dist/auth/credentials.js +27 -0
  19. package/dist/auth/credentials.test.js +21 -1
  20. package/dist/auth/csrf.d.ts +26 -0
  21. package/dist/auth/csrf.js +128 -0
  22. package/dist/auth/csrf.test.d.ts +1 -0
  23. package/dist/auth/csrf.test.js +143 -0
  24. package/dist/auth/local-users.d.ts +6 -0
  25. package/dist/auth/local-users.js +11 -0
  26. package/dist/auth/local-users.test.js +41 -0
  27. package/dist/auth/middleware.d.ts +7 -6
  28. package/dist/auth/oidc/dcr.d.ts +70 -0
  29. package/dist/auth/oidc/dcr.js +160 -0
  30. package/dist/auth/oidc/dcr.test.d.ts +1 -0
  31. package/dist/auth/oidc/dcr.test.js +109 -0
  32. package/dist/auth/oidc/endpoints.js +44 -0
  33. package/dist/auth/oidc/profiles.d.ts +22 -0
  34. package/dist/auth/oidc/profiles.js +95 -0
  35. package/dist/auth/oidc/profiles.test.d.ts +1 -0
  36. package/dist/auth/oidc/profiles.test.js +51 -0
  37. package/dist/auth/oidc/runtime.d.ts +3 -0
  38. package/dist/auth/oidc/runtime.js +16 -3
  39. package/dist/auth/oidc/runtime.test.js +1 -0
  40. package/dist/auth/policy/batch-dry-run.d.ts +56 -0
  41. package/dist/auth/policy/batch-dry-run.js +129 -0
  42. package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
  43. package/dist/auth/policy/batch-dry-run.test.js +140 -0
  44. package/dist/auth/policy/engine.d.ts +20 -4
  45. package/dist/auth/policy/engine.js +16 -2
  46. package/dist/auth/policy/loader.d.ts +11 -1
  47. package/dist/auth/policy/loader.js +37 -0
  48. package/dist/auth/policy/loader.test.d.ts +1 -0
  49. package/dist/auth/policy/loader.test.js +86 -0
  50. package/dist/auth/policy/opa.d.ts +5 -5
  51. package/dist/auth/policy/opa.js +25 -14
  52. package/dist/auth/policy/opa.test.js +48 -0
  53. package/dist/auth/rbac.d.ts +23 -1
  54. package/dist/auth/rbac.js +43 -1
  55. package/dist/auth/rbac.test.js +62 -0
  56. package/dist/cli/index.js +3 -0
  57. package/dist/cli/inspector-config.d.ts +9 -0
  58. package/dist/cli/inspector-config.js +28 -0
  59. package/dist/cli/inspector-config.test.d.ts +1 -0
  60. package/dist/cli/inspector-config.test.js +33 -0
  61. package/dist/cli/lib.d.ts +1 -1
  62. package/dist/cli/lib.js +1 -0
  63. package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
  64. package/dist/conformance/mcp-2025-11-25.test.js +206 -0
  65. package/dist/connectors/interface.d.ts +5 -1
  66. package/dist/connectors/loader.js +6 -4
  67. package/dist/connectors/loader.test.d.ts +1 -0
  68. package/dist/connectors/loader.test.js +78 -0
  69. package/dist/connectors/prometheus.test.js +31 -13
  70. package/dist/connectors/registry.d.ts +13 -0
  71. package/dist/connectors/registry.js +30 -0
  72. package/dist/connectors/registry.test.js +56 -2
  73. package/dist/context.d.ts +32 -0
  74. package/dist/context.js +35 -0
  75. package/dist/context.test.d.ts +1 -0
  76. package/dist/context.test.js +58 -0
  77. package/dist/federation/registry.d.ts +32 -0
  78. package/dist/federation/registry.js +77 -0
  79. package/dist/federation/registry.test.d.ts +1 -0
  80. package/dist/federation/registry.test.js +130 -0
  81. package/dist/federation/upstream.d.ts +60 -0
  82. package/dist/federation/upstream.js +114 -0
  83. package/dist/index.js +1188 -120
  84. package/dist/middleware/ssrfGuard.d.ts +15 -0
  85. package/dist/middleware/ssrfGuard.js +103 -0
  86. package/dist/middleware/ssrfGuard.test.d.ts +1 -0
  87. package/dist/middleware/ssrfGuard.test.js +81 -0
  88. package/dist/observability/otel.d.ts +20 -0
  89. package/dist/observability/otel.js +118 -0
  90. package/dist/observability/otel.test.d.ts +1 -0
  91. package/dist/observability/otel.test.js +56 -0
  92. package/dist/openapi.js +215 -7
  93. package/dist/openapi.test.js +34 -0
  94. package/dist/postmortem/synthesizer.d.ts +83 -0
  95. package/dist/postmortem/synthesizer.js +205 -0
  96. package/dist/postmortem/synthesizer.test.d.ts +1 -0
  97. package/dist/postmortem/synthesizer.test.js +141 -0
  98. package/dist/products/loader.d.ts +31 -3
  99. package/dist/products/loader.js +77 -4
  100. package/dist/products/loader.test.js +90 -1
  101. package/dist/quota/charge.d.ts +28 -0
  102. package/dist/quota/charge.js +30 -0
  103. package/dist/quota/charge.test.d.ts +1 -0
  104. package/dist/quota/charge.test.js +83 -0
  105. package/dist/quota/limiter.d.ts +29 -4
  106. package/dist/quota/limiter.js +64 -8
  107. package/dist/quota/limiter.test.js +86 -0
  108. package/dist/scim/group-role-map.d.ts +4 -0
  109. package/dist/scim/group-role-map.js +33 -0
  110. package/dist/scim/group-role-map.test.d.ts +1 -0
  111. package/dist/scim/group-role-map.test.js +33 -0
  112. package/dist/scim/routes.d.ts +15 -0
  113. package/dist/scim/routes.js +249 -0
  114. package/dist/scim/store.d.ts +37 -0
  115. package/dist/scim/store.js +178 -0
  116. package/dist/scim/store.test.d.ts +1 -0
  117. package/dist/scim/store.test.js +121 -0
  118. package/dist/scim/types.d.ts +73 -0
  119. package/dist/scim/types.js +29 -0
  120. package/dist/sdk/hooks.d.ts +77 -0
  121. package/dist/sdk/hooks.js +72 -0
  122. package/dist/sdk/hooks.test.d.ts +1 -0
  123. package/dist/sdk/hooks.test.js +159 -0
  124. package/dist/sdk/index.d.ts +2 -0
  125. package/dist/sdk/index.js +1 -0
  126. package/dist/sdk/manifest-schema.d.ts +17 -0
  127. package/dist/sdk/manifest-schema.js +21 -0
  128. package/dist/tools/context-seam.test.js +6 -1
  129. package/dist/tools/detect-anomalies.d.ts +1 -1
  130. package/dist/tools/detect-anomalies.js +5 -4
  131. package/dist/tools/generate-postmortem.d.ts +35 -0
  132. package/dist/tools/generate-postmortem.js +191 -0
  133. package/dist/tools/get-anomaly-history.d.ts +35 -0
  134. package/dist/tools/get-anomaly-history.js +126 -0
  135. package/dist/tools/get-service-health.d.ts +1 -1
  136. package/dist/tools/get-service-health.js +4 -3
  137. package/dist/tools/list-services.d.ts +1 -1
  138. package/dist/tools/list-services.js +3 -2
  139. package/dist/tools/list-sources.d.ts +1 -1
  140. package/dist/tools/list-sources.js +6 -2
  141. package/dist/tools/query-logs.d.ts +1 -1
  142. package/dist/tools/query-logs.js +2 -2
  143. package/dist/tools/query-metrics.d.ts +1 -1
  144. package/dist/tools/query-metrics.js +19 -6
  145. package/dist/tools/query-traces.d.ts +47 -0
  146. package/dist/tools/query-traces.js +145 -0
  147. package/dist/tools/query-traces.test.d.ts +1 -0
  148. package/dist/tools/query-traces.test.js +110 -0
  149. package/dist/tools/registry-names.d.ts +35 -0
  150. package/dist/tools/registry-names.js +54 -0
  151. package/dist/tools/registry-names.test.d.ts +1 -0
  152. package/dist/tools/registry-names.test.js +61 -0
  153. package/dist/tools/topology.d.ts +3 -3
  154. package/dist/tools/topology.js +10 -6
  155. package/dist/topology/merge.d.ts +22 -0
  156. package/dist/topology/merge.js +178 -0
  157. package/dist/topology/merge.test.d.ts +1 -0
  158. package/dist/topology/merge.test.js +110 -0
  159. package/dist/transport/sessionStore.d.ts +66 -0
  160. package/dist/transport/sessionStore.js +138 -0
  161. package/dist/transport/sessionStore.test.d.ts +1 -0
  162. package/dist/transport/sessionStore.test.js +118 -0
  163. package/dist/transport/websocket.d.ts +35 -0
  164. package/dist/transport/websocket.js +133 -0
  165. package/dist/transport/websocket.test.d.ts +1 -0
  166. package/dist/transport/websocket.test.js +124 -0
  167. package/dist/types.d.ts +51 -0
  168. package/dist/ui/index.html +1729 -100
  169. package/package.json +13 -3
package/dist/index.js CHANGED
@@ -9,35 +9,54 @@ import { z } from "zod";
9
9
  import { loadConfig, saveConfig, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_SETTINGS } from "./config/loader.js";
10
10
  import { ConnectorRegistry, getSupportedTypes } from "./connectors/registry.js";
11
11
  import { isTopologyProvider } from "./connectors/interface.js";
12
- import { defaultContext, principalContext } from "./context.js";
12
+ import { defaultContext, principalContext, sessionContext, allowsTool } from "./context.js";
13
+ import { parseKeyTenants } from "./tenancy/context.js";
13
14
  import { enforceEntitledAccess, enterpriseGateStatus, enterpriseGateInfo, enterprisePolicyView, enterpriseCatalogView, enterpriseAuditTail, authorizeAdmin, updateRbacPolicy, updateCatalog, } from "./enterprise-gate.js";
14
15
  import { loadCredentials, credentialsConfigured, extractToken, resolveToken, } from "./auth/credentials.js";
15
16
  import { issueSession, setCookieHeader, clearCookieHeader, generateSecret, } from "./auth/session.js";
16
- import { readUsersFile, authenticate, } from "./auth/local-users.js";
17
+ import { readUsersFile, writeUsersFile, authenticate, } from "./auth/local-users.js";
17
18
  import { buildSessionAttacher, buildRequireSession, } from "./auth/middleware.js";
18
- import { buildRequirePermission, hasPermission, listGrantedPermissions, DEFAULT_POLICY, } from "./auth/rbac.js";
19
+ import { buildRequirePermissionFromEngine, hasPermission, listGrantedPermissions, DEFAULT_POLICY, } from "./auth/rbac.js";
19
20
  import { resolveOidcConfig, buildOidcRuntime } from "./auth/oidc/runtime.js";
20
21
  import { registerOidcRoutes } from "./auth/oidc/endpoints.js";
22
+ import { ScimStore } from "./scim/store.js";
23
+ import { registerScimRoutes } from "./scim/routes.js";
21
24
  import { BuiltinPolicyEngine } from "./auth/policy/engine.js";
22
- import { loadPolicyFromFile, PolicyLoadError, VALID_RESOURCES, VALID_ACTIONS } from "./auth/policy/loader.js";
25
+ import { loadPolicyFromFile, writePolicyFile, PolicyLoadError, VALID_RESOURCES, VALID_ACTIONS } from "./auth/policy/loader.js";
23
26
  import { OpaPolicyEngine } from "./auth/policy/opa.js";
27
+ import { evaluateBatch, batchResultToCsv } from "./auth/policy/batch-dry-run.js";
24
28
  import { AuditLog } from "./audit/log.js";
25
29
  import { buildAuditMiddleware } from "./audit/middleware.js";
30
+ import { WebhookSink } from "./audit/sinks/webhook.js";
31
+ import { buildBypassBreadcrumb, buildBypassAuditParams } from "./audit/redaction-bypass.js";
26
32
  import { readCatalogFile, CatalogStore } from "./catalog/loader.js";
27
33
  import { readProductsFile, ProductsStore, validateProduct, writeProductsFile, ProductsLoadError } from "./products/loader.js";
34
+ import { REGISTERED_TOOL_NAMES, REGISTERED_TOOLS, unknownToolNames } from "./tools/registry-names.js";
28
35
  import { redactValue } from "./policy/redact.js";
29
- import { IdentityRateLimiter, resolveToolRatePerMin } from "./quota/limiter.js";
36
+ import { IdentityRateLimiter, resolveToolRatePerMin, parseKeyRateLimits } from "./quota/limiter.js";
30
37
  import { TokenBudget, estimateTokensFor, resolveDailyTokenLimit } from "./quota/token-budget.js";
38
+ import { applyBudgetDecision } from "./quota/charge.js";
31
39
  import { getPluginLoader } from "./connectors/loader.js";
32
40
  import { resolveHubCatalogUrl, describeInstalled, mergeCatalog, fetchHubCatalog, } from "./connectors/hub.js";
33
41
  import { isValidConnectorName, installTarball } from "./connectors/install.js";
34
42
  import { PluginVerificationError } from "./connectors/verify.js";
35
43
  import { selfRegistry, withToolMetrics, apiRequests, mcpActiveSessions } from "./metrics/self.js";
44
+ import { initOtel } from "./observability/otel.js";
45
+ import { WebSocketServerTransport } from "./transport/websocket.js";
46
+ import { HookRegistry } from "./sdk/hooks.js";
47
+ import { UpstreamClient } from "./federation/upstream.js";
48
+ import { FederationRegistry, parseFederationEnv } from "./federation/registry.js";
49
+ import { buildCsrfIssuer, buildCsrfEnforcer, csrfBypassFromEnv } from "./auth/csrf.js";
50
+ import { checkOutboundUrl, ssrfGuardFromEnv } from "./middleware/ssrfGuard.js";
36
51
  import { buildOpenApiSpec } from "./openapi.js";
37
52
  import { listSourcesHandler } from "./tools/list-sources.js";
38
53
  import { listServicesHandler } from "./tools/list-services.js";
39
54
  import { queryMetricsHandler } from "./tools/query-metrics.js";
40
55
  import { queryLogsHandler } from "./tools/query-logs.js";
56
+ import { queryTracesHandler } from "./tools/query-traces.js";
57
+ import { getAnomalyHistoryHandler } from "./tools/get-anomaly-history.js";
58
+ import { generatePostmortemHandler } from "./tools/generate-postmortem.js";
59
+ import { AnomalyHistory, fromEnv as anomalyHistoryFromEnv } from "./analysis/history.js";
41
60
  import { getServiceHealthHandler, setHealthThresholds } from "./tools/get-service-health.js";
42
61
  import { detectAnomaliesHandler } from "./tools/detect-anomalies.js";
43
62
  import { getTopologyHandler, getBlastRadiusHandler } from "./tools/topology.js";
@@ -82,15 +101,7 @@ function qstr(v) {
82
101
  * so a downstream investigator can join the two channels there.
83
102
  */
84
103
  function emitBypassEvent(event, ctx, args) {
85
- console.error(JSON.stringify({
86
- event,
87
- ts: new Date().toISOString(),
88
- auth: ctx.auth,
89
- tool: "query_logs",
90
- service: args?.service ?? null,
91
- correlationId: ctx.correlationId,
92
- ...(event === "redaction_bypass_denied" ? { reason: "credential_not_in_OMCP_KEY_BYPASS_REDACTION" } : {}),
93
- }));
104
+ console.error(JSON.stringify(buildBypassBreadcrumb(event, ctx, args)));
94
105
  }
95
106
  /** Bridge from the new PolicyEngine to the existing
96
107
  * hasPermission/buildRequirePermission signatures (which still take
@@ -127,21 +138,25 @@ function getAvailableMetricNames(registry) {
127
138
  }
128
139
  /** Validate source URL: must be http/https, reject obviously dangerous targets */
129
140
  function validateSourceUrl(url) {
141
+ // Phase F11: delegate to the shared SSRF guard. Strict by default;
142
+ // operators add OMCP_ALLOW_PRIVATE_BACKENDS=true to allow in-cluster
143
+ // backends. Cloud-metadata IPs (AWS 169.254.169.254, GCE
144
+ // fd00:ec2::254) are rejected regardless.
145
+ const v = checkOutboundUrl(url, ssrfGuardFromEnv());
146
+ if (!v.allow)
147
+ return v.reason ?? `URL "${url}" is rejected by the SSRF guard`;
148
+ // Extra Google-metadata-hostname check (DNS-based, not in the
149
+ // numeric guard).
130
150
  try {
131
- const parsed = new URL(url);
132
- if (!["http:", "https:"].includes(parsed.protocol)) {
133
- return `Invalid URL scheme "${parsed.protocol}". Only http and https are allowed.`;
134
- }
135
- // Block cloud metadata endpoints
136
- const host = parsed.hostname.toLowerCase();
137
- if (host === "169.254.169.254" || host === "metadata.google.internal") {
151
+ const host = new URL(url).hostname.toLowerCase();
152
+ if (host === "metadata.google.internal") {
138
153
  return "Access to cloud metadata endpoints is not allowed.";
139
154
  }
140
- return null;
141
155
  }
142
156
  catch {
143
- return `Invalid URL: "${url}"`;
157
+ /* already caught by checkOutboundUrl */
144
158
  }
159
+ return null;
145
160
  }
146
161
  // Hard cap for a downloaded/uploaded connector tarball (defence against
147
162
  // a hostile or accidental huge artifact OOM-ing the server).
@@ -169,6 +184,13 @@ async function main() {
169
184
  if (STDIO) {
170
185
  console.log = (...a) => console.error(...a);
171
186
  }
187
+ // OpenTelemetry self-tracing — opt-in via OMCP_OTEL_ENABLED. Init
188
+ // before express() so HTTP auto-instrumentation captures every
189
+ // /api/* and /mcp request. Skipped in stdio mode (no HTTP surface
190
+ // and the auto-instrumentation would emit noise per stdio call).
191
+ if (!STDIO) {
192
+ await initOtel({ serviceVersion: process.env.npm_package_version });
193
+ }
172
194
  let config = loadConfig();
173
195
  await getPluginLoader().load();
174
196
  const registry = new ConnectorRegistry();
@@ -238,37 +260,7 @@ async function main() {
238
260
  const text = result.content[0]?.text ?? "";
239
261
  const tokens = estimateTokensFor(text);
240
262
  const decision = tokenBudget.check(identityKey(ctx), tokens);
241
- if (decision.allowed || decision.limit === 0)
242
- return result;
243
- // A single request larger than the entire daily cap can never
244
- // succeed by waiting — surface a distinct error code so the
245
- // agent doesn't loop. Otherwise the wait-then-retry path is the
246
- // right answer (and freedAtRetry tells the agent how much they
247
- // can request after the wait).
248
- const requestExceedsCap = tokens > decision.limit;
249
- const errBody = {
250
- error: requestExceedsCap ? "OMCP_TOKEN_REQUEST_EXCEEDS_BUDGET" : "OMCP_TOKEN_BUDGET_EXCEEDED",
251
- tool: toolName,
252
- used: decision.used,
253
- limit: decision.limit,
254
- requested: tokens,
255
- retryAfterSeconds: requestExceedsCap ? 0 : decision.retryAfterSeconds,
256
- freedAtRetry: decision.freedAtRetry,
257
- message: requestExceedsCap
258
- ? `This single response (~${tokens} tokens) is larger than the entire daily budget (${decision.limit}). Retrying won't help — narrow the query (smaller window / lower limit / more selective filter) or raise OMCP_TOOL_DAILY_TOKENS.`
259
- : `Daily token budget exceeded (${decision.used}/${decision.limit} tokens used in the trailing 24h; this call would have added ~${tokens}). Try again in ~${Math.ceil(decision.retryAfterSeconds / 3600)}h or raise OMCP_TOOL_DAILY_TOKENS.`,
260
- };
261
- // Preserve any additional content entries (e.g. a future
262
- // tool returning [text, image]) — only the text payload of the
263
- // first entry is replaced with the error JSON; everything after
264
- // it passes through.
265
- return {
266
- ...result,
267
- content: [
268
- { ...result.content[0], text: JSON.stringify(errBody) },
269
- ...result.content.slice(1),
270
- ],
271
- };
263
+ return applyBudgetDecision(result, decision, tokens, toolName);
272
264
  }
273
265
  const REDACTION_ENABLED = String(process.env.OMCP_REDACTION ?? "on").toLowerCase() !== "off";
274
266
  function redactToolText(result, opts = {}) {
@@ -309,7 +301,53 @@ async function main() {
309
301
  version: SERVER_VERSION,
310
302
  });
311
303
  // --- Register tools with Zod schemas ---
312
- mcpServer.tool("list_sources", [
304
+ // Product-aware registration: when the active credential is bound
305
+ // to a Product (OMCP_KEY_PRODUCTS), `ctx.allowedTools` carries that
306
+ // Product's `tools` allow-list and we skip the registration of any
307
+ // tool not in it. Anonymous + Product-less sessions leave
308
+ // allowedTools undefined and see every tool — the bypass is the
309
+ // back-compat path the open-source default relies on.
310
+ //
311
+ // The wrapper also wires Phase F7 hook fan-out: every tool dispatch
312
+ // fires tool_pre_invoke before the handler and tool_post_invoke after.
313
+ // Plugins can deny the call (allow:false → isError CallToolResult),
314
+ // mutate the args before dispatch, or mutate the result before it
315
+ // reaches the caller. When no hooks are registered (the default in
316
+ // the OSS demo) the wrapper is a thin pass-through.
317
+ const registerTool = ((name, ...rest) => {
318
+ if (!allowsTool(ctx.allowedTools, name))
319
+ return undefined;
320
+ if (rest.length > 0 && typeof rest[rest.length - 1] === "function") {
321
+ const originalHandler = rest[rest.length - 1];
322
+ const wrappedHandler = async (args, extra) => {
323
+ const hookCtxBase = {
324
+ principal: ctx.principalId,
325
+ tenant: ctx.tenant || "default",
326
+ target: name,
327
+ };
328
+ const pre = await hookRegistry.fire("tool_pre_invoke", { ...hookCtxBase, kind: "tool_pre_invoke" }, { args });
329
+ if (!pre.allow) {
330
+ return {
331
+ content: [{ type: "text", text: pre.reason ?? "denied by plugin hook" }],
332
+ isError: true,
333
+ };
334
+ }
335
+ const effectiveArgs = pre.payload?.args ?? args;
336
+ const result = await originalHandler(effectiveArgs, extra);
337
+ const post = await hookRegistry.fire("tool_post_invoke", { ...hookCtxBase, kind: "tool_post_invoke" }, { args: effectiveArgs, result });
338
+ if (!post.allow) {
339
+ return {
340
+ content: [{ type: "text", text: post.reason ?? "denied by plugin hook" }],
341
+ isError: true,
342
+ };
343
+ }
344
+ return post.payload?.result ?? result;
345
+ };
346
+ rest[rest.length - 1] = wrappedHandler;
347
+ }
348
+ return mcpServer.tool(name, ...rest);
349
+ });
350
+ registerTool("list_sources", [
313
351
  "List the configured observability backends (Prometheus, Loki, and any connector) and whether each is currently reachable.",
314
352
  "When to use: call this first to learn which source names exist and are healthy before passing `source` to other tools, or to debug why a query returns no data.",
315
353
  "Behavior: read-only, no side effects. Returns one entry per source with its name, type, configured URL, signal types (metrics/logs), and a live up/down status. Never throws for an unreachable backend — the backend is reported as down instead.",
@@ -318,7 +356,7 @@ async function main() {
318
356
  await enforceEntitledAccess(ctx, { tool: "list_sources" });
319
357
  return withToolMetrics("list_sources", () => listSourcesHandler(registry, ctx));
320
358
  });
321
- mcpServer.tool("list_services", [
359
+ registerTool("list_services", [
322
360
  "Discover the service names that can be queried, aggregated across every connected backend.",
323
361
  "When to use: call this before `query_metrics`, `query_logs`, or `get_service_health` to obtain the exact, case-sensitive service name those tools require.",
324
362
  "Behavior: read-only, no side effects. Returns one entry per service with the service name, the source(s) it was discovered in, and which signals are available for it (metrics, logs, or both).",
@@ -336,7 +374,7 @@ async function main() {
336
374
  const metricsList = getAvailableMetricNames(registry);
337
375
  const metricNames = registry.getBySignal("metrics").flatMap(c => c.getMetrics().map(m => m.name));
338
376
  const uniqueNames = [...new Set(metricNames)];
339
- mcpServer.tool("query_metrics", [
377
+ registerTool("query_metrics", [
340
378
  "Fetch the raw time-series for ONE metric of ONE service over a look-back window, returned together with pre-computed summary statistics.",
341
379
  "When to use: when you need the actual numeric values or the trend of a known metric. For a 'is this service OK?' verdict use `get_service_health`; to find which services are misbehaving use `detect_anomalies`.",
342
380
  "Prerequisites: get the exact service name from `list_services` and choose a metric from the list at the end of this description.",
@@ -366,7 +404,7 @@ async function main() {
366
404
  const result = await withToolMetrics("query_metrics", () => queryMetricsHandler(registry, args, ctx));
367
405
  return chargeTokenBudget(result, ctx, "query_metrics");
368
406
  });
369
- mcpServer.tool("query_logs", [
407
+ registerTool("query_logs", [
370
408
  "Fetch recent log entries for ONE service over a look-back window, with a pre-computed summary (error/warning counts and the most frequent error patterns).",
371
409
  "When to use: to inspect what a service actually logged, or to investigate an error spike surfaced by `detect_anomalies` / `get_service_health`. For numeric metrics use `query_metrics` instead.",
372
410
  "Prerequisites: get the exact service name from `list_services` (the service must expose a logs signal).",
@@ -419,16 +457,7 @@ async function main() {
419
457
  // invocation is tamper-evident alongside the rest of
420
458
  // /api/*. Persists if OMCP_MGMT_AUDIT_FILE is set.
421
459
  emitBypassEvent(bypass ? "redaction_bypass_engaged" : "redaction_bypass_denied", ctx, args);
422
- void mgmtAudit.record({
423
- actor: { sub: ctx.principalId },
424
- tenant: ctx.tenant,
425
- resource: "redaction",
426
- action: "bypass",
427
- method: "MCP",
428
- path: "/mcp/query_logs",
429
- status: bypass ? 200 : 403,
430
- target: args?.service ?? undefined,
431
- }).catch(() => {
460
+ void mgmtAudit.record(buildBypassAuditParams(bypass, ctx, args)).catch(() => {
432
461
  // Audit record is best-effort — losing one entry must not
433
462
  // crash the tool call. The chain itself remains intact.
434
463
  });
@@ -436,7 +465,54 @@ async function main() {
436
465
  const redacted = redactToolText(result, { bypass });
437
466
  return chargeTokenBudget(redacted, ctx, "query_logs");
438
467
  });
439
- mcpServer.tool("get_service_health", [
468
+ registerTool("get_anomaly_history", [
469
+ "Replay historical anomaly scores for a service from the TSDB the gateway writes to (omcp_anomaly_score series).",
470
+ "When to use: post-mortem reconstruction, trend analysis on detector noise, or pulling context for the LLM when an incident is reviewed after the fact.",
471
+ "Prerequisites: the operator must have OMCP_ANOMALY_HISTORY_REMOTE_WRITE configured AND a Prometheus source pointed at the same TSDB so the round-trip closes.",
472
+ "Behavior: read-only. Returns the time-series of scores. Empty result means either no anomalies in the window or history is disabled.",
473
+ "Related: `detect_anomalies` for the live scores; `query_metrics` if you want to write the PromQL by hand.",
474
+ ].join(" "), {
475
+ service: z.string().describe("Service name to filter on."),
476
+ duration: z.string().optional().describe("Rolling window, e.g. '1h', '24h'. Default '1h'."),
477
+ method: z.string().optional().describe("Filter by detector method ('mad' / 'seasonality' / 'correlator'). Optional."),
478
+ }, async (args) => {
479
+ await enforceEntitledAccess(ctx, { tool: "get_anomaly_history", service: args?.service });
480
+ const result = await withToolMetrics("get_anomaly_history", () => getAnomalyHistoryHandler(registry, args, ctx));
481
+ return chargeTokenBudget(result, ctx, "get_anomaly_history");
482
+ });
483
+ registerTool("generate_postmortem", [
484
+ "Stitch the gateway's primitives (anomaly history, blast-radius, traces, log highlights) into a single markdown post-mortem report for one service over a given window.",
485
+ "When to use: after an incident, when the operator or LLM wants 'one document the on-call can read in 60 seconds' instead of poking the individual tools.",
486
+ "Prerequisites: anomaly history requires OMCP_ANOMALY_HISTORY_REMOTE_WRITE + a Prometheus source. Traces require Tempo / Jaeger. Blast-radius requires a topology provider.",
487
+ "Behavior: read-only. Returns markdown by default; pass `format='json'` for the structured shape. Output capped (timeline 20 rows, blast-radius 30 nodes, 10 traces) — JSON shape carries the full data.",
488
+ "Related: `get_anomaly_history`, `query_traces`, `get_blast_radius` for the underlying primitives.",
489
+ ].join(" "), {
490
+ service: z.string().describe("Suspected root-cause service."),
491
+ duration: z.string().optional().describe("Window length, e.g. '1h', '6h'. Default '1h'."),
492
+ format: z.enum(["markdown", "json"]).optional().describe("'markdown' (default) or 'json'."),
493
+ }, async (args) => {
494
+ await enforceEntitledAccess(ctx, { tool: "generate_postmortem", service: args?.service });
495
+ const result = await withToolMetrics("generate_postmortem", () => generatePostmortemHandler(registry, args, ctx));
496
+ return chargeTokenBudget(result, ctx, "generate_postmortem");
497
+ });
498
+ registerTool("query_traces", [
499
+ "Query distributed traces for a service over a given timeframe.",
500
+ "Returns ranked trace summaries (duration, span count, error status) with a p50/p95 aggregate across the returned set.",
501
+ "When to use: investigate tail-latency outliers, walk call chains across services for a specific time window, or pull traces related to an anomaly that the metric/log tools surfaced first.",
502
+ "Prerequisites: get the exact service name from `list_services`. A Tempo / Jaeger / OTLP connector must be configured.",
503
+ "Behavior: read-only. `filter` accepts the backend's native query language (TraceQL on Tempo, tag query on Jaeger). When `errorsOnly=true`, only traces with at least one error span are returned. Default limit is 50.",
504
+ ].join(" "), {
505
+ service: z.string().describe("Service name (e.g. 'payment-service')."),
506
+ duration: z.string().optional().describe("Rolling time window, e.g. '5m', '1h'. Default '15m'."),
507
+ filter: z.string().optional().describe("Backend-native filter (TraceQL on Tempo, tag query on Jaeger). Optional."),
508
+ limit: z.number().int().positive().optional().describe("Soft cap on returned trace summaries. Default 50."),
509
+ errorsOnly: z.boolean().optional().describe("If true, only traces with at least one error span."),
510
+ }, async (args) => {
511
+ await enforceEntitledAccess(ctx, { tool: "query_traces", service: args?.service });
512
+ const result = await withToolMetrics("query_traces", () => queryTracesHandler(registry, args, ctx));
513
+ return chargeTokenBudget(result, ctx, "query_traces");
514
+ });
515
+ registerTool("get_service_health", [
440
516
  "Produce a single aggregated health verdict for ONE service by combining its metrics and logs.",
441
517
  "When to use: the fastest way to answer 'is this service healthy right now and why?'. Use `query_metrics`/`query_logs` to drill into the underlying numbers, or `detect_anomalies` to scan many services at once.",
442
518
  "Prerequisites: get the exact service name from `list_services`.",
@@ -451,7 +527,7 @@ async function main() {
451
527
  const enriched = enrichToolHealthText(result, String(args?.service ?? ""), ctx);
452
528
  return chargeTokenBudget(enriched, ctx, "get_service_health");
453
529
  });
454
- mcpServer.tool("detect_anomalies", [
530
+ registerTool("detect_anomalies", [
455
531
  "Scan one or all monitored services for abnormal behavior and return the findings ranked by severity.",
456
532
  "When to use: the entry point for 'is anything wrong anywhere?' triage. Once a service is flagged, follow up with `get_service_health` for the verdict or `query_metrics`/`query_logs` for the raw evidence.",
457
533
  "Behavior: read-only, no side effects. Applies z-score analysis to metrics, detects log error-rate spikes, and correlates the two. Returns a list of anomalies, each with the affected service, metric/signal, severity, the deviation (e.g. σ and % change), and a short explanation. No anomalies yields an empty list, not an error.",
@@ -473,7 +549,7 @@ async function main() {
473
549
  await enforceEntitledAccess(ctx, { tool: "detect_anomalies", source: args?.source, service: args?.service });
474
550
  return withToolMetrics("detect_anomalies", () => detectAnomaliesHandler(registry, args, ctx));
475
551
  });
476
- mcpServer.tool("get_topology", [
552
+ registerTool("get_topology", [
477
553
  "Return the infrastructure topology graph (Resources and Edges) from every topology-capable connector.",
478
554
  "When to use: when an agent needs to reason about which workload runs on which host, who owns whom, or which scope (namespace/project/folder) a resource belongs to. Pair with `get_blast_radius` for shared-host RCA.",
479
555
  "Behavior: read-only, no side effects. Returns `{ sources, resources, edges, total, truncated }`. Filters compose: `source` to one connector, `kind` to one resource type (e.g. 'pod', 'node', 'deployment'), `scope` to members of a namespace/folder/project. Output is capped by `limit` (default 500, max 5000) and edges referencing dropped resources are removed.",
@@ -502,7 +578,7 @@ async function main() {
502
578
  await enforceEntitledAccess(ctx, { tool: "get_topology", source: args?.source });
503
579
  return withToolMetrics("get_topology", () => getTopologyHandler(registry, args, ctx));
504
580
  });
505
- mcpServer.tool("get_blast_radius", [
581
+ registerTool("get_blast_radius", [
506
582
  "Given a resource, return who else fails if its underlying host(s) fail.",
507
583
  "When to use: cross-cutting RCA — when several services degrade together and you suspect a shared host. Works for any RUNS_ON relationship: pod→node, vm→hypervisor, container→host.",
508
584
  "Behavior: read-only, no side effects. Resolves `resource` to a Resource (accepts canonical id, exact name, or unique substring), determines its host(s) via RUNS_ON, then lists every other resource that runs on those hosts, bucketed by ownership root (the terminal `OWNED_BY` target — e.g. the Deployment, not the ReplicaSet). If the target is itself a host, its tenants are reported. Returns a structured error if the resource is ambiguous or unknown.",
@@ -515,6 +591,23 @@ async function main() {
515
591
  await enforceEntitledAccess(ctx, { tool: "get_blast_radius" });
516
592
  return withToolMetrics("get_blast_radius", () => getBlastRadiusHandler(registry, args, ctx));
517
593
  });
594
+ // Phase F10: federated tools — every upstream MCP server's tools
595
+ // show up here under `<prefix>.<upstream-tool>`. The handler is a
596
+ // pure proxy: it forwards args verbatim and returns the upstream's
597
+ // CallToolResult unchanged. The wrapping registerTool() at the top
598
+ // of this function still fires F7 lifecycle hooks + the F1
599
+ // Product-allow-list gate, so federated tools obey the same policy
600
+ // surface as native ones.
601
+ for (const info of federationRegistry.getNamespacedTools()) {
602
+ // Upstream's inputSchema is forwarded verbatim. The SDK's
603
+ // tool() overload signatures don't carry an obvious type for a
604
+ // dynamic-shape schema, so we cast to `any` at the boundary and
605
+ // let the upstream contract speak for the validation.
606
+ registerTool(info.namespacedName, info.description || `Federated from upstream ${info.sourceName}.`, info.inputSchema ?? {}, async (args) => {
607
+ await enforceEntitledAccess(ctx, { tool: info.namespacedName });
608
+ return withToolMetrics(info.namespacedName, () => federationRegistry.callNamespacedTool(info.namespacedName, args));
609
+ });
610
+ }
518
611
  return mcpServer;
519
612
  }
520
613
  // --- Management-plane auth (basic mode) -----------------------------------
@@ -673,6 +766,17 @@ async function main() {
673
766
  // there is no string-match-based "is this public?" branch anywhere.
674
767
  app.use(buildSessionAttacher(authRuntime));
675
768
  const requireSession = buildRequireSession(authRuntime);
769
+ // Phase F11: CSRF — double-submit cookie pattern, enforced on every
770
+ // mutating /api/* request. The issuer runs top-of-pipe so any page
771
+ // render leaves a CSRF token cookie the SPA can read + echo back.
772
+ // Bearer-token clients (CI, agents, MCP clients) bypass by default
773
+ // since they can't be a browser confused-deputy.
774
+ const csrfCfg = {
775
+ bypassBearer: csrfBypassFromEnv(),
776
+ secureCookie: (r) => r.secure || r.headers["x-forwarded-proto"] === "https",
777
+ };
778
+ app.use(buildCsrfIssuer(csrfCfg));
779
+ app.use("/api", buildCsrfEnforcer(csrfCfg));
676
780
  // Active policy engine — built-in DEFAULT_POLICY by default. When
677
781
  // OMCP_RBAC_POLICY_FILE is set we load it and ALWAYS abort on
678
782
  // failure: OMCP_AUTH_ALLOW_FALLBACK is for *auth-mode* fallback
@@ -713,22 +817,42 @@ async function main() {
713
817
  return;
714
818
  const resources = [...VALID_RESOURCES];
715
819
  const actions = [...VALID_ACTIONS];
820
+ // Tenant-aware pre-warm: the gate keys cache per
821
+ // (roles, resource, action, tenant) so a tenant-conditional
822
+ // Rego rule that fires for "acme" but not "bigco" produces a
823
+ // distinct cached verdict per tenant. The pre-warm iterates
824
+ // every known declared tenant + "default" so the first user
825
+ // request from a tenant'd identity gets a real decision
826
+ // instead of a warming-deny. OIDC tenants only known at
827
+ // runtime are still subject to first-request warming, but
828
+ // operator-set OMCP_KEY_TENANTS land here.
829
+ const knownTenants = new Set(["default"]);
830
+ // parseKeyTenants is the same parser the credentials layer
831
+ // uses, so the warm set is exactly what the gate will see.
832
+ for (const t of parseKeyTenants(process.env.OMCP_KEY_TENANTS).values()) {
833
+ if (t)
834
+ knownTenants.add(t);
835
+ }
836
+ const tenants = Array.from(knownTenants);
716
837
  const tasks = [];
717
- for (const role of roles) {
718
- for (const resource of resources)
719
- for (const action of actions) {
720
- tasks.push(opaEngine.warmEvaluate([role], resource, action));
721
- }
722
- tasks.push(opaEngine.warmList([role]));
838
+ for (const tenant of tenants) {
839
+ for (const role of roles) {
840
+ for (const resource of resources)
841
+ for (const action of actions) {
842
+ tasks.push(opaEngine.warmEvaluate([role], resource, action, tenant));
843
+ }
844
+ tasks.push(opaEngine.warmList([role], tenant));
845
+ }
723
846
  }
724
847
  try {
725
848
  const settled = await Promise.allSettled(tasks);
726
849
  const failed = settled.filter((s) => s.status === "rejected").length;
850
+ const tlbl = tenants.length === 1 ? "1 tenant" : `${tenants.length} tenants`;
727
851
  if (failed === 0) {
728
- console.log(`[auth] OPA cache pre-warmed: ${settled.length} decisions cached for ${roles.length} role(s)`);
852
+ console.log(`[auth] OPA cache pre-warmed: ${settled.length} decisions cached for ${roles.length} role(s) × ${tlbl}`);
729
853
  }
730
854
  else {
731
- console.warn(`[auth] OPA cache pre-warmed: ${settled.length - failed}/${settled.length} ok, ${failed} failed (gates will retry on first user call)`);
855
+ console.warn(`[auth] OPA cache pre-warmed: ${settled.length - failed}/${settled.length} ok, ${failed} failed across ${tlbl} (gates will retry on first user call)`);
732
856
  }
733
857
  }
734
858
  catch { /* best-effort */ }
@@ -745,20 +869,108 @@ async function main() {
745
869
  process.exit(1);
746
870
  }
747
871
  }
748
- const need = (resource, action) => buildRequirePermission(authRuntime, resource, action, policyEngineToMap(policyEngine));
872
+ // Use the engine-aware variant so tenant (session.tenant) flows into
873
+ // engine.evaluate() — required for tenant-conditional Rego rules
874
+ // (`input.tenant == "acme"` etc.) under OMCP_OPA_URL. Built-in /
875
+ // file-loaded engines ignore the tenant ctx, so the behaviour is
876
+ // unchanged for those deployments.
877
+ const need = (resource, action) => buildRequirePermissionFromEngine(authRuntime, resource, action, policyEngine);
749
878
  // Management-plane audit log. Records one entry per mutating /api/*
750
879
  // request. Writes JSONL to disk when OMCP_MGMT_AUDIT_FILE is set;
751
880
  // otherwise an in-memory ring of the last 500 entries keeps the
752
881
  // /api/audit endpoint useful in the demo / single-user case.
753
- const mgmtAudit = new AuditLog({ file: process.env.OMCP_MGMT_AUDIT_FILE });
882
+ // External audit sinks opt-in via env. Each chained entry is
883
+ // mirrored to every configured sink; the on-disk JSONL master
884
+ // remains the source of truth (the hash chain is never split).
885
+ const auditSinks = [];
886
+ if (process.env.OMCP_AUDIT_WEBHOOK_URL) {
887
+ auditSinks.push(new WebhookSink({
888
+ url: process.env.OMCP_AUDIT_WEBHOOK_URL,
889
+ token: process.env.OMCP_AUDIT_WEBHOOK_TOKEN,
890
+ deadLetterFile: process.env.OMCP_AUDIT_WEBHOOK_DLQ,
891
+ }));
892
+ console.log("AuditLog: webhook sink enabled -> %s%s", process.env.OMCP_AUDIT_WEBHOOK_URL, process.env.OMCP_AUDIT_WEBHOOK_DLQ
893
+ ? ` (DLQ: ${process.env.OMCP_AUDIT_WEBHOOK_DLQ})`
894
+ : "");
895
+ }
896
+ const mgmtAudit = new AuditLog({
897
+ file: process.env.OMCP_MGMT_AUDIT_FILE,
898
+ sinks: auditSinks,
899
+ });
754
900
  await mgmtAudit.bootstrap();
901
+ process.on("SIGTERM", () => {
902
+ mgmtAudit
903
+ .flushSinks()
904
+ .catch((err) => console.warn("AuditLog flushSinks failed:", err));
905
+ });
755
906
  const audit = (resource, action) => buildAuditMiddleware({ audit: mgmtAudit, resource, action });
907
+ // Plugin lifecycle hook registry — populated by the loader at boot
908
+ // (one entry per manifest `hooks[]` entry) and mutable at runtime
909
+ // when a connector is installed via /api/connectors/install. Each
910
+ // tool dispatch in createMcpServer fans through this registry's
911
+ // tool_pre_invoke / tool_post_invoke chains; resource and prompt
912
+ // hooks plug into their respective seams as they ship.
913
+ const hookRegistry = new HookRegistry();
914
+ // Phase F15: anomaly-history sink — opt-in via
915
+ // OMCP_ANOMALY_HISTORY_REMOTE_WRITE. When configured, anomaly
916
+ // scores written via anomalyHistory.record() flush to the
917
+ // configured TSDB on a 10-second timer. The MCP tool
918
+ // get_anomaly_history queries them back via any Prometheus source
919
+ // pointed at the same TSDB.
920
+ //
921
+ // The detector-side hook that actually records per-anomaly scores
922
+ // is plumbed in F15b (it requires passing this instance into the
923
+ // detectAnomaliesHandler — minor surgery deferred). The
924
+ // infrastructure ships now so externally-written omcp_anomaly_score
925
+ // metrics are already queryable end-to-end.
926
+ const anomalyHistory = new AnomalyHistory(anomalyHistoryFromEnv());
927
+ anomalyHistory.start();
928
+ if (anomalyHistory.isEnabled()) {
929
+ console.log("AnomalyHistory: TSDB sink enabled (OMCP_ANOMALY_HISTORY_REMOTE_WRITE set)");
930
+ }
931
+ process.on("SIGTERM", () => {
932
+ void anomalyHistory.stop().catch(() => undefined);
933
+ });
934
+ // Federation registry — populated from OMCP_FEDERATION_UPSTREAMS at
935
+ // boot. Each upstream connects, fetches tools/list, and exposes its
936
+ // tools under `<prefix>.<upstream-tool-name>` on the gateway's
937
+ // surface. Failures are logged + the upstream is left in `degraded`
938
+ // (no tools) so the gateway boots regardless of upstream health.
939
+ const federationRegistry = new FederationRegistry();
940
+ for (const cfg of parseFederationEnv()) {
941
+ const client = new UpstreamClient({
942
+ name: cfg.name,
943
+ url: cfg.url,
944
+ bearerToken: cfg.bearerToken,
945
+ });
946
+ federationRegistry.add(client);
947
+ client.connect().catch((err) => {
948
+ console.warn("federation upstream %s initial connect failed: %s", cfg.name, err instanceof Error ? err.message : String(err));
949
+ });
950
+ }
951
+ if (federationRegistry.list().length > 0) {
952
+ console.log("federation: %d upstream(s) configured: %s", federationRegistry.list().length, federationRegistry.list().map((u) => `${u.name}=${u.url}`).join(", "));
953
+ }
954
+ process.on("SIGTERM", () => {
955
+ federationRegistry
956
+ .closeAll()
957
+ .catch((err) => console.warn("federation closeAll failed:", err));
958
+ });
756
959
  // Service catalog: optional operator-curated ownership / criticality /
757
960
  // on-call metadata, keyed on the service name list_services returns.
758
961
  // No file ⇒ empty catalog, enrichment is a no-op (anonymous demos
759
962
  // see no behaviour change).
760
963
  const catalog = new CatalogStore(await readCatalogFile(process.env.OMCP_SERVICE_CATALOG_FILE));
761
- const products = new ProductsStore(await readProductsFile(process.env.OMCP_PRODUCTS_FILE));
964
+ // Hot-reload aware: passing the path lets `products.maybeReload()`
965
+ // pick up out-of-band edits to OMCP_PRODUCTS_FILE without a restart.
966
+ // Each /api/products* handler awaits maybeReload() before reading,
967
+ // so a `kubectl apply` of an updated ConfigMap or a git-ops edit is
968
+ // visible on the very next request.
969
+ const productsPath = process.env.OMCP_PRODUCTS_FILE;
970
+ const products = new ProductsStore(await readProductsFile(productsPath), { path: productsPath });
971
+ // Seed the mtime cursor from the file we just loaded so the first
972
+ // maybeReload() call doesn't redundantly re-parse the boot state.
973
+ await products.pinMtimeAfterWrite();
762
974
  // Protected route prefixes. /api/me, /api/auth/*, /api/info,
763
975
  // /api/openapi.json deliberately don't appear here — they stay public.
764
976
  for (const prefix of [
@@ -784,6 +996,39 @@ async function main() {
784
996
  // enough to skip the request-counter middleware.
785
997
  let ready = false;
786
998
  app.get("/healthz", (_req, res) => res.type("text").send("ok"));
999
+ // Procurement-time probe: the MCP spec revisions and transports the
1000
+ // gateway supports. Static today — kept as a separate endpoint so a
1001
+ // discovery tool / RFP probe / catalog scanner can resolve our
1002
+ // compliance posture without sending a real MCP handshake.
1003
+ // See docs/mcp-conformance.md for the test suite that proves it.
1004
+ app.get("/api/conformance", (_req, res) => {
1005
+ res.json({
1006
+ revisions: ["2025-11-25"],
1007
+ transports: ["streamable-http", "stdio", "websocket"],
1008
+ methods: {
1009
+ // Methods exercised by the conformance harness. "supported"
1010
+ // is the union of methods that return a non -32601 envelope
1011
+ // for any conforming caller. Per-method spec compliance is
1012
+ // proven by src/conformance/mcp-2025-11-25.test.ts.
1013
+ supported: [
1014
+ "initialize",
1015
+ "notifications/initialized",
1016
+ "ping",
1017
+ "tools/list",
1018
+ "tools/call",
1019
+ ],
1020
+ optional: [
1021
+ "resources/list",
1022
+ "resources/read",
1023
+ "prompts/list",
1024
+ "prompts/get",
1025
+ "logging/setLevel",
1026
+ ],
1027
+ },
1028
+ harnessPath: "mcp-server/src/conformance/mcp-2025-11-25.test.ts",
1029
+ docs: "docs/mcp-conformance.md",
1030
+ });
1031
+ });
787
1032
  app.get("/readyz", (_req, res) => {
788
1033
  if (ready)
789
1034
  return res.type("text").send("ok");
@@ -806,11 +1051,33 @@ async function main() {
806
1051
  // Serve Web UI
807
1052
  app.use(express.static(join(__dirname, "ui")));
808
1053
  // --- API endpoints for Web UI ---
809
- // List sources with health status
810
- app.get("/api/sources", async (_req, res) => {
1054
+ // List sources with health status — tenant-scoped.
1055
+ // Non-admin callers see only their own tenant's sources + globals
1056
+ // (untagged). Admins (users:delete) see everything, with optional
1057
+ // ?tenant=acme drill-down. Anonymous mode (no session) sees
1058
+ // everything — preserves single-tenant default. The `tenant` field
1059
+ // is included on every entry so the UI can render scope badges.
1060
+ app.get("/api/sources", async (req, res) => {
1061
+ const sess = req.session;
1062
+ const isAdmin = hasPermission(sess?.roles, "users", "delete");
1063
+ const callerTenant = sess?.tenant || "default";
1064
+ const requestedTenant = qstr(req.query.tenant);
811
1065
  const health = await registry.healthCheckAll();
812
1066
  const configs = registry.getSourceConfigs();
813
- const sources = configs.map((c) => {
1067
+ const filtered = configs.filter((c) => {
1068
+ // Anonymous: every source.
1069
+ if (!sess)
1070
+ return true;
1071
+ // Admin with ?tenant=X drill-down: untagged + that tenant.
1072
+ if (isAdmin && requestedTenant)
1073
+ return !c.tenant || c.tenant === requestedTenant;
1074
+ // Admin no filter: every source (cross-tenant view).
1075
+ if (isAdmin)
1076
+ return true;
1077
+ // Non-admin: own tenant + untagged.
1078
+ return !c.tenant || c.tenant === callerTenant;
1079
+ });
1080
+ const sources = filtered.map((c) => {
814
1081
  const connector = registry.getByName(c.name);
815
1082
  return {
816
1083
  name: c.name,
@@ -819,6 +1086,7 @@ async function main() {
819
1086
  enabled: c.enabled,
820
1087
  auth: c.auth ? { type: c.auth.type } : undefined,
821
1088
  tls: c.tls || undefined,
1089
+ tenant: c.tenant,
822
1090
  signalType: connector?.signalType || null,
823
1091
  status: health[c.name]?.status || (c.enabled ? "down" : "disabled"),
824
1092
  latencyMs: health[c.name]?.latencyMs || null,
@@ -831,6 +1099,15 @@ async function main() {
831
1099
  app.get("/api/source-types", (_req, res) => {
832
1100
  res.json(getSupportedTypes());
833
1101
  });
1102
+ // Get the registry of MCP tools the server can advertise (name +
1103
+ // category + one-line summary). The Products modal uses this to
1104
+ // populate the tools-allowlist picker so a typo can't happen at
1105
+ // authoring time; the server-side typo guard (PR #343) stays as
1106
+ // defence-in-depth. Open to every viewer — there's nothing
1107
+ // sensitive in the catalogue, it's just static metadata.
1108
+ app.get("/api/tools/registry", (_req, res) => {
1109
+ res.json({ tools: REGISTERED_TOOLS });
1110
+ });
834
1111
  // Server info — version, loaded plugins, MCP protocol version, build metadata.
835
1112
  // Used by the Web UI footer and by operators to confirm what's deployed.
836
1113
  app.get("/api/info", async (_req, res) => {
@@ -923,9 +1200,18 @@ async function main() {
923
1200
  // to non-admin sessions.
924
1201
  app.get("/api/policy", need("users", "delete"), (req, res) => {
925
1202
  const map = policyEngineToMap(policyEngine);
926
- // Optional dry-run: ?roles=admin,operator&resource=sources&action=delete
1203
+ // The OPA engine's kind() is prefixed `opa:` (see opa.ts:198).
1204
+ // Surface a `tenantAware` boolean so operators can confirm at a
1205
+ // glance whether the active engine honours session.tenant in
1206
+ // .evaluate() — the BuiltinPolicyEngine ignores tenant ctx; OPA
1207
+ // threads it into the Rego input. This is the property required
1208
+ // for `allow { input.tenant == "acme" }` rules to actually fire.
1209
+ const tenantAware = policyEngine.kind().startsWith("opa:");
1210
+ // Optional dry-run: ?roles=admin,operator&resource=sources&action=delete[&tenant=acme]
927
1211
  // returns { allowed, reason } so operators can probe the active
928
- // engine without writing tests against a checkout.
1212
+ // engine without writing tests against a checkout. Tenant defaults
1213
+ // to the caller's session tenant; an admin can override via the
1214
+ // ?tenant= query string to probe verdicts for any tenant.
929
1215
  const q = req.query;
930
1216
  if (q.resource && q.action) {
931
1217
  const dryRoles = typeof q.roles === "string" ? q.roles.split(",").map((r) => r.trim()).filter(Boolean) : undefined;
@@ -940,12 +1226,30 @@ async function main() {
940
1226
  res.json({ dryRun: { roles: dryRoles ?? [], resource: q.resource, action: q.action, allowed: false, reason: `unknown action '${q.action}' (valid: ${[...VALID_ACTIONS].join(", ")})` } });
941
1227
  return;
942
1228
  }
943
- const result = policyEngine.evaluate(dryRoles, q.resource, q.action);
944
- res.json({ dryRun: { roles: dryRoles ?? [], resource: q.resource, action: q.action, ...result } });
1229
+ const callerSess = req.session;
1230
+ // Tenant resolution: explicit ?tenant= override wins, else the
1231
+ // caller's session tenant. The probe runs at users:delete (admin),
1232
+ // so a cross-tenant override is intentional — exactly how an
1233
+ // operator debugs "why doesn't my tenant-conditional Rego rule
1234
+ // fire for tenant Acme?".
1235
+ const probeTenant = typeof q.tenant === "string" && q.tenant
1236
+ ? q.tenant.trim()
1237
+ : callerSess?.tenant;
1238
+ const result = policyEngine.evaluate(dryRoles, q.resource, q.action, probeTenant ? { tenant: probeTenant } : undefined);
1239
+ res.json({
1240
+ dryRun: {
1241
+ roles: dryRoles ?? [],
1242
+ resource: q.resource,
1243
+ action: q.action,
1244
+ tenant: probeTenant,
1245
+ ...result,
1246
+ },
1247
+ });
945
1248
  return;
946
1249
  }
947
1250
  res.json({
948
1251
  engine: policyEngine.kind(),
1252
+ tenantAware,
949
1253
  policy: map,
950
1254
  roles: policyEngine.roles(),
951
1255
  note: policyEngine.kind() === "builtin"
@@ -953,6 +1257,281 @@ async function main() {
953
1257
  : `policy loaded from ${policyEngine.kind()}; restart to reload.`,
954
1258
  });
955
1259
  });
1260
+ // Phase F16: batch policy dry-run. Evaluates every
1261
+ // (subject × resource × action) cell against the active engine and
1262
+ // returns a matrix the UI heat-map renders. Gated identically to
1263
+ // the single-call dry-run on GET /api/policy. Capped at 100×100×10
1264
+ // cells per request — a single OPA query per cell is cheap on the
1265
+ // BuiltinPolicyEngine but a careless caller could hose an external
1266
+ // OPA, so the limit fences that. Operators get CSV via
1267
+ // Accept: text/csv for ticket attachments.
1268
+ app.post("/api/policy/dry-run-batch", need("users", "delete"), audit("policy", "read"), async (req, res) => {
1269
+ const body = (req.body ?? {});
1270
+ const subjects = Array.isArray(body.subjects) ? body.subjects : [];
1271
+ const resources = Array.isArray(body.resources) ? body.resources : [];
1272
+ const actions = Array.isArray(body.actions) ? body.actions : [];
1273
+ const result = await evaluateBatch(policyEngine, { subjects, resources, actions }, VALID_RESOURCES, VALID_ACTIONS);
1274
+ if (req.headers["accept"]?.toString().includes("text/csv")) {
1275
+ res.type("text/csv").send(batchResultToCsv(result));
1276
+ return;
1277
+ }
1278
+ res.json(result);
1279
+ });
1280
+ // --- /api/subjects — aggregated principals catalogue ------------------
1281
+ // The third k8s-shaped RBAC view: who the deployment knows about.
1282
+ // Three independent sources, returned in three independent arrays so
1283
+ // the UI can table each section separately:
1284
+ // - users : OMCP_USERS_FILE (basic-mode local users). Password
1285
+ // hashes are never returned.
1286
+ // - apiKeys : OMCP_API_KEYS names (the bearer-token catalogue).
1287
+ // Tokens are never returned; only metadata (tenant,
1288
+ // bound product, source allow-list, bypass flag).
1289
+ // - oidcGroups: keys of OMCP_OIDC_ROLE_MAP — every group the
1290
+ // operator has explicitly mapped to an OMCP role.
1291
+ // Runtime-only groups (claims that arrive without an
1292
+ // OMCP-side mapping) are skipped on purpose; they
1293
+ // produce no roles by definition.
1294
+ // Gated identically to /api/policy.
1295
+ app.get("/api/subjects", need("users", "delete"), async (_req, res) => {
1296
+ // Local users.
1297
+ const usersOut = [];
1298
+ if (process.env.OMCP_USERS_FILE) {
1299
+ try {
1300
+ const f = await readUsersFile(process.env.OMCP_USERS_FILE);
1301
+ if (f && Array.isArray(f.users)) {
1302
+ for (const u of f.users) {
1303
+ usersOut.push({
1304
+ username: u.username,
1305
+ name: u.name,
1306
+ roles: u.roles ? u.roles.slice() : [],
1307
+ tenant: u.tenant || "default",
1308
+ });
1309
+ }
1310
+ }
1311
+ }
1312
+ catch (e) {
1313
+ // Read failures don't 500 the whole endpoint — surface an
1314
+ // empty users array; admins can check the boot log for the
1315
+ // file-load diagnostic.
1316
+ console.warn(`[/api/subjects] readUsersFile failed: ${e.message}`);
1317
+ }
1318
+ }
1319
+ // API key credentials (tokens stripped).
1320
+ const apiKeysOut = [];
1321
+ for (const c of loadCredentials()) {
1322
+ apiKeysOut.push({
1323
+ name: c.name,
1324
+ tenant: c.tenant || "default",
1325
+ productId: c.productId,
1326
+ bypassRedaction: !!c.bypassRedaction,
1327
+ allowedSources: c.allowedSources,
1328
+ });
1329
+ }
1330
+ // OIDC groups → role mappings.
1331
+ const oidcGroupsOut = [];
1332
+ const roleMapRaw = process.env.OMCP_OIDC_ROLE_MAP;
1333
+ if (roleMapRaw) {
1334
+ try {
1335
+ const parsed = JSON.parse(roleMapRaw);
1336
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
1337
+ for (const [claim, role] of Object.entries(parsed)) {
1338
+ if (typeof role === "string" && claim) {
1339
+ oidcGroupsOut.push({ claim, role });
1340
+ }
1341
+ }
1342
+ }
1343
+ }
1344
+ catch {
1345
+ // The OIDC runtime already rejects an invalid role map at
1346
+ // boot — if parsing fails here it's almost certainly a
1347
+ // transient state during config reload. Surface empty.
1348
+ }
1349
+ }
1350
+ res.json({
1351
+ users: usersOut,
1352
+ apiKeys: apiKeysOut,
1353
+ oidcGroups: oidcGroupsOut,
1354
+ // Surface which env vars actually drive each list so an
1355
+ // admin diagnosing "where is my user?" sees the source path
1356
+ // without having to read the deploy.
1357
+ sources: {
1358
+ users: process.env.OMCP_USERS_FILE || null,
1359
+ apiKeys: process.env.OMCP_API_KEYS ? "OMCP_API_KEYS" : null,
1360
+ oidcGroups: process.env.OMCP_OIDC_ROLE_MAP ? "OMCP_OIDC_ROLE_MAP" : null,
1361
+ },
1362
+ });
1363
+ });
1364
+ // Update a user's roles. Today this is the only binding-shape that
1365
+ // OMCP can actually mutate at runtime: api-key roles aren't stored
1366
+ // anywhere (creds carry sources / tenant / product but not roles),
1367
+ // and OIDC group → role mappings come from OMCP_OIDC_ROLE_MAP which
1368
+ // is read once at boot. The Bindings UI surface api-key + oidc rows
1369
+ // explain the env-source path instead of offering an edit affordance.
1370
+ app.put("/api/users/:username/roles", need("users", "delete"), audit("users", "write"), async (req, res) => {
1371
+ const username = String(req.params.username);
1372
+ const path = process.env.OMCP_USERS_FILE;
1373
+ if (!path) {
1374
+ res.status(409).json({ error: "OMCP_USERS_FILE is not configured — basic-mode user roles can't be edited via the API." });
1375
+ return;
1376
+ }
1377
+ const body = req.body;
1378
+ if (!body || !Array.isArray(body.roles) || !body.roles.every((r) => typeof r === "string")) {
1379
+ res.status(400).json({ error: "body must include { roles: string[] }" });
1380
+ return;
1381
+ }
1382
+ const requestedRoles = body.roles;
1383
+ // Reject role names not in the active policy engine's catalogue —
1384
+ // assigning a user a role that grants nothing is almost always a
1385
+ // typo, not intent. Same defence-in-depth posture as the products
1386
+ // typo guard (PR #343).
1387
+ const knownRoles = new Set(policyEngine.roles());
1388
+ const unknown = requestedRoles.filter((r) => !knownRoles.has(r));
1389
+ if (unknown.length > 0) {
1390
+ res.status(422).json({
1391
+ error: `unknown role name(s) for user '${username}': ${unknown.join(", ")}`,
1392
+ code: "OMCP_USER_UNKNOWN_ROLE",
1393
+ unknown,
1394
+ available: Array.from(knownRoles),
1395
+ });
1396
+ return;
1397
+ }
1398
+ const file = await readUsersFile(path);
1399
+ if (!file) {
1400
+ res.status(404).json({ error: `users file at ${path} is unreadable or empty` });
1401
+ return;
1402
+ }
1403
+ const idx = file.users.findIndex((u) => u.username === username);
1404
+ if (idx < 0) {
1405
+ res.status(404).json({ error: `user '${username}' not found` });
1406
+ return;
1407
+ }
1408
+ file.users[idx].roles = requestedRoles;
1409
+ try {
1410
+ await writeUsersFile(path, file);
1411
+ }
1412
+ catch (e) {
1413
+ res.status(500).json({ error: `failed to persist users file: ${e.message}` });
1414
+ return;
1415
+ }
1416
+ // Refresh the in-memory store so the next login picks up the new
1417
+ // role set without a server restart. maybeReloadUsers stat()s the
1418
+ // file's mtime, which we just bumped via the atomic rename.
1419
+ await maybeReloadUsers();
1420
+ res.json({ ok: true, username, roles: requestedRoles });
1421
+ });
1422
+ // Upsert a role in the file-backed RBAC policy. File engine only:
1423
+ // built-in defaults are immutable in source; OPA is the Rego
1424
+ // source of truth. The UI hides the affordance under non-file
1425
+ // engines via the [data-engine-required="file"] CSS gate; the
1426
+ // endpoint enforces the rule too for defence-in-depth.
1427
+ app.put("/api/policy/roles/:name", need("users", "delete"), audit("users", "write"), async (req, res) => {
1428
+ const name = String(req.params.name);
1429
+ // Reject names with shell-unfriendly characters early so the
1430
+ // YAML round-trip can't accidentally produce an exotic key.
1431
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name)) {
1432
+ res.status(400).json({ error: `role name '${name}' must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}` });
1433
+ return;
1434
+ }
1435
+ const policyFile = process.env.OMCP_RBAC_POLICY_FILE?.trim();
1436
+ if (!policyEngine.kind().startsWith("file:")) {
1437
+ // Built-in (immutable source) or OPA (Rego is the source of
1438
+ // truth) — role authoring isn't available. Return distinct
1439
+ // error codes so the UI can show the right hint without
1440
+ // string-matching the message.
1441
+ const code = policyEngine.kind() === "builtin"
1442
+ ? "OMCP_POLICY_ENGINE_BUILTIN"
1443
+ : policyEngine.kind().startsWith("opa:")
1444
+ ? "OMCP_POLICY_ENGINE_OPA"
1445
+ : "OMCP_POLICY_ENGINE_NOT_FILE";
1446
+ res.status(409).json({
1447
+ error: `role authoring requires the file engine — current is '${policyEngine.kind()}'`,
1448
+ code,
1449
+ });
1450
+ return;
1451
+ }
1452
+ if (!policyFile) {
1453
+ res.status(409).json({
1454
+ error: "OMCP_RBAC_POLICY_FILE is not configured — role authoring writes through that file.",
1455
+ code: "OMCP_POLICY_FILE_NOT_SET",
1456
+ });
1457
+ return;
1458
+ }
1459
+ const body = req.body;
1460
+ if (!body || !Array.isArray(body.permissions)) {
1461
+ res.status(400).json({ error: "body must include { permissions: [{resource, action}] }" });
1462
+ return;
1463
+ }
1464
+ const cleanPerms = [];
1465
+ for (let i = 0; i < body.permissions.length; i++) {
1466
+ const p = body.permissions[i];
1467
+ if (!p || typeof p !== "object" || typeof p.resource !== "string" || typeof p.action !== "string") {
1468
+ res.status(400).json({ error: `body.permissions[${i}] must be { resource: string, action: string }` });
1469
+ return;
1470
+ }
1471
+ if (!VALID_RESOURCES.has(p.resource)) {
1472
+ res.status(422).json({
1473
+ error: `unknown resource '${p.resource}'`,
1474
+ code: "OMCP_POLICY_UNKNOWN_RESOURCE",
1475
+ unknown: p.resource,
1476
+ available: [...VALID_RESOURCES],
1477
+ });
1478
+ return;
1479
+ }
1480
+ if (!VALID_ACTIONS.has(p.action)) {
1481
+ res.status(422).json({
1482
+ error: `unknown action '${p.action}'`,
1483
+ code: "OMCP_POLICY_UNKNOWN_ACTION",
1484
+ unknown: p.action,
1485
+ available: [...VALID_ACTIONS],
1486
+ });
1487
+ return;
1488
+ }
1489
+ cleanPerms.push({ resource: p.resource, action: p.action });
1490
+ }
1491
+ // De-duplicate exact (resource, action) pairs so the file
1492
+ // doesn't accumulate redundant entries via re-saves.
1493
+ const seen = new Set();
1494
+ const dedup = [];
1495
+ for (const p of cleanPerms) {
1496
+ const k = p.resource + ":" + p.action;
1497
+ if (seen.has(k))
1498
+ continue;
1499
+ seen.add(k);
1500
+ dedup.push(p);
1501
+ }
1502
+ // Snapshot the existing map (via raw()) and overlay the upsert.
1503
+ // BuiltinPolicyEngine is the only kind that reaches here per the
1504
+ // checks above.
1505
+ const current = {};
1506
+ if (policyEngine instanceof BuiltinPolicyEngine) {
1507
+ for (const [r, ps] of Object.entries(policyEngine.raw())) {
1508
+ current[r] = ps.slice();
1509
+ }
1510
+ }
1511
+ current[name] = dedup;
1512
+ try {
1513
+ await writePolicyFile(policyFile, current);
1514
+ }
1515
+ catch (e) {
1516
+ if (e instanceof PolicyLoadError) {
1517
+ res.status(422).json({ error: e.message });
1518
+ return;
1519
+ }
1520
+ res.status(500).json({ error: `failed to persist policy: ${e.message}` });
1521
+ return;
1522
+ }
1523
+ // Hot-swap the in-memory engine so the next gate evaluation
1524
+ // picks up the new role without a restart. `replace()` mutates
1525
+ // in-place, so existing middleware closures over `policyEngine`
1526
+ // see the new map immediately.
1527
+ if (policyEngine instanceof BuiltinPolicyEngine) {
1528
+ const fresh = loadPolicyFromFile(policyFile);
1529
+ if (fresh instanceof BuiltinPolicyEngine) {
1530
+ policyEngine.replace(fresh.raw());
1531
+ }
1532
+ }
1533
+ res.json({ ok: true, name, permissions: dedup });
1534
+ });
956
1535
  // --- /api/audit — management-plane audit feed -------------------------
957
1536
  // Read-only, gated by the "audit:read" permission so only viewers /
958
1537
  // operators / admins (basically anyone authenticated in the default
@@ -1137,6 +1716,31 @@ async function main() {
1137
1716
  registerOidcRoutes(app, { sessionCfg, oidc: oidcRuntime });
1138
1717
  console.log("[auth] OIDC endpoints registered: /api/auth/oidc/{login,callback,logout}");
1139
1718
  }
1719
+ // Phase F21: SCIM 2.0 — opt-in. OMCP_SCIM_TOKEN gates access;
1720
+ // OMCP_SCIM_STORE points at the on-disk JSON (mode 0600, atomic).
1721
+ // Multi-replica deployments should plug the F8 SessionStore in
1722
+ // when F21b lands.
1723
+ const scimToken = process.env.OMCP_SCIM_TOKEN?.trim();
1724
+ if (scimToken) {
1725
+ const scimStorePath = process.env.OMCP_SCIM_STORE?.trim() || "/tmp/scim.json";
1726
+ const scimStore = new ScimStore(scimStorePath);
1727
+ await scimStore.load();
1728
+ registerScimRoutes(app, {
1729
+ store: scimStore,
1730
+ bearerToken: scimToken,
1731
+ audit: (ev) => void mgmtAudit.record({
1732
+ actor: { sub: `scim:${ev.actor}` },
1733
+ tenant: "default",
1734
+ resource: "users",
1735
+ action: ev.action.includes("delete") ? "delete" : "write",
1736
+ method: "SCIM",
1737
+ path: `/scim/v2/${ev.action}`,
1738
+ status: ev.status,
1739
+ target: ev.target,
1740
+ }).catch(() => undefined),
1741
+ });
1742
+ console.log("[scim] /scim/v2/* registered (store: %s)", scimStorePath);
1743
+ }
1140
1744
  // Connectors currently loaded into this server (builtin + filesystem
1141
1745
  // plugins), with manifest metadata — drives the UI "Connectors" page.
1142
1746
  app.get("/api/connectors", (_req, res) => {
@@ -1328,9 +1932,13 @@ async function main() {
1328
1932
  rmSync(work, { recursive: true, force: true });
1329
1933
  }
1330
1934
  });
1331
- // Add a new source
1935
+ // Add a new source — tenant-aware. Non-admins can only create
1936
+ // sources in their own tenant; admins may set any tenant or leave
1937
+ // unset (global). Untagged inputs default to undefined (global) for
1938
+ // admins and to the caller's own tenant for non-admins, so a
1939
+ // tenant-bound user can't accidentally pollute the global pool.
1332
1940
  app.post("/api/sources", installRateLimit, need("sources", "write"), audit("sources", "write"), async (req, res) => {
1333
- const { name, type, url, enabled, auth, tls } = req.body;
1941
+ const { name, type, url, enabled, auth, tls, tenant: bodyTenant } = req.body;
1334
1942
  if (!name || !type || !url) {
1335
1943
  res.status(400).json({ error: "name, type, and url are required" });
1336
1944
  return;
@@ -1340,22 +1948,40 @@ async function main() {
1340
1948
  res.status(400).json({ error: urlErr });
1341
1949
  return;
1342
1950
  }
1951
+ const sess = req.session;
1952
+ const isAdmin = hasPermission(sess?.roles, "users", "delete");
1953
+ const callerTenant = sess?.tenant || "default";
1954
+ const resolvedTenant = isAdmin
1955
+ ? (typeof bodyTenant === "string" && bodyTenant ? bodyTenant : undefined)
1956
+ : (typeof bodyTenant === "string" && bodyTenant && bodyTenant !== callerTenant
1957
+ ? "__deny__"
1958
+ : callerTenant);
1959
+ if (resolvedTenant === "__deny__") {
1960
+ res.status(403).json({ error: "cannot create source in another tenant" });
1961
+ return;
1962
+ }
1343
1963
  const existing = registry.getSourceConfigs().find((s) => s.name === name);
1344
1964
  if (existing) {
1345
1965
  res.status(409).json({ error: `Source "${name}" already exists` });
1346
1966
  return;
1347
1967
  }
1348
- const source = { name, type, url, enabled: enabled !== false, auth, tls };
1968
+ const source = { name, type, url, enabled: enabled !== false, auth, tls, tenant: resolvedTenant };
1349
1969
  await registry.addSource(source);
1350
1970
  saveConfig(config = { ...config, sources: registry.getSourceConfigs() });
1351
1971
  res.status(201).json({ ok: true, source });
1352
1972
  });
1353
- // Update an existing source
1973
+ // Update an existing source — tenant-aware. Non-admins editing a
1974
+ // cross-tenant source get the same 404 they'd get for "no such
1975
+ // source" (no existence leak). Admins may move a source between
1976
+ // tenants by setting body.tenant; non-admins cannot.
1354
1977
  app.put("/api/sources/:name", need("sources", "write"), audit("sources", "write"), async (req, res) => {
1355
1978
  const oldName = String(req.params.name);
1356
- const { name, type, url, enabled, auth, tls } = req.body;
1979
+ const { name, type, url, enabled, auth, tls, tenant: bodyTenant } = req.body;
1357
1980
  const existing = registry.getSourceConfigs().find((s) => s.name === oldName);
1358
- if (!existing) {
1981
+ const sess = req.session;
1982
+ const isAdmin = hasPermission(sess?.roles, "users", "delete");
1983
+ const callerTenant = sess?.tenant || "default";
1984
+ if (!existing || (!isAdmin && existing.tenant && existing.tenant !== callerTenant)) {
1359
1985
  res.status(404).json({ error: `Source "${oldName}" not found` });
1360
1986
  return;
1361
1987
  }
@@ -1367,6 +1993,19 @@ async function main() {
1367
1993
  return;
1368
1994
  }
1369
1995
  }
1996
+ let nextTenant = existing.tenant;
1997
+ if (bodyTenant !== undefined) {
1998
+ if (!isAdmin) {
1999
+ // Non-admin attempting tenant reassignment — disallow.
2000
+ if (bodyTenant !== existing.tenant) {
2001
+ res.status(403).json({ error: "cannot change source tenant" });
2002
+ return;
2003
+ }
2004
+ }
2005
+ else {
2006
+ nextTenant = typeof bodyTenant === "string" && bodyTenant ? bodyTenant : undefined;
2007
+ }
2008
+ }
1370
2009
  const source = {
1371
2010
  name: name || oldName,
1372
2011
  type: type || existing.type,
@@ -1374,16 +2013,20 @@ async function main() {
1374
2013
  enabled: enabled !== undefined ? enabled : existing.enabled,
1375
2014
  auth: auth !== undefined ? auth : existing.auth,
1376
2015
  tls: tls !== undefined ? tls : existing.tls,
2016
+ tenant: nextTenant,
1377
2017
  };
1378
2018
  await registry.updateSource(oldName, source);
1379
2019
  saveConfig(config = { ...config, sources: registry.getSourceConfigs() });
1380
2020
  res.json({ ok: true, source });
1381
2021
  });
1382
- // Delete a source
2022
+ // Delete a source — same cross-tenant 404 posture.
1383
2023
  app.delete("/api/sources/:name", need("sources", "delete"), audit("sources", "delete"), async (req, res) => {
1384
2024
  const name = String(req.params.name);
1385
2025
  const existing = registry.getSourceConfigs().find((s) => s.name === name);
1386
- if (!existing) {
2026
+ const sess = req.session;
2027
+ const isAdmin = hasPermission(sess?.roles, "users", "delete");
2028
+ const callerTenant = sess?.tenant || "default";
2029
+ if (!existing || (!isAdmin && existing.tenant && existing.tenant !== callerTenant)) {
1387
2030
  res.status(404).json({ error: `Source "${name}" not found` });
1388
2031
  return;
1389
2032
  }
@@ -1417,7 +2060,10 @@ async function main() {
1417
2060
  app.patch("/api/sources/:name/toggle", need("sources", "write"), audit("sources", "write"), async (req, res) => {
1418
2061
  const name = String(req.params.name);
1419
2062
  const existing = registry.getSourceConfigs().find((s) => s.name === name);
1420
- if (!existing) {
2063
+ const sess = req.session;
2064
+ const isAdmin = hasPermission(sess?.roles, "users", "delete");
2065
+ const callerTenant = sess?.tenant || "default";
2066
+ if (!existing || (!isAdmin && existing.tenant && existing.tenant !== callerTenant)) {
1421
2067
  res.status(404).json({ error: `Source "${name}" not found` });
1422
2068
  return;
1423
2069
  }
@@ -1440,7 +2086,10 @@ async function main() {
1440
2086
  try {
1441
2087
  const sess = req.session;
1442
2088
  const callerTenant = sess?.tenant || "default";
1443
- const result = await listServicesHandler(registry, {}, defaultContext());
2089
+ // sessionContext threads the caller's tenant into the handler so
2090
+ // PR #331's per-tenant connector scoping fires for the dashboard
2091
+ // surface too (was previously bypassed with defaultContext()).
2092
+ const result = await listServicesHandler(registry, {}, sessionContext(sess));
1444
2093
  const parsed = parseToolResult(result);
1445
2094
  // Tenant-scope catalog enrichment so a viewer in tenant A
1446
2095
  // doesn't accidentally see acme's owner/SLO metadata on a
@@ -1483,7 +2132,9 @@ async function main() {
1483
2132
  // Same scoping / staging-visibility pattern as /api/catalog. Non-admins
1484
2133
  // see only their own tenant's PUBLISHED products; admins see all
1485
2134
  // tenants by default + staging.
1486
- app.get("/api/products", need("products", "read"), (req, res) => {
2135
+ app.get("/api/products", need("products", "read"), async (req, res) => {
2136
+ // Pick up out-of-band edits before serving — see ProductsStore docs.
2137
+ await products.maybeReload();
1487
2138
  const sess = req.session;
1488
2139
  const isAdmin = hasPermission(sess?.roles, "users", "delete");
1489
2140
  const callerTenant = sess?.tenant || "default";
@@ -1497,6 +2148,67 @@ async function main() {
1497
2148
  includesStaging: includeStaging,
1498
2149
  });
1499
2150
  });
2151
+ // Create a new product (REST convention: POST = create, 409 on
2152
+ // conflict). Same tenancy + typo-guard posture as PUT. The PUT
2153
+ // upsert path remains for the existing UI; new integrations that
2154
+ // want strict create-vs-update semantics use POST.
2155
+ app.post("/api/products", need("products", "write"), audit("products", "write"), async (req, res) => {
2156
+ await products.maybeReload();
2157
+ const sess = req.session;
2158
+ const isAdmin = hasPermission(sess?.roles, "users", "delete");
2159
+ const callerTenant = sess?.tenant || "default";
2160
+ const body = req.body;
2161
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
2162
+ res.status(400).json({ error: "body must be a product object" });
2163
+ return;
2164
+ }
2165
+ if (typeof body.id !== "string" || !body.id) {
2166
+ res.status(400).json({ error: "body.id is required" });
2167
+ return;
2168
+ }
2169
+ let validated;
2170
+ try {
2171
+ validated = validateProduct(body, `POST /api/products`);
2172
+ }
2173
+ catch (e) {
2174
+ if (e instanceof ProductsLoadError) {
2175
+ res.status(400).json({ error: e.message });
2176
+ return;
2177
+ }
2178
+ throw e;
2179
+ }
2180
+ if (validated.tools && validated.tools.length > 0) {
2181
+ const unknown = unknownToolNames(validated.tools);
2182
+ if (unknown.length > 0) {
2183
+ res.status(422).json({
2184
+ error: `unknown tool name(s) in product '${validated.id}': ${unknown.join(", ")}`,
2185
+ code: "OMCP_PRODUCT_UNKNOWN_TOOL",
2186
+ unknown,
2187
+ available: REGISTERED_TOOL_NAMES,
2188
+ });
2189
+ return;
2190
+ }
2191
+ }
2192
+ if (!isAdmin && (validated.tenant || "default") !== callerTenant) {
2193
+ res.status(403).json({ error: "cannot create product in another tenant" });
2194
+ return;
2195
+ }
2196
+ if (products.get(validated.id)) {
2197
+ res.status(409).json({ error: `product '${validated.id}' already exists; use PUT to update` });
2198
+ return;
2199
+ }
2200
+ const next = products.upsert(validated);
2201
+ if (process.env.OMCP_PRODUCTS_FILE) {
2202
+ try {
2203
+ await writeProductsFile(process.env.OMCP_PRODUCTS_FILE, next);
2204
+ await products.pinMtimeAfterWrite();
2205
+ }
2206
+ catch (e) {
2207
+ console.warn(`[products] POST ${validated.id}: failed to persist to ${process.env.OMCP_PRODUCTS_FILE}: ${e.message} — in-memory state is still updated`);
2208
+ }
2209
+ }
2210
+ res.status(201).json({ product: validated, persisted: !!process.env.OMCP_PRODUCTS_FILE });
2211
+ });
1500
2212
  // Upsert a product. Body is the same shape as a single entry
1501
2213
  // in OMCP_PRODUCTS_FILE. The URL-path id must match the body id
1502
2214
  // (defence-in-depth: the gate keys on body, the path keys the
@@ -1504,6 +2216,9 @@ async function main() {
1504
2216
  // updated catalogue back to disk so the change survives a
1505
2217
  // restart; without the file, the upsert is in-memory only.
1506
2218
  app.put("/api/products/:id", need("products", "write"), audit("products", "write"), async (req, res) => {
2219
+ // Hot-reload before mutating so a concurrent on-disk edit isn't
2220
+ // silently clobbered by our in-memory snapshot.
2221
+ await products.maybeReload();
1507
2222
  const id = String(req.params.id);
1508
2223
  const sess = req.session;
1509
2224
  const isAdmin = hasPermission(sess?.roles, "users", "delete");
@@ -1532,6 +2247,23 @@ async function main() {
1532
2247
  }
1533
2248
  throw e;
1534
2249
  }
2250
+ // Typo guard: a Product whose `tools` allow-list names tools
2251
+ // that don't actually register would bind a credential to an
2252
+ // empty /mcp tool surface (silent dead session). Reject with
2253
+ // 422 + a hint of valid tool names so the operator can see the
2254
+ // intended typo immediately.
2255
+ if (validated.tools && validated.tools.length > 0) {
2256
+ const unknown = unknownToolNames(validated.tools);
2257
+ if (unknown.length > 0) {
2258
+ res.status(422).json({
2259
+ error: `unknown tool name(s) in product '${id}': ${unknown.join(", ")}`,
2260
+ code: "OMCP_PRODUCT_UNKNOWN_TOOL",
2261
+ unknown,
2262
+ available: REGISTERED_TOOL_NAMES,
2263
+ });
2264
+ return;
2265
+ }
2266
+ }
1535
2267
  // Tenant gate: non-admins can only write into their own tenant.
1536
2268
  if (!isAdmin && (validated.tenant || "default") !== callerTenant) {
1537
2269
  res.status(403).json({ error: "cannot write product into another tenant" });
@@ -1549,6 +2281,10 @@ async function main() {
1549
2281
  if (process.env.OMCP_PRODUCTS_FILE) {
1550
2282
  try {
1551
2283
  await writeProductsFile(process.env.OMCP_PRODUCTS_FILE, next);
2284
+ // Advance our mtime cursor past this write so the next
2285
+ // maybeReload() doesn't treat our own change as an external
2286
+ // edit and re-read what we just persisted.
2287
+ await products.pinMtimeAfterWrite();
1552
2288
  }
1553
2289
  catch (e) {
1554
2290
  console.warn(`[products] PUT ${id}: failed to persist to ${process.env.OMCP_PRODUCTS_FILE}: ${e.message} — in-memory state is still updated`);
@@ -1557,6 +2293,7 @@ async function main() {
1557
2293
  res.json({ product: validated, persisted: !!process.env.OMCP_PRODUCTS_FILE });
1558
2294
  });
1559
2295
  app.delete("/api/products/:id", need("products", "delete"), audit("products", "delete"), async (req, res) => {
2296
+ await products.maybeReload();
1560
2297
  const id = String(req.params.id);
1561
2298
  const sess = req.session;
1562
2299
  const isAdmin = hasPermission(sess?.roles, "users", "delete");
@@ -1574,6 +2311,7 @@ async function main() {
1574
2311
  if (process.env.OMCP_PRODUCTS_FILE) {
1575
2312
  try {
1576
2313
  await writeProductsFile(process.env.OMCP_PRODUCTS_FILE, next);
2314
+ await products.pinMtimeAfterWrite();
1577
2315
  }
1578
2316
  catch (e) {
1579
2317
  console.warn(`[products] DELETE ${id}: failed to persist to ${process.env.OMCP_PRODUCTS_FILE}: ${e.message} — in-memory state is still updated`);
@@ -1584,7 +2322,8 @@ async function main() {
1584
2322
  // Single product by id. Non-admins get a 404 (not 403) on a
1585
2323
  // cross-tenant probe so the existence of the product isn't leaked
1586
2324
  // — same posture as the rest of the tenancy layer.
1587
- app.get("/api/products/:id", need("products", "read"), (req, res) => {
2325
+ app.get("/api/products/:id", need("products", "read"), async (req, res) => {
2326
+ await products.maybeReload();
1588
2327
  const sess = req.session;
1589
2328
  const isAdmin = hasPermission(sess?.roles, "users", "delete");
1590
2329
  const callerTenant = sess?.tenant || "default";
@@ -1603,12 +2342,48 @@ async function main() {
1603
2342
  }
1604
2343
  res.json(p);
1605
2344
  });
2345
+ // Agent preview — what would the /mcp tools/list response look
2346
+ // like for a credential bound to this product? Same RBAC + tenant
2347
+ // gate as the singular GET above. The body mirrors the actual
2348
+ // tools/list shape (name + description + category), filtered the
2349
+ // same way the /mcp transport filters it via allowsTool +
2350
+ // registerTool — so the UI's Review pane shows the exact set the
2351
+ // agent will see, not an approximation. Branding metadata travels
2352
+ // alongside so the preview can render with the product's identity.
2353
+ app.get("/api/products/:id/preview", need("products", "read"), async (req, res) => {
2354
+ await products.maybeReload();
2355
+ const sess = req.session;
2356
+ const isAdmin = hasPermission(sess?.roles, "users", "delete");
2357
+ const callerTenant = sess?.tenant || "default";
2358
+ const tenantFilter = isAdmin ? undefined : callerTenant;
2359
+ const id = String(req.params.id);
2360
+ const p = products.get(id, tenantFilter);
2361
+ if (!p) {
2362
+ res.status(404).json({ error: "not found" });
2363
+ return;
2364
+ }
2365
+ if (!isAdmin && p.status === "staging") {
2366
+ res.status(404).json({ error: "not found" });
2367
+ return;
2368
+ }
2369
+ const allowList = p.tools && p.tools.length > 0 ? p.tools : undefined;
2370
+ const filteredTools = REGISTERED_TOOLS.filter((t) => allowsTool(allowList, t.name));
2371
+ res.json({
2372
+ product: { id: p.id, name: p.name, version: p.version, branding: p.branding, tenant: p.tenant, status: p.status },
2373
+ // unrestricted = true when the product has no tools allow-list,
2374
+ // i.e. the bound agent sees every registered tool. UI uses this
2375
+ // to render a distinct "no filter" preview banner.
2376
+ unrestricted: !allowList,
2377
+ tools: filteredTools,
2378
+ });
2379
+ });
1606
2380
  // Health endpoint for UI dashboard
1607
2381
  app.get("/api/health/:service", async (req, res) => {
1608
2382
  try {
1609
- const callerTenant = req.session?.tenant || "default";
2383
+ const sess = req.session;
2384
+ const callerTenant = sess?.tenant || "default";
1610
2385
  const service = String(req.params.service);
1611
- const result = await getServiceHealthHandler(registry, { service }, defaultContext());
2386
+ const result = await getServiceHealthHandler(registry, { service }, sessionContext(sess));
1612
2387
  const parsed = parseToolResult(result);
1613
2388
  const entry = catalog.get(service, callerTenant);
1614
2389
  if (entry && parsed && typeof parsed === "object")
@@ -1622,14 +2397,16 @@ async function main() {
1622
2397
  // Health for all services
1623
2398
  app.get("/api/health", async (req, res) => {
1624
2399
  try {
1625
- const callerTenant = req.session?.tenant || "default";
1626
- const servicesResult = await listServicesHandler(registry, {}, defaultContext());
2400
+ const sess = req.session;
2401
+ const callerTenant = sess?.tenant || "default";
2402
+ const ctx = sessionContext(sess);
2403
+ const servicesResult = await listServicesHandler(registry, {}, ctx);
1627
2404
  const parsed = parseToolResult(servicesResult);
1628
2405
  const services = parsed?.services || [];
1629
2406
  const health = {};
1630
2407
  for (const svc of services) {
1631
2408
  try {
1632
- const result = await getServiceHealthHandler(registry, { service: svc.name }, defaultContext());
2409
+ const result = await getServiceHealthHandler(registry, { service: svc.name }, ctx);
1633
2410
  const h = parseToolResult(result);
1634
2411
  // Same tenant scoping as /api/services to avoid the
1635
2412
  // dashboard cross-tenant catalog leak the reviewer
@@ -1653,12 +2430,18 @@ async function main() {
1653
2430
  // Returns the union of topology snapshots across all topology-capable
1654
2431
  // connectors (today only "kubernetes"). One JSON document so the UI can
1655
2432
  // render summary + grouped views without N round-trips.
1656
- app.get("/api/topology", async (_req, res) => {
2433
+ app.get("/api/topology", async (req, res) => {
1657
2434
  try {
2435
+ const sess = req.session;
2436
+ const callerTenant = sess?.tenant || "default";
1658
2437
  const sources = [];
1659
2438
  const allResources = [];
1660
2439
  const allEdges = [];
1661
- for (const c of registry.getAll()) {
2440
+ // Tenant-scoped: non-anonymous callers only see topology from
2441
+ // connectors their tenant can reach. Anonymous mode keeps the
2442
+ // global view (single-tenant default).
2443
+ const connectors = sess ? registry.getByTenant(callerTenant) : registry.getAll();
2444
+ for (const c of connectors) {
1662
2445
  if (!isTopologyProvider(c))
1663
2446
  continue;
1664
2447
  const snap = await c.getTopologySnapshot();
@@ -1710,9 +2493,19 @@ async function main() {
1710
2493
  // --- Per-Source Metrics API ---
1711
2494
  // Get metrics for a source (active metrics or defaults)
1712
2495
  app.get("/api/sources/:name/metrics", (req, res) => {
1713
- const connector = registry.getByName(String(req.params.name));
2496
+ const name = String(req.params.name);
2497
+ const sess = req.session;
2498
+ const isAdmin = hasPermission(sess?.roles, "users", "delete");
2499
+ const callerTenant = sess?.tenant || "default";
2500
+ // Tenant-aware: getByNameForTenant returns undefined for both
2501
+ // "doesn't exist" and "cross-tenant" — same no-leak posture as
2502
+ // /api/sources GET/PUT/DELETE. Anonymous / admin keep the
2503
+ // single-tenant behaviour by falling back to getByName.
2504
+ const connector = (sess && !isAdmin)
2505
+ ? registry.getByNameForTenant(name, callerTenant)
2506
+ : registry.getByName(name);
1714
2507
  if (!connector) {
1715
- res.status(404).json({ error: `Source "${String(req.params.name)}" not found` });
2508
+ res.status(404).json({ error: `Source "${name}" not found` });
1716
2509
  return;
1717
2510
  }
1718
2511
  res.json({
@@ -1720,11 +2513,15 @@ async function main() {
1720
2513
  defaults: connector.getDefaultMetrics(),
1721
2514
  });
1722
2515
  });
1723
- // Update metrics for a source
2516
+ // Update metrics for a source — tenant-aware mutation.
1724
2517
  app.put("/api/sources/:name/metrics", need("sources", "write"), audit("sources", "write"), async (req, res) => {
1725
2518
  const name = String(req.params.name);
1726
2519
  const sourceIdx = config.sources.findIndex((s) => s.name === name);
1727
- if (sourceIdx === -1) {
2520
+ const sess = req.session;
2521
+ const isAdmin = hasPermission(sess?.roles, "users", "delete");
2522
+ const callerTenant = sess?.tenant || "default";
2523
+ const src = sourceIdx >= 0 ? config.sources[sourceIdx] : undefined;
2524
+ if (!src || (!isAdmin && src.tenant && src.tenant !== callerTenant)) {
1728
2525
  res.status(404).json({ error: `Source "${name}" not found` });
1729
2526
  return;
1730
2527
  }
@@ -1734,11 +2531,15 @@ async function main() {
1734
2531
  saveConfig(config);
1735
2532
  res.json({ ok: true });
1736
2533
  });
1737
- // Reset a source's metrics to connector defaults
2534
+ // Reset a source's metrics to connector defaults — tenant-aware.
1738
2535
  app.delete("/api/sources/:name/metrics", need("sources", "write"), audit("sources", "write"), async (req, res) => {
1739
2536
  const name = String(req.params.name);
1740
2537
  const sourceIdx = config.sources.findIndex((s) => s.name === name);
1741
- if (sourceIdx === -1) {
2538
+ const sess = req.session;
2539
+ const isAdmin = hasPermission(sess?.roles, "users", "delete");
2540
+ const callerTenant = sess?.tenant || "default";
2541
+ const src = sourceIdx >= 0 ? config.sources[sourceIdx] : undefined;
2542
+ if (!src || (!isAdmin && src.tenant && src.tenant !== callerTenant)) {
1742
2543
  res.status(404).json({ error: `Source "${name}" not found` });
1743
2544
  return;
1744
2545
  }
@@ -1760,6 +2561,12 @@ async function main() {
1760
2561
  // MCP Streamable HTTP transport — stateful sessions
1761
2562
  const transports = new Map();
1762
2563
  const sessionLastActive = new Map();
2564
+ // Phase F9: per-session tag identifying the virtual-server slug a
2565
+ // session was issued under (or undefined for the root /mcp surface).
2566
+ // Used to prevent a session minted on /mcp/v/foo from being probed
2567
+ // via /mcp/v/bar — the GET/DELETE handlers refuse the cross-product
2568
+ // lookup.
2569
+ const sessionProduct = new Map();
1763
2570
  const SESSION_TTL_MS = 30 * 60 * 1000; // 30 min idle timeout
1764
2571
  // Clean up idle sessions every 5 minutes
1765
2572
  setInterval(() => {
@@ -1768,6 +2575,7 @@ async function main() {
1768
2575
  if (now - lastActive > SESSION_TTL_MS) {
1769
2576
  transports.delete(sid);
1770
2577
  sessionLastActive.delete(sid);
2578
+ sessionProduct.delete(sid);
1771
2579
  console.log(`Session ${sid} expired (idle)`);
1772
2580
  }
1773
2581
  }
@@ -1781,8 +2589,22 @@ async function main() {
1781
2589
  // 429 with a Retry-After. Anonymous /mcp traffic (no OMCP_API_KEYS
1782
2590
  // configured) bypasses this — the global express-rate-limit IP gate
1783
2591
  // still applies. Override via OMCP_TOOL_RATE_PER_MIN.
2592
+ // Per-credential cap overrides: OMCP_KEY_RATE_PER_MIN="agent=600;ci=240"
2593
+ // wins over the global OMCP_TOOL_RATE_PER_MIN for the named credentials.
2594
+ // The bucket identity is "<tenant> <credName>"; the override map keys on
2595
+ // credName, so the lookup pulls the cred-name back out of the composite.
2596
+ const keyRateLimits = parseKeyRateLimits(process.env.OMCP_KEY_RATE_PER_MIN);
1784
2597
  const toolRateLimiter = new IdentityRateLimiter({
1785
2598
  limit: resolveToolRatePerMin(process.env.OMCP_TOOL_RATE_PER_MIN),
2599
+ limitFor: keyRateLimits.size === 0 ? undefined : (identity) => {
2600
+ // Composite identity is "<tenant> <credName>" — split on the
2601
+ // single space that gateCtx put there (NUL would be safer but
2602
+ // would break existing /api/usage actor labels; cred names are
2603
+ // operator-set and don't contain spaces in practice).
2604
+ const sp = identity.indexOf(" ");
2605
+ const credName = sp >= 0 ? identity.slice(sp + 1) : identity;
2606
+ return keyRateLimits.get(credName);
2607
+ },
1786
2608
  });
1787
2609
  // Per-identity tracker key. Composes tenant + principalId so two
1788
2610
  // credentials of the same name in different tenants don't share
@@ -1822,7 +2644,7 @@ async function main() {
1822
2644
  }
1823
2645
  // Bearer/X-API-Key on every /mcp request; resolve the principal + its
1824
2646
  // coarse source allow-list into the RequestContext.
1825
- function gateCtx(req, res) {
2647
+ async function gateCtx(req, res) {
1826
2648
  if (!credentialsConfigured())
1827
2649
  return defaultContext();
1828
2650
  const cred = resolveToken(extractToken(req.headers), loadCredentials());
@@ -1853,13 +2675,33 @@ async function main() {
1853
2675
  });
1854
2676
  return null;
1855
2677
  }
2678
+ // Resolve the credential's bound Product (OMCP_KEY_PRODUCTS) into
2679
+ // a concrete tools allow-list. Cross-tenant Products are invisible
2680
+ // — products.get() returns undefined when the productId belongs to
2681
+ // another tenant, mirroring the rest of the tenancy layer. A bound
2682
+ // Product whose own `tools` field is absent / empty leaves the
2683
+ // allow-list undefined (== unrestricted), matching the YAML
2684
+ // loader's "no tools key = no restriction" semantics.
2685
+ let allowedTools;
2686
+ if (cred.productId) {
2687
+ // Pick up out-of-band edits to OMCP_PRODUCTS_FILE before each
2688
+ // /mcp request — cheap (one stat), keeps the binding live.
2689
+ // Best-effort: if the catalogue reload fails we keep the prior
2690
+ // good state (the store handles that internally) rather than
2691
+ // failing the request.
2692
+ await products.maybeReload().catch(() => undefined);
2693
+ const p = products.get(cred.productId, credTenant);
2694
+ if (p && p.tools && p.tools.length > 0)
2695
+ allowedTools = p.tools.slice();
2696
+ }
1856
2697
  return principalContext(cred.name, cred.allowedSources, {
1857
2698
  allowBypassRedaction: cred.bypassRedaction,
1858
2699
  tenant: cred.tenant,
2700
+ allowedTools,
1859
2701
  });
1860
2702
  }
1861
2703
  app.post("/mcp", async (req, res) => {
1862
- const ctx = gateCtx(req, res);
2704
+ const ctx = await gateCtx(req, res);
1863
2705
  if (!ctx)
1864
2706
  return;
1865
2707
  const sessionId = req.headers["mcp-session-id"];
@@ -1895,7 +2737,7 @@ async function main() {
1895
2737
  mcpActiveSessions.set(transports.size);
1896
2738
  });
1897
2739
  app.get("/mcp", async (req, res) => {
1898
- if (!gateCtx(req, res))
2740
+ if (!(await gateCtx(req, res)))
1899
2741
  return;
1900
2742
  const sessionId = req.headers["mcp-session-id"];
1901
2743
  const transport = transports.get(sessionId);
@@ -1906,7 +2748,7 @@ async function main() {
1906
2748
  await transport.handleRequest(req, res);
1907
2749
  });
1908
2750
  app.delete("/mcp", async (req, res) => {
1909
- if (!gateCtx(req, res))
2751
+ if (!(await gateCtx(req, res)))
1910
2752
  return;
1911
2753
  const sessionId = req.headers["mcp-session-id"];
1912
2754
  const transport = transports.get(sessionId);
@@ -1914,18 +2756,244 @@ async function main() {
1914
2756
  await transport.handleRequest(req, res);
1915
2757
  transports.delete(sessionId);
1916
2758
  sessionLastActive.delete(sessionId);
2759
+ sessionProduct.delete(sessionId);
1917
2760
  }
1918
2761
  else {
1919
2762
  res.status(400).json({ error: "No active session" });
1920
2763
  }
1921
2764
  });
2765
+ // Phase F9: virtual servers — every Product gets its own MCP
2766
+ // endpoint at /mcp/v/<slug> that exposes only the tools bound to
2767
+ // that Product, with the caller's existing tenant + RBAC scoping
2768
+ // preserved. The narrow ctx flows into createMcpServer's
2769
+ // registerTool gate, so the surface a /mcp/v/<slug> client sees is
2770
+ // strictly product.tools (intersected with any pre-existing
2771
+ // allowedTools the credential already carries).
2772
+ function intersectAllowed(a, b) {
2773
+ if (!a)
2774
+ return b;
2775
+ if (!b)
2776
+ return a;
2777
+ const bSet = new Set(b);
2778
+ return a.filter((t) => bSet.has(t));
2779
+ }
2780
+ async function resolveVirtualProduct(req, res, baseCtx) {
2781
+ const slug = req.params.slug;
2782
+ if (!slug || typeof slug !== "string") {
2783
+ res.status(404).json({ error: "virtual server not found" });
2784
+ return null;
2785
+ }
2786
+ // Hot-reload aware so newly-published products are visible
2787
+ // without restart (same pattern /mcp uses for product changes).
2788
+ await products.maybeReload().catch(() => undefined);
2789
+ const tenant = baseCtx.tenant || "default";
2790
+ const product = products.get(slug, tenant);
2791
+ if (!product || product.status === "staging") {
2792
+ // 404 (not 403) for cross-tenant or missing — matches the
2793
+ // existence-hiding stance of the rest of the tenancy layer.
2794
+ res.status(404).json({ error: "virtual server not found" });
2795
+ return null;
2796
+ }
2797
+ const allowedTools = intersectAllowed(baseCtx.allowedTools, product.tools);
2798
+ const ctx = { ...baseCtx, allowedTools };
2799
+ return { product, ctx };
2800
+ }
2801
+ app.post("/mcp/v/:slug", async (req, res) => {
2802
+ const baseCtx = await gateCtx(req, res);
2803
+ if (!baseCtx)
2804
+ return;
2805
+ const resolved = await resolveVirtualProduct(req, res, baseCtx);
2806
+ if (!resolved)
2807
+ return;
2808
+ const { ctx, product } = resolved;
2809
+ const sessionId = req.headers["mcp-session-id"];
2810
+ let transport;
2811
+ if (sessionId && transports.has(sessionId)) {
2812
+ // Cross-product session probe is rejected: the session is
2813
+ // bound to whichever virtual server issued it.
2814
+ if (sessionProduct.get(sessionId) !== product.id) {
2815
+ res.status(404).json({ error: "virtual server not found" });
2816
+ return;
2817
+ }
2818
+ transport = transports.get(sessionId);
2819
+ }
2820
+ else {
2821
+ transport = new StreamableHTTPServerTransport({
2822
+ sessionIdGenerator: () => randomUUID(),
2823
+ });
2824
+ transport.onclose = () => {
2825
+ for (const [sid, t] of transports) {
2826
+ if (t === transport) {
2827
+ transports.delete(sid);
2828
+ sessionProduct.delete(sid);
2829
+ break;
2830
+ }
2831
+ }
2832
+ mcpActiveSessions.set(transports.size);
2833
+ };
2834
+ const sessionMcpServer = createMcpServer(ctx);
2835
+ await sessionMcpServer.connect(transport);
2836
+ }
2837
+ await transport.handleRequest(req, res, req.body);
2838
+ const sid = res.getHeader("mcp-session-id");
2839
+ if (sid) {
2840
+ if (!transports.has(sid)) {
2841
+ transports.set(sid, transport);
2842
+ sessionProduct.set(sid, product.id);
2843
+ }
2844
+ sessionLastActive.set(sid, Date.now());
2845
+ }
2846
+ mcpActiveSessions.set(transports.size);
2847
+ });
2848
+ app.get("/mcp/v/:slug", async (req, res) => {
2849
+ const baseCtx = await gateCtx(req, res);
2850
+ if (!baseCtx)
2851
+ return;
2852
+ const resolved = await resolveVirtualProduct(req, res, baseCtx);
2853
+ if (!resolved)
2854
+ return;
2855
+ const sessionId = req.headers["mcp-session-id"];
2856
+ const transport = transports.get(sessionId);
2857
+ if (!transport || sessionProduct.get(sessionId) !== resolved.product.id) {
2858
+ res.status(400).json({ error: "No active session" });
2859
+ return;
2860
+ }
2861
+ await transport.handleRequest(req, res);
2862
+ });
2863
+ app.delete("/mcp/v/:slug", async (req, res) => {
2864
+ const baseCtx = await gateCtx(req, res);
2865
+ if (!baseCtx)
2866
+ return;
2867
+ const resolved = await resolveVirtualProduct(req, res, baseCtx);
2868
+ if (!resolved)
2869
+ return;
2870
+ const sessionId = req.headers["mcp-session-id"];
2871
+ const transport = transports.get(sessionId);
2872
+ if (transport && sessionProduct.get(sessionId) === resolved.product.id) {
2873
+ await transport.handleRequest(req, res);
2874
+ transports.delete(sessionId);
2875
+ sessionLastActive.delete(sessionId);
2876
+ sessionProduct.delete(sessionId);
2877
+ }
2878
+ else {
2879
+ res.status(400).json({ error: "No active session" });
2880
+ }
2881
+ });
2882
+ // Bearer-token resolver for WebSocket upgrade requests. Browsers
2883
+ // can't set Authorization on a WS handshake, so we accept the token
2884
+ // from any of: Authorization: Bearer X, ?token=X, or the
2885
+ // Sec-WebSocket-Protocol subprotocol "bearer.X" (echoed back by the
2886
+ // server when accepted so clients see which subprotocol won).
2887
+ function extractWsToken(req) {
2888
+ const auth = req.headers["authorization"];
2889
+ if (typeof auth === "string") {
2890
+ const m = auth.match(/^Bearer\s+(.+)$/i);
2891
+ if (m)
2892
+ return { token: m[1] };
2893
+ }
2894
+ try {
2895
+ const url = new URL(req.url ?? "/", "http://localhost");
2896
+ const q = url.searchParams.get("token");
2897
+ if (q)
2898
+ return { token: q };
2899
+ }
2900
+ catch {
2901
+ /* malformed URL */
2902
+ }
2903
+ const sp = req.headers["sec-websocket-protocol"];
2904
+ if (typeof sp === "string") {
2905
+ const offered = sp.split(",").map((s) => s.trim());
2906
+ const bearer = offered.find((p) => p.startsWith("bearer."));
2907
+ if (bearer)
2908
+ return { token: bearer.slice("bearer.".length), selectedSubprotocol: bearer };
2909
+ }
2910
+ return {};
2911
+ }
2912
+ async function gateWsCtx(req) {
2913
+ const { token, selectedSubprotocol } = extractWsToken(req);
2914
+ if (!credentialsConfigured()) {
2915
+ return { ctx: defaultContext(), selectedSubprotocol };
2916
+ }
2917
+ if (!token) {
2918
+ return { reject: 4401, reason: "unauthorized: token required" };
2919
+ }
2920
+ const cred = resolveToken(token, loadCredentials());
2921
+ if (!cred) {
2922
+ return { reject: 4401, reason: "unauthorized: invalid token" };
2923
+ }
2924
+ const credTenant = cred.tenant || "default";
2925
+ const decision = toolRateLimiter.check(`${credTenant} ${cred.name}`);
2926
+ if (!decision.allowed) {
2927
+ return { reject: 4429, reason: "rate limit exceeded for identity" };
2928
+ }
2929
+ let allowedTools;
2930
+ if (cred.productId) {
2931
+ await products.maybeReload().catch(() => undefined);
2932
+ const p = products.get(cred.productId, credTenant);
2933
+ if (p && p.tools && p.tools.length > 0)
2934
+ allowedTools = p.tools.slice();
2935
+ }
2936
+ return {
2937
+ ctx: principalContext(cred.name, cred.allowedSources, {
2938
+ allowBypassRedaction: cred.bypassRedaction,
2939
+ tenant: cred.tenant,
2940
+ allowedTools,
2941
+ }),
2942
+ selectedSubprotocol,
2943
+ };
2944
+ }
1922
2945
  const PORT = parseInt(process.env.PORT || "3000");
1923
- app.listen(PORT, () => {
2946
+ const httpServer = app.listen(PORT, () => {
1924
2947
  ready = true;
1925
2948
  console.log(`observability-mcp server running on port ${PORT}`);
1926
2949
  console.log(` MCP endpoint: http://localhost:${PORT}/mcp`);
2950
+ console.log(` MCP (WS): ws://localhost:${PORT}/mcp/ws`);
1927
2951
  console.log(` Web UI: http://localhost:${PORT}`);
1928
2952
  console.log(` Connectors: ${registry.getAll().map((c) => c.name).join(", ")}`);
1929
2953
  });
2954
+ // Mount the WebSocket MCP transport. One McpServer instance per
2955
+ // accepted socket; per-connection state is carried in
2956
+ // WebSocketServerTransport.sessionId so concurrent clients stay
2957
+ // isolated. Dynamic import so the `ws` package only loads on
2958
+ // platforms that actually use this transport.
2959
+ const { WebSocketServer } = await import("ws");
2960
+ const wss = new WebSocketServer({ noServer: true });
2961
+ httpServer.on("upgrade", async (req, socket, head) => {
2962
+ if (!req.url) {
2963
+ socket.destroy();
2964
+ return;
2965
+ }
2966
+ const path = req.url.split("?")[0];
2967
+ if (path !== "/mcp/ws") {
2968
+ socket.destroy();
2969
+ return;
2970
+ }
2971
+ const auth = await gateWsCtx(req);
2972
+ if ("reject" in auth) {
2973
+ // Custom 4xxx codes during upgrade aren't expressible via HTTP
2974
+ // status, so we accept the upgrade just long enough to close
2975
+ // with the WS-level close code that carries our reason.
2976
+ wss.handleUpgrade(req, socket, head, (ws) => {
2977
+ ws.close(auth.reject === 4429 ? 1013 : 1008, auth.reason);
2978
+ });
2979
+ return;
2980
+ }
2981
+ wss.handleUpgrade(req, socket, head, async (ws) => {
2982
+ try {
2983
+ const transport = new WebSocketServerTransport(ws);
2984
+ const sessionMcpServer = createMcpServer(auth.ctx);
2985
+ await sessionMcpServer.connect(transport);
2986
+ }
2987
+ catch (err) {
2988
+ console.warn("WS /mcp/ws session setup failed:", err);
2989
+ try {
2990
+ ws.close(1011, "server error");
2991
+ }
2992
+ catch {
2993
+ /* socket already gone */
2994
+ }
2995
+ }
2996
+ });
2997
+ });
1930
2998
  }
1931
2999
  main().catch(console.error);