@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.
- package/dist/analysis/history.d.ts +70 -0
- package/dist/analysis/history.js +170 -0
- package/dist/analysis/history.test.d.ts +1 -0
- package/dist/analysis/history.test.js +141 -0
- package/dist/audit/log.d.ts +9 -0
- package/dist/audit/log.js +20 -0
- package/dist/audit/redaction-bypass.d.ts +67 -0
- package/dist/audit/redaction-bypass.js +64 -0
- package/dist/audit/redaction-bypass.test.d.ts +1 -0
- package/dist/audit/redaction-bypass.test.js +72 -0
- package/dist/audit/sinks/types.d.ts +18 -0
- package/dist/audit/sinks/types.js +1 -0
- package/dist/audit/sinks/webhook.d.ts +45 -0
- package/dist/audit/sinks/webhook.js +111 -0
- package/dist/audit/sinks/webhook.test.d.ts +1 -0
- package/dist/audit/sinks/webhook.test.js +162 -0
- package/dist/auth/credentials.d.ts +11 -0
- package/dist/auth/credentials.js +27 -0
- package/dist/auth/credentials.test.js +21 -1
- package/dist/auth/csrf.d.ts +26 -0
- package/dist/auth/csrf.js +128 -0
- package/dist/auth/csrf.test.d.ts +1 -0
- package/dist/auth/csrf.test.js +143 -0
- package/dist/auth/local-users.d.ts +6 -0
- package/dist/auth/local-users.js +11 -0
- package/dist/auth/local-users.test.js +41 -0
- package/dist/auth/middleware.d.ts +7 -6
- package/dist/auth/oidc/dcr.d.ts +70 -0
- package/dist/auth/oidc/dcr.js +160 -0
- package/dist/auth/oidc/dcr.test.d.ts +1 -0
- package/dist/auth/oidc/dcr.test.js +109 -0
- package/dist/auth/oidc/endpoints.js +44 -0
- package/dist/auth/oidc/profiles.d.ts +22 -0
- package/dist/auth/oidc/profiles.js +95 -0
- package/dist/auth/oidc/profiles.test.d.ts +1 -0
- package/dist/auth/oidc/profiles.test.js +51 -0
- package/dist/auth/oidc/runtime.d.ts +3 -0
- package/dist/auth/oidc/runtime.js +16 -3
- package/dist/auth/oidc/runtime.test.js +1 -0
- package/dist/auth/policy/batch-dry-run.d.ts +56 -0
- package/dist/auth/policy/batch-dry-run.js +129 -0
- package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
- package/dist/auth/policy/batch-dry-run.test.js +140 -0
- package/dist/auth/policy/engine.d.ts +20 -4
- package/dist/auth/policy/engine.js +16 -2
- package/dist/auth/policy/loader.d.ts +11 -1
- package/dist/auth/policy/loader.js +37 -0
- package/dist/auth/policy/loader.test.d.ts +1 -0
- package/dist/auth/policy/loader.test.js +86 -0
- package/dist/auth/policy/opa.d.ts +5 -5
- package/dist/auth/policy/opa.js +25 -14
- package/dist/auth/policy/opa.test.js +48 -0
- package/dist/auth/rbac.d.ts +23 -1
- package/dist/auth/rbac.js +43 -1
- package/dist/auth/rbac.test.js +62 -0
- package/dist/cli/index.js +3 -0
- package/dist/cli/inspector-config.d.ts +9 -0
- package/dist/cli/inspector-config.js +28 -0
- package/dist/cli/inspector-config.test.d.ts +1 -0
- package/dist/cli/inspector-config.test.js +33 -0
- package/dist/cli/lib.d.ts +1 -1
- package/dist/cli/lib.js +1 -0
- package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
- package/dist/conformance/mcp-2025-11-25.test.js +206 -0
- package/dist/connectors/interface.d.ts +5 -1
- package/dist/connectors/loader.js +6 -4
- package/dist/connectors/loader.test.d.ts +1 -0
- package/dist/connectors/loader.test.js +78 -0
- package/dist/connectors/prometheus.test.js +31 -13
- package/dist/connectors/registry.d.ts +13 -0
- package/dist/connectors/registry.js +30 -0
- package/dist/connectors/registry.test.js +56 -2
- package/dist/context.d.ts +32 -0
- package/dist/context.js +35 -0
- package/dist/context.test.d.ts +1 -0
- package/dist/context.test.js +58 -0
- package/dist/federation/registry.d.ts +32 -0
- package/dist/federation/registry.js +77 -0
- package/dist/federation/registry.test.d.ts +1 -0
- package/dist/federation/registry.test.js +130 -0
- package/dist/federation/upstream.d.ts +60 -0
- package/dist/federation/upstream.js +114 -0
- package/dist/index.js +1188 -120
- package/dist/middleware/ssrfGuard.d.ts +15 -0
- package/dist/middleware/ssrfGuard.js +103 -0
- package/dist/middleware/ssrfGuard.test.d.ts +1 -0
- package/dist/middleware/ssrfGuard.test.js +81 -0
- package/dist/observability/otel.d.ts +20 -0
- package/dist/observability/otel.js +118 -0
- package/dist/observability/otel.test.d.ts +1 -0
- package/dist/observability/otel.test.js +56 -0
- package/dist/openapi.js +215 -7
- package/dist/openapi.test.js +34 -0
- package/dist/postmortem/synthesizer.d.ts +83 -0
- package/dist/postmortem/synthesizer.js +205 -0
- package/dist/postmortem/synthesizer.test.d.ts +1 -0
- package/dist/postmortem/synthesizer.test.js +141 -0
- package/dist/products/loader.d.ts +31 -3
- package/dist/products/loader.js +77 -4
- package/dist/products/loader.test.js +90 -1
- package/dist/quota/charge.d.ts +28 -0
- package/dist/quota/charge.js +30 -0
- package/dist/quota/charge.test.d.ts +1 -0
- package/dist/quota/charge.test.js +83 -0
- package/dist/quota/limiter.d.ts +29 -4
- package/dist/quota/limiter.js +64 -8
- package/dist/quota/limiter.test.js +86 -0
- package/dist/scim/group-role-map.d.ts +4 -0
- package/dist/scim/group-role-map.js +33 -0
- package/dist/scim/group-role-map.test.d.ts +1 -0
- package/dist/scim/group-role-map.test.js +33 -0
- package/dist/scim/routes.d.ts +15 -0
- package/dist/scim/routes.js +249 -0
- package/dist/scim/store.d.ts +37 -0
- package/dist/scim/store.js +178 -0
- package/dist/scim/store.test.d.ts +1 -0
- package/dist/scim/store.test.js +121 -0
- package/dist/scim/types.d.ts +73 -0
- package/dist/scim/types.js +29 -0
- package/dist/sdk/hooks.d.ts +77 -0
- package/dist/sdk/hooks.js +72 -0
- package/dist/sdk/hooks.test.d.ts +1 -0
- package/dist/sdk/hooks.test.js +159 -0
- package/dist/sdk/index.d.ts +2 -0
- package/dist/sdk/index.js +1 -0
- package/dist/sdk/manifest-schema.d.ts +17 -0
- package/dist/sdk/manifest-schema.js +21 -0
- package/dist/tools/context-seam.test.js +6 -1
- package/dist/tools/detect-anomalies.d.ts +1 -1
- package/dist/tools/detect-anomalies.js +5 -4
- package/dist/tools/generate-postmortem.d.ts +35 -0
- package/dist/tools/generate-postmortem.js +191 -0
- package/dist/tools/get-anomaly-history.d.ts +35 -0
- package/dist/tools/get-anomaly-history.js +126 -0
- package/dist/tools/get-service-health.d.ts +1 -1
- package/dist/tools/get-service-health.js +4 -3
- package/dist/tools/list-services.d.ts +1 -1
- package/dist/tools/list-services.js +3 -2
- package/dist/tools/list-sources.d.ts +1 -1
- package/dist/tools/list-sources.js +6 -2
- package/dist/tools/query-logs.d.ts +1 -1
- package/dist/tools/query-logs.js +2 -2
- package/dist/tools/query-metrics.d.ts +1 -1
- package/dist/tools/query-metrics.js +19 -6
- package/dist/tools/query-traces.d.ts +47 -0
- package/dist/tools/query-traces.js +145 -0
- package/dist/tools/query-traces.test.d.ts +1 -0
- package/dist/tools/query-traces.test.js +110 -0
- package/dist/tools/registry-names.d.ts +35 -0
- package/dist/tools/registry-names.js +54 -0
- package/dist/tools/registry-names.test.d.ts +1 -0
- package/dist/tools/registry-names.test.js +61 -0
- package/dist/tools/topology.d.ts +3 -3
- package/dist/tools/topology.js +10 -6
- package/dist/topology/merge.d.ts +22 -0
- package/dist/topology/merge.js +178 -0
- package/dist/topology/merge.test.d.ts +1 -0
- package/dist/topology/merge.test.js +110 -0
- package/dist/transport/sessionStore.d.ts +66 -0
- package/dist/transport/sessionStore.js +138 -0
- package/dist/transport/sessionStore.test.d.ts +1 -0
- package/dist/transport/sessionStore.test.js +118 -0
- package/dist/transport/websocket.d.ts +35 -0
- package/dist/transport/websocket.js +133 -0
- package/dist/transport/websocket.test.d.ts +1 -0
- package/dist/transport/websocket.test.js +124 -0
- package/dist/types.d.ts +51 -0
- package/dist/ui/index.html +1729 -100
- 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 {
|
|
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
|
|
132
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
718
|
-
for (const
|
|
719
|
-
for (const
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
944
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 },
|
|
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
|
|
1626
|
-
const
|
|
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 },
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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 "${
|
|
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
|
-
|
|
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
|
-
|
|
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);
|