@thotischner/observability-mcp 1.7.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/config/products.yaml.example +48 -0
  2. package/dist/analysis/history.d.ts +70 -0
  3. package/dist/analysis/history.js +170 -0
  4. package/dist/analysis/history.test.d.ts +1 -0
  5. package/dist/analysis/history.test.js +141 -0
  6. package/dist/audit/log.d.ts +108 -0
  7. package/dist/audit/log.js +200 -0
  8. package/dist/audit/log.test.d.ts +1 -0
  9. package/dist/audit/log.test.js +147 -0
  10. package/dist/audit/middleware.d.ts +20 -0
  11. package/dist/audit/middleware.js +50 -0
  12. package/dist/audit/redaction-bypass.d.ts +67 -0
  13. package/dist/audit/redaction-bypass.js +64 -0
  14. package/dist/audit/redaction-bypass.test.d.ts +1 -0
  15. package/dist/audit/redaction-bypass.test.js +72 -0
  16. package/dist/audit/sinks/types.d.ts +18 -0
  17. package/dist/audit/sinks/types.js +1 -0
  18. package/dist/audit/sinks/webhook.d.ts +45 -0
  19. package/dist/audit/sinks/webhook.js +111 -0
  20. package/dist/audit/sinks/webhook.test.d.ts +1 -0
  21. package/dist/audit/sinks/webhook.test.js +162 -0
  22. package/dist/auth/credentials.d.ts +29 -0
  23. package/dist/auth/credentials.js +53 -1
  24. package/dist/auth/credentials.test.js +46 -1
  25. package/dist/auth/csrf.d.ts +26 -0
  26. package/dist/auth/csrf.js +128 -0
  27. package/dist/auth/csrf.test.d.ts +1 -0
  28. package/dist/auth/csrf.test.js +143 -0
  29. package/dist/auth/local-users.d.ts +68 -0
  30. package/dist/auth/local-users.js +154 -0
  31. package/dist/auth/local-users.test.d.ts +1 -0
  32. package/dist/auth/local-users.test.js +121 -0
  33. package/dist/auth/middleware.d.ts +49 -0
  34. package/dist/auth/middleware.js +65 -0
  35. package/dist/auth/middleware.test.d.ts +1 -0
  36. package/dist/auth/middleware.test.js +90 -0
  37. package/dist/auth/oidc/client.d.ts +73 -0
  38. package/dist/auth/oidc/client.js +104 -0
  39. package/dist/auth/oidc/client.test.d.ts +1 -0
  40. package/dist/auth/oidc/client.test.js +121 -0
  41. package/dist/auth/oidc/dcr.d.ts +70 -0
  42. package/dist/auth/oidc/dcr.js +160 -0
  43. package/dist/auth/oidc/dcr.test.d.ts +1 -0
  44. package/dist/auth/oidc/dcr.test.js +109 -0
  45. package/dist/auth/oidc/discovery.d.ts +38 -0
  46. package/dist/auth/oidc/discovery.js +48 -0
  47. package/dist/auth/oidc/discovery.test.d.ts +1 -0
  48. package/dist/auth/oidc/discovery.test.js +68 -0
  49. package/dist/auth/oidc/endpoints.d.ts +20 -0
  50. package/dist/auth/oidc/endpoints.js +168 -0
  51. package/dist/auth/oidc/endpoints.test.d.ts +7 -0
  52. package/dist/auth/oidc/endpoints.test.js +304 -0
  53. package/dist/auth/oidc/flow-cookie.d.ts +57 -0
  54. package/dist/auth/oidc/flow-cookie.js +142 -0
  55. package/dist/auth/oidc/flow-cookie.test.d.ts +1 -0
  56. package/dist/auth/oidc/flow-cookie.test.js +0 -0
  57. package/dist/auth/oidc/index.d.ts +7 -0
  58. package/dist/auth/oidc/index.js +6 -0
  59. package/dist/auth/oidc/jwks.d.ts +36 -0
  60. package/dist/auth/oidc/jwks.js +69 -0
  61. package/dist/auth/oidc/jwks.test.d.ts +1 -0
  62. package/dist/auth/oidc/jwks.test.js +65 -0
  63. package/dist/auth/oidc/jwt.d.ts +62 -0
  64. package/dist/auth/oidc/jwt.js +113 -0
  65. package/dist/auth/oidc/jwt.test.d.ts +1 -0
  66. package/dist/auth/oidc/jwt.test.js +141 -0
  67. package/dist/auth/oidc/pkce.d.ts +19 -0
  68. package/dist/auth/oidc/pkce.js +43 -0
  69. package/dist/auth/oidc/pkce.test.d.ts +1 -0
  70. package/dist/auth/oidc/pkce.test.js +55 -0
  71. package/dist/auth/oidc/profiles.d.ts +22 -0
  72. package/dist/auth/oidc/profiles.js +95 -0
  73. package/dist/auth/oidc/profiles.test.d.ts +1 -0
  74. package/dist/auth/oidc/profiles.test.js +51 -0
  75. package/dist/auth/oidc/runtime.d.ts +66 -0
  76. package/dist/auth/oidc/runtime.js +142 -0
  77. package/dist/auth/oidc/runtime.test.d.ts +1 -0
  78. package/dist/auth/oidc/runtime.test.js +181 -0
  79. package/dist/auth/policy/batch-dry-run.d.ts +56 -0
  80. package/dist/auth/policy/batch-dry-run.js +129 -0
  81. package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
  82. package/dist/auth/policy/batch-dry-run.test.js +140 -0
  83. package/dist/auth/policy/engine.d.ts +64 -0
  84. package/dist/auth/policy/engine.js +87 -0
  85. package/dist/auth/policy/engine.test.d.ts +1 -0
  86. package/dist/auth/policy/engine.test.js +98 -0
  87. package/dist/auth/policy/loader.d.ts +45 -0
  88. package/dist/auth/policy/loader.js +137 -0
  89. package/dist/auth/policy/loader.test.d.ts +1 -0
  90. package/dist/auth/policy/loader.test.js +86 -0
  91. package/dist/auth/policy/opa.d.ts +69 -0
  92. package/dist/auth/policy/opa.js +173 -0
  93. package/dist/auth/policy/opa.test.d.ts +1 -0
  94. package/dist/auth/policy/opa.test.js +206 -0
  95. package/dist/auth/rbac.d.ts +62 -0
  96. package/dist/auth/rbac.js +162 -0
  97. package/dist/auth/rbac.test.d.ts +1 -0
  98. package/dist/auth/rbac.test.js +183 -0
  99. package/dist/auth/session.d.ts +66 -0
  100. package/dist/auth/session.js +146 -0
  101. package/dist/auth/session.test.d.ts +1 -0
  102. package/dist/auth/session.test.js +90 -0
  103. package/dist/catalog/loader.d.ts +67 -0
  104. package/dist/catalog/loader.js +122 -0
  105. package/dist/catalog/loader.test.d.ts +1 -0
  106. package/dist/catalog/loader.test.js +108 -0
  107. package/dist/cli/index.js +3 -0
  108. package/dist/cli/inspector-config.d.ts +9 -0
  109. package/dist/cli/inspector-config.js +28 -0
  110. package/dist/cli/inspector-config.test.d.ts +1 -0
  111. package/dist/cli/inspector-config.test.js +33 -0
  112. package/dist/cli/lib.d.ts +1 -1
  113. package/dist/cli/lib.js +1 -0
  114. package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
  115. package/dist/conformance/mcp-2025-11-25.test.js +206 -0
  116. package/dist/connectors/interface.d.ts +5 -1
  117. package/dist/connectors/loader.js +6 -4
  118. package/dist/connectors/loader.test.d.ts +1 -0
  119. package/dist/connectors/loader.test.js +78 -0
  120. package/dist/connectors/prometheus.test.js +31 -13
  121. package/dist/connectors/registry.d.ts +13 -0
  122. package/dist/connectors/registry.js +30 -0
  123. package/dist/connectors/registry.test.js +56 -2
  124. package/dist/context.d.ts +45 -1
  125. package/dist/context.js +40 -1
  126. package/dist/context.test.d.ts +1 -0
  127. package/dist/context.test.js +58 -0
  128. package/dist/federation/registry.d.ts +32 -0
  129. package/dist/federation/registry.js +77 -0
  130. package/dist/federation/registry.test.d.ts +1 -0
  131. package/dist/federation/registry.test.js +130 -0
  132. package/dist/federation/upstream.d.ts +60 -0
  133. package/dist/federation/upstream.js +114 -0
  134. package/dist/index.js +2124 -73
  135. package/dist/middleware/ssrfGuard.d.ts +15 -0
  136. package/dist/middleware/ssrfGuard.js +103 -0
  137. package/dist/middleware/ssrfGuard.test.d.ts +1 -0
  138. package/dist/middleware/ssrfGuard.test.js +81 -0
  139. package/dist/net/egress-policy.js +2 -0
  140. package/dist/observability/otel.d.ts +20 -0
  141. package/dist/observability/otel.js +118 -0
  142. package/dist/observability/otel.test.d.ts +1 -0
  143. package/dist/observability/otel.test.js +56 -0
  144. package/dist/openapi.js +654 -6
  145. package/dist/openapi.test.d.ts +1 -0
  146. package/dist/openapi.test.js +98 -0
  147. package/dist/policy/redact.d.ts +44 -0
  148. package/dist/policy/redact.js +144 -0
  149. package/dist/policy/redact.test.d.ts +1 -0
  150. package/dist/policy/redact.test.js +172 -0
  151. package/dist/postmortem/synthesizer.d.ts +83 -0
  152. package/dist/postmortem/synthesizer.js +205 -0
  153. package/dist/postmortem/synthesizer.test.d.ts +1 -0
  154. package/dist/postmortem/synthesizer.test.js +141 -0
  155. package/dist/products/loader.d.ts +112 -0
  156. package/dist/products/loader.js +289 -0
  157. package/dist/products/loader.test.d.ts +1 -0
  158. package/dist/products/loader.test.js +257 -0
  159. package/dist/quota/charge.d.ts +28 -0
  160. package/dist/quota/charge.js +30 -0
  161. package/dist/quota/charge.test.d.ts +1 -0
  162. package/dist/quota/charge.test.js +83 -0
  163. package/dist/quota/limiter.d.ts +97 -0
  164. package/dist/quota/limiter.js +161 -0
  165. package/dist/quota/limiter.test.d.ts +1 -0
  166. package/dist/quota/limiter.test.js +205 -0
  167. package/dist/quota/token-budget.d.ts +119 -0
  168. package/dist/quota/token-budget.js +297 -0
  169. package/dist/quota/token-budget.test.d.ts +1 -0
  170. package/dist/quota/token-budget.test.js +215 -0
  171. package/dist/scim/group-role-map.d.ts +4 -0
  172. package/dist/scim/group-role-map.js +33 -0
  173. package/dist/scim/group-role-map.test.d.ts +1 -0
  174. package/dist/scim/group-role-map.test.js +33 -0
  175. package/dist/scim/routes.d.ts +15 -0
  176. package/dist/scim/routes.js +249 -0
  177. package/dist/scim/store.d.ts +37 -0
  178. package/dist/scim/store.js +178 -0
  179. package/dist/scim/store.test.d.ts +1 -0
  180. package/dist/scim/store.test.js +121 -0
  181. package/dist/scim/types.d.ts +73 -0
  182. package/dist/scim/types.js +29 -0
  183. package/dist/sdk/hooks.d.ts +77 -0
  184. package/dist/sdk/hooks.js +72 -0
  185. package/dist/sdk/hooks.test.d.ts +1 -0
  186. package/dist/sdk/hooks.test.js +159 -0
  187. package/dist/sdk/index.d.ts +2 -0
  188. package/dist/sdk/index.js +1 -0
  189. package/dist/sdk/manifest-schema.d.ts +17 -0
  190. package/dist/sdk/manifest-schema.js +21 -0
  191. package/dist/tenancy/context.d.ts +45 -0
  192. package/dist/tenancy/context.js +97 -0
  193. package/dist/tenancy/context.test.d.ts +1 -0
  194. package/dist/tenancy/context.test.js +72 -0
  195. package/dist/tenancy/migration.test.d.ts +7 -0
  196. package/dist/tenancy/migration.test.js +75 -0
  197. package/dist/tools/context-seam.test.js +6 -1
  198. package/dist/tools/detect-anomalies.d.ts +1 -1
  199. package/dist/tools/detect-anomalies.js +5 -4
  200. package/dist/tools/generate-postmortem.d.ts +35 -0
  201. package/dist/tools/generate-postmortem.js +191 -0
  202. package/dist/tools/get-anomaly-history.d.ts +35 -0
  203. package/dist/tools/get-anomaly-history.js +126 -0
  204. package/dist/tools/get-service-health.d.ts +1 -1
  205. package/dist/tools/get-service-health.js +4 -3
  206. package/dist/tools/list-services.d.ts +1 -1
  207. package/dist/tools/list-services.js +3 -2
  208. package/dist/tools/list-sources.d.ts +1 -1
  209. package/dist/tools/list-sources.js +6 -2
  210. package/dist/tools/query-logs.d.ts +1 -1
  211. package/dist/tools/query-logs.js +2 -2
  212. package/dist/tools/query-metrics.d.ts +1 -1
  213. package/dist/tools/query-metrics.js +19 -6
  214. package/dist/tools/query-traces.d.ts +47 -0
  215. package/dist/tools/query-traces.js +145 -0
  216. package/dist/tools/query-traces.test.d.ts +1 -0
  217. package/dist/tools/query-traces.test.js +110 -0
  218. package/dist/tools/registry-names.d.ts +35 -0
  219. package/dist/tools/registry-names.js +54 -0
  220. package/dist/tools/registry-names.test.d.ts +1 -0
  221. package/dist/tools/registry-names.test.js +61 -0
  222. package/dist/tools/topology.d.ts +3 -3
  223. package/dist/tools/topology.js +10 -6
  224. package/dist/topology/merge.d.ts +22 -0
  225. package/dist/topology/merge.js +178 -0
  226. package/dist/topology/merge.test.d.ts +1 -0
  227. package/dist/topology/merge.test.js +110 -0
  228. package/dist/transport/sessionStore.d.ts +66 -0
  229. package/dist/transport/sessionStore.js +138 -0
  230. package/dist/transport/sessionStore.test.d.ts +1 -0
  231. package/dist/transport/sessionStore.test.js +118 -0
  232. package/dist/transport/websocket.d.ts +35 -0
  233. package/dist/transport/websocket.js +133 -0
  234. package/dist/transport/websocket.test.d.ts +1 -0
  235. package/dist/transport/websocket.test.js +124 -0
  236. package/dist/types.d.ts +51 -0
  237. package/dist/ui/index.html +3083 -88
  238. package/package.json +32 -5
@@ -30,12 +30,12 @@ export const queryMetricsDefinition = {
30
30
  required: ["service", "metric"],
31
31
  },
32
32
  };
33
- export async function queryMetricsHandler(registry, args, _ctx = defaultContext()) {
33
+ export async function queryMetricsHandler(registry, args, ctx = defaultContext()) {
34
34
  // Coarse single-tenant source scoping: if the principal is restricted to a
35
35
  // source allow-list, deny an explicit out-of-scope source.
36
- if (_ctx.allowedSources &&
36
+ if (ctx.allowedSources &&
37
37
  args.source &&
38
- !_ctx.allowedSources.includes(args.source)) {
38
+ !ctx.allowedSources.includes(args.source)) {
39
39
  return errorResponse(`forbidden: source "${args.source}" is not in your allowed sources`);
40
40
  }
41
41
  const svcErr = validateServiceName(args.service);
@@ -51,12 +51,25 @@ export async function queryMetricsHandler(registry, args, _ctx = defaultContext(
51
51
  if (args.groupBy && !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(args.groupBy)) {
52
52
  return errorResponse(`Invalid groupBy "${args.groupBy}". Must be a valid Prometheus label name (alphanumeric + underscore, starting with letter/underscore).`);
53
53
  }
54
+ // Tenant-scoped resolution: an explicit `source` from the agent
55
+ // must belong to the caller's tenant (or be a global / untagged
56
+ // source) — cross-tenant sources resolve to undefined exactly like
57
+ // a missing source, preserving the no-existence-leak posture used
58
+ // elsewhere in the tenancy layer.
54
59
  const connectors = args.source
55
- ? [registry.getByName(args.source)].filter(Boolean)
56
- : registry.getBySignal("metrics");
60
+ ? [registry.getByNameForTenant(args.source, ctx.tenant)].filter(Boolean)
61
+ : registry.getByTenant(ctx.tenant).filter((c) => c.signalType === "metrics");
57
62
  if (connectors.length === 0) {
63
+ // Distinct messages but identical posture: the source-named branch
64
+ // could land here either because the source doesn't exist OR
65
+ // belongs to another tenant — both surface as "not found", same
66
+ // shape, no existence leak. The fan-out branch lands here only on
67
+ // an empty registry.
68
+ const msg = args.source
69
+ ? `Source "${args.source}" not found`
70
+ : "No metrics backends configured";
58
71
  return {
59
- content: [{ type: "text", text: JSON.stringify({ error: "No metrics backends configured" }) }],
72
+ content: [{ type: "text", text: JSON.stringify({ error: msg }) }],
60
73
  isError: true,
61
74
  };
62
75
  }
@@ -0,0 +1,47 @@
1
+ import type { ConnectorRegistry } from "../connectors/registry.js";
2
+ import { type RequestContext } from "../context.js";
3
+ export declare const queryTracesDefinition: {
4
+ name: "query_traces";
5
+ description: string;
6
+ inputSchema: {
7
+ type: "object";
8
+ properties: {
9
+ service: {
10
+ type: string;
11
+ description: string;
12
+ };
13
+ duration: {
14
+ type: string;
15
+ description: string;
16
+ };
17
+ filter: {
18
+ type: string;
19
+ description: string;
20
+ };
21
+ limit: {
22
+ type: string;
23
+ description: string;
24
+ };
25
+ errorsOnly: {
26
+ type: string;
27
+ description: string;
28
+ };
29
+ };
30
+ required: string[];
31
+ };
32
+ };
33
+ export declare function queryTracesHandler(registry: ConnectorRegistry, args: {
34
+ service: string;
35
+ duration?: string;
36
+ filter?: string;
37
+ limit?: number;
38
+ errorsOnly?: boolean;
39
+ }, ctx?: RequestContext): Promise<{
40
+ content: {
41
+ type: "text";
42
+ text: string;
43
+ }[];
44
+ isError: boolean;
45
+ }>;
46
+ /** Pure percentile over a numeric array. Returns 0 for empty input. */
47
+ export declare function percentile(values: number[], p: number): number;
@@ -0,0 +1,145 @@
1
+ // query_traces — Phase F13.
2
+ //
3
+ // Surfaces distributed traces from any connector that implements the
4
+ // queryTraces capability. Fans out across every traces-signal
5
+ // connector in the caller's tenant, merges the returned trace
6
+ // summaries, and recomputes a global p50/p95 over the merged set
7
+ // (rather than blindly averaging per-source summaries).
8
+ //
9
+ // Backend support today: a Tempo connector + a Jaeger shim ship as
10
+ // filesystem plugins. Any connector that implements queryTraces
11
+ // participates automatically — no changes needed in the tool layer
12
+ // when a new backend lands.
13
+ import { defaultContext } from "../context.js";
14
+ import { validateDuration, validateServiceName, errorResponse } from "./validation.js";
15
+ export const queryTracesDefinition = {
16
+ name: "query_traces",
17
+ description: [
18
+ "Query distributed traces for a service over a given timeframe.",
19
+ "Returns ranked trace summaries with duration, error status, and span count, plus a p50/p95 duration aggregate across the returned set.",
20
+ "When to use: investigating tail-latency outliers, walking call chains across services for a known time window, or pulling related traces for an anomaly the metric/log tools surfaced first.",
21
+ "Behavior: read-only; results may be capped via `limit` (default 50). `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.",
22
+ "Related: `query_metrics` for the per-service latency series; `get_blast_radius` for the topology a trace traverses.",
23
+ ].join(" "),
24
+ inputSchema: {
25
+ type: "object",
26
+ properties: {
27
+ service: { type: "string", description: "Service name (e.g. 'payment-service')" },
28
+ duration: { type: "string", description: "Rolling time window (e.g. '5m', '1h'). Default '15m'." },
29
+ filter: { type: "string", description: "Backend-native filter (TraceQL on Tempo, tag query on Jaeger). Optional." },
30
+ limit: { type: "number", description: "Soft cap on returned trace summaries. Default 50." },
31
+ errorsOnly: { type: "boolean", description: "If true, only traces with at least one error span." },
32
+ },
33
+ required: ["service"],
34
+ },
35
+ };
36
+ export async function queryTracesHandler(registry, args, ctx = defaultContext()) {
37
+ const svcErr = validateServiceName(args.service);
38
+ if (svcErr)
39
+ return errorResponse(svcErr);
40
+ const duration = args.duration || "15m";
41
+ const durationErr = validateDuration(duration);
42
+ if (durationErr)
43
+ return errorResponse(durationErr);
44
+ // signalType filter: traces-aware connectors should report "traces"
45
+ // (the new signal type) but we also accept any connector that
46
+ // declares queryTraces — back-compat for connectors that haven't
47
+ // updated their signalType yet.
48
+ const candidates = registry
49
+ .getByTenant(ctx.tenant)
50
+ .filter((c) => typeof c.queryTraces === "function");
51
+ if (candidates.length === 0) {
52
+ return {
53
+ content: [
54
+ {
55
+ type: "text",
56
+ text: JSON.stringify({ error: "No trace backends configured" }),
57
+ },
58
+ ],
59
+ isError: true,
60
+ };
61
+ }
62
+ const results = [];
63
+ const errors = [];
64
+ for (const connector of candidates) {
65
+ if (!connector.queryTraces)
66
+ continue;
67
+ try {
68
+ const r = await connector.queryTraces({
69
+ service: args.service,
70
+ duration,
71
+ filter: args.filter,
72
+ limit: args.limit,
73
+ errorsOnly: args.errorsOnly,
74
+ });
75
+ results.push(r);
76
+ }
77
+ catch (err) {
78
+ const msg = err instanceof Error ? err.message : String(err);
79
+ console.error(`Trace query failed on ${connector.name}:`, msg);
80
+ errors.push(`${connector.name}: ${msg}`);
81
+ }
82
+ }
83
+ if (results.length === 0) {
84
+ return {
85
+ content: [
86
+ {
87
+ type: "text",
88
+ text: JSON.stringify({
89
+ error: errors.length > 0 ? `Query failed: ${errors.join("; ")}` : "No traces returned",
90
+ service: args.service,
91
+ duration,
92
+ }),
93
+ },
94
+ ],
95
+ isError: errors.length > 0,
96
+ };
97
+ }
98
+ // Merge: every source returns its own ranked set; we keep the union
99
+ // and recompute a global p50/p95 over the merged set so the summary
100
+ // reflects what the tool actually returned to the caller.
101
+ const merged = [];
102
+ for (const r of results)
103
+ merged.push(...r.traces);
104
+ // Sort hottest-first by duration, then truncate to the requested limit.
105
+ merged.sort((a, b) => b.durationMs - a.durationMs);
106
+ const limit = args.limit ?? 50;
107
+ const capped = merged.slice(0, limit);
108
+ const errorCount = capped.filter((t) => t.hasError).length;
109
+ const summary = {
110
+ total: capped.length,
111
+ errorCount,
112
+ p50DurationMs: percentile(capped.map((t) => t.durationMs), 0.5),
113
+ p95DurationMs: percentile(capped.map((t) => t.durationMs), 0.95),
114
+ };
115
+ return {
116
+ content: [
117
+ {
118
+ type: "text",
119
+ text: JSON.stringify({
120
+ service: args.service,
121
+ duration,
122
+ sources: results.map((r) => r.source),
123
+ summary,
124
+ traces: capped,
125
+ errors: errors.length > 0 ? errors : undefined,
126
+ }),
127
+ },
128
+ ],
129
+ isError: false,
130
+ };
131
+ }
132
+ /** Pure percentile over a numeric array. Returns 0 for empty input. */
133
+ export function percentile(values, p) {
134
+ if (values.length === 0)
135
+ return 0;
136
+ const sorted = [...values].sort((a, b) => a - b);
137
+ // Linear interpolation between the two surrounding samples.
138
+ const rank = p * (sorted.length - 1);
139
+ const lo = Math.floor(rank);
140
+ const hi = Math.ceil(rank);
141
+ if (lo === hi)
142
+ return sorted[lo] ?? 0;
143
+ const frac = rank - lo;
144
+ return Math.round((sorted[lo] ?? 0) * (1 - frac) + (sorted[hi] ?? 0) * frac);
145
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,110 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { queryTracesHandler, percentile } from "./query-traces.js";
4
+ function span(traceId, durationMs, opts = {}) {
5
+ return {
6
+ traceId,
7
+ rootName: "GET /pay",
8
+ rootService: opts.service ?? "payment-service",
9
+ durationMs,
10
+ spanCount: 4,
11
+ hasError: opts.hasError ?? false,
12
+ startTs: "2026-06-06T00:00:00.000Z",
13
+ };
14
+ }
15
+ function fakeRegistry(connectors) {
16
+ return {
17
+ getByTenant: (_tenant) => connectors,
18
+ };
19
+ }
20
+ function parseResponse(r) {
21
+ return JSON.parse(r.content[0].text);
22
+ }
23
+ test("percentile: empty → 0; single value → that value; linear interpolation otherwise", () => {
24
+ assert.equal(percentile([], 0.5), 0);
25
+ assert.equal(percentile([10], 0.5), 10);
26
+ assert.equal(percentile([1, 2, 3, 4, 5], 0.5), 3);
27
+ // 95th of [1..20] sits between index 18 (19) and 19 (20)
28
+ const vs = Array.from({ length: 20 }, (_, i) => i + 1);
29
+ assert.ok(percentile(vs, 0.95) >= 19 && percentile(vs, 0.95) <= 20);
30
+ });
31
+ test("query_traces: rejects invalid service name", async () => {
32
+ const r = await queryTracesHandler(fakeRegistry([]), { service: "bad name with space" });
33
+ assert.equal(r.isError, true);
34
+ });
35
+ test("query_traces: rejects invalid duration", async () => {
36
+ const r = await queryTracesHandler(fakeRegistry([]), { service: "ok", duration: "bogus" });
37
+ assert.equal(r.isError, true);
38
+ });
39
+ test("query_traces: no trace backends configured → isError + clear message", async () => {
40
+ // Connectors without queryTraces are skipped.
41
+ const conn = { name: "prom", signalType: "metrics" };
42
+ const r = await queryTracesHandler(fakeRegistry([conn]), { service: "ok" });
43
+ assert.equal(r.isError, true);
44
+ assert.match(parseResponse(r).error, /No trace backends/);
45
+ });
46
+ test("query_traces: merges spans from every connector that returned, caps to limit, ranks by duration", async () => {
47
+ const tempo = {
48
+ name: "tempo",
49
+ signalType: "metrics",
50
+ queryTraces: async () => ({
51
+ source: "tempo",
52
+ service: "payment",
53
+ traces: [span("aaa", 100), span("bbb", 800), span("ccc", 300)],
54
+ summary: { total: 3, errorCount: 0, p50DurationMs: 300, p95DurationMs: 800 },
55
+ }),
56
+ };
57
+ const jaeger = {
58
+ name: "jaeger",
59
+ signalType: "metrics",
60
+ queryTraces: async () => ({
61
+ source: "jaeger",
62
+ service: "payment",
63
+ traces: [span("ddd", 500, { hasError: true }), span("eee", 200)],
64
+ summary: { total: 2, errorCount: 1, p50DurationMs: 350, p95DurationMs: 500 },
65
+ }),
66
+ };
67
+ const r = await queryTracesHandler(fakeRegistry([tempo, jaeger]), { service: "payment", limit: 4 });
68
+ const body = parseResponse(r);
69
+ assert.deepEqual(body.sources.sort(), ["jaeger", "tempo"]);
70
+ assert.equal(body.traces.length, 4, "limit honoured");
71
+ // Sorted hottest-first
72
+ assert.equal(body.traces[0].durationMs, 800);
73
+ assert.equal(body.traces[1].durationMs, 500);
74
+ assert.equal(body.summary.errorCount, 1);
75
+ });
76
+ test("query_traces: surfaces per-connector errors but still returns successful results", async () => {
77
+ const ok = {
78
+ name: "tempo",
79
+ signalType: "metrics",
80
+ queryTraces: async () => ({
81
+ source: "tempo",
82
+ service: "payment",
83
+ traces: [span("aaa", 50)],
84
+ summary: { total: 1, errorCount: 0, p50DurationMs: 50, p95DurationMs: 50 },
85
+ }),
86
+ };
87
+ const broken = {
88
+ name: "jaeger",
89
+ signalType: "metrics",
90
+ queryTraces: async () => {
91
+ throw new Error("upstream 503");
92
+ },
93
+ };
94
+ const r = await queryTracesHandler(fakeRegistry([ok, broken]), { service: "payment" });
95
+ const body = parseResponse(r);
96
+ assert.equal(body.errors.length, 1);
97
+ assert.equal(body.traces.length, 1);
98
+ });
99
+ test("query_traces: all backends fail → isError true + errors surfaced", async () => {
100
+ const broken = {
101
+ name: "tempo",
102
+ signalType: "metrics",
103
+ queryTraces: async () => {
104
+ throw new Error("upstream gone");
105
+ },
106
+ };
107
+ const r = await queryTracesHandler(fakeRegistry([broken]), { service: "payment" });
108
+ assert.equal(r.isError, true);
109
+ assert.match(parseResponse(r).error, /upstream gone/);
110
+ });
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Canonical list of MCP tool names exposed by createMcpServer().
3
+ *
4
+ * Used by:
5
+ * - the Product validator (typo guard): a Product's `tools` allow-
6
+ * list must reference names that actually register, otherwise a
7
+ * bound credential opens an /mcp session with an empty tool set
8
+ * and the agent silently fails.
9
+ * - the keystone integration test in registry-names.test.ts that
10
+ * reads index.ts and asserts the registerTool() call sites match
11
+ * this list 1:1 — a missing entry or an extra one trips the test.
12
+ *
13
+ * Keep this list and the registerTool("name", ...) calls in
14
+ * createMcpServer in sync. The test enforces it.
15
+ */
16
+ export declare const REGISTERED_TOOL_NAMES: readonly ["list_sources", "list_services", "query_metrics", "query_logs", "query_traces", "get_service_health", "detect_anomalies", "get_anomaly_history", "generate_postmortem", "get_topology", "get_blast_radius"];
17
+ export type RegisteredToolName = typeof REGISTERED_TOOL_NAMES[number];
18
+ /** Functional category of a tool, surfaced in /api/tools/registry and
19
+ * used by the Products UI to group the multi-select picker. Keeps
20
+ * operator-facing taxonomy stable even when tool descriptions evolve. */
21
+ export type ToolCategory = "discovery" | "query" | "diagnose" | "topology";
22
+ export interface ToolRegistryEntry {
23
+ name: RegisteredToolName;
24
+ category: ToolCategory;
25
+ /** One-liner — what the tool does, no fluff. The full multi-paragraph
26
+ * description lives in createMcpServer's registerTool() call; this
27
+ * is the catalogue summary the picker shows alongside the name. */
28
+ summary: string;
29
+ }
30
+ export declare const REGISTERED_TOOLS: readonly ToolRegistryEntry[];
31
+ /** Validate a candidate Product tools[] array. Returns the unknown
32
+ * names (empty array = all OK). Pure helper — the caller decides
33
+ * how to surface the rejection (the API handler emits a 422 with a
34
+ * hint of valid names; the YAML loader could decide to warn). */
35
+ export declare function unknownToolNames(tools: readonly string[]): string[];
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Canonical list of MCP tool names exposed by createMcpServer().
3
+ *
4
+ * Used by:
5
+ * - the Product validator (typo guard): a Product's `tools` allow-
6
+ * list must reference names that actually register, otherwise a
7
+ * bound credential opens an /mcp session with an empty tool set
8
+ * and the agent silently fails.
9
+ * - the keystone integration test in registry-names.test.ts that
10
+ * reads index.ts and asserts the registerTool() call sites match
11
+ * this list 1:1 — a missing entry or an extra one trips the test.
12
+ *
13
+ * Keep this list and the registerTool("name", ...) calls in
14
+ * createMcpServer in sync. The test enforces it.
15
+ */
16
+ export const REGISTERED_TOOL_NAMES = [
17
+ "list_sources",
18
+ "list_services",
19
+ "query_metrics",
20
+ "query_logs",
21
+ "query_traces",
22
+ "get_service_health",
23
+ "detect_anomalies",
24
+ "get_anomaly_history",
25
+ "generate_postmortem",
26
+ "get_topology",
27
+ "get_blast_radius",
28
+ ];
29
+ export const REGISTERED_TOOLS = [
30
+ { name: "list_sources", category: "discovery", summary: "List configured observability backends + reachability." },
31
+ { name: "list_services", category: "discovery", summary: "Discover service names across every connected backend." },
32
+ { name: "query_metrics", category: "query", summary: "Fetch the raw time-series for one metric of one service over a window." },
33
+ { name: "query_logs", category: "query", summary: "Fetch matching log lines for one service over a window." },
34
+ { name: "query_traces", category: "query", summary: "Fetch ranked trace summaries for one service over a window." },
35
+ { name: "get_service_health", category: "diagnose", summary: "Aggregated health verdict for one service (metrics + logs)." },
36
+ { name: "detect_anomalies", category: "diagnose", summary: "Scan for anomalous services using z-score / heuristics." },
37
+ { name: "get_anomaly_history", category: "diagnose", summary: "Replay historical anomaly scores for a service (post-mortem context)." },
38
+ { name: "generate_postmortem", category: "diagnose", summary: "One-shot markdown post-mortem stitching anomaly history + traces + blast-radius + logs for a service." },
39
+ { name: "get_topology", category: "topology", summary: "Return the infrastructure topology graph (resources + edges)." },
40
+ { name: "get_blast_radius", category: "topology", summary: "Given a resource, return the impact set if its host(s) fail." },
41
+ ];
42
+ /** Validate a candidate Product tools[] array. Returns the unknown
43
+ * names (empty array = all OK). Pure helper — the caller decides
44
+ * how to surface the rejection (the API handler emits a 422 with a
45
+ * hint of valid names; the YAML loader could decide to warn). */
46
+ export function unknownToolNames(tools) {
47
+ const known = new Set(REGISTERED_TOOL_NAMES);
48
+ const out = [];
49
+ for (const t of tools) {
50
+ if (!known.has(t))
51
+ out.push(t);
52
+ }
53
+ return out;
54
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,61 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import { dirname, join } from "node:path";
6
+ import { REGISTERED_TOOL_NAMES, REGISTERED_TOOLS, unknownToolNames } from "./registry-names.js";
7
+ const here = dirname(fileURLToPath(import.meta.url));
8
+ const INDEX_TS = join(here, "..", "index.ts");
9
+ test("REGISTERED_TOOL_NAMES — 1:1 with createMcpServer's registerTool() calls", () => {
10
+ // This is the integration-test-shaped guard: if a future PR adds
11
+ // or removes a tool registration in index.ts without updating
12
+ // REGISTERED_TOOL_NAMES, the Product validator will silently
13
+ // accept or reject the wrong names. We parse index.ts as text and
14
+ // assert the registered set matches the constant exactly.
15
+ const src = readFileSync(INDEX_TS, "utf8");
16
+ // registerTool("name", → captures the first argument of every call site.
17
+ const re = /\bregisterTool\(\s*"([a-z_][a-z0-9_]*)"/g;
18
+ const found = [];
19
+ for (const m of src.matchAll(re))
20
+ found.push(m[1]);
21
+ found.sort();
22
+ const expected = [...REGISTERED_TOOL_NAMES].sort();
23
+ assert.deepEqual(found, expected, `registerTool() call sites in index.ts don't match REGISTERED_TOOL_NAMES. ` +
24
+ `Add or remove names in src/tools/registry-names.ts to match the actual ` +
25
+ `registrations.\n found: ${JSON.stringify(found)}\n expected: ${JSON.stringify(expected)}`);
26
+ });
27
+ test("unknownToolNames — empty input → no unknowns", () => {
28
+ assert.deepEqual(unknownToolNames([]), []);
29
+ });
30
+ test("unknownToolNames — every registered name is accepted", () => {
31
+ assert.deepEqual(unknownToolNames([...REGISTERED_TOOL_NAMES]), []);
32
+ });
33
+ test("unknownToolNames — surfaces typos and unknown names verbatim", () => {
34
+ const r = unknownToolNames(["list_sources", "query_logz", "get_topologyy"]);
35
+ assert.deepEqual(r, ["query_logz", "get_topologyy"]);
36
+ });
37
+ test("unknownToolNames — case-sensitive (MCP spec)", () => {
38
+ // The spec requires exact name match; "List_Sources" is not the
39
+ // same tool as "list_sources" and silently accepting it would be a
40
+ // worse failure mode than rejecting (mismatched casing wouldn't
41
+ // route to a real tool).
42
+ assert.deepEqual(unknownToolNames(["List_Sources"]), ["List_Sources"]);
43
+ });
44
+ test("REGISTERED_TOOLS — every name has a category + summary", () => {
45
+ for (const t of REGISTERED_TOOLS) {
46
+ assert.ok(t.name, "name required");
47
+ assert.ok(t.category, `category required on ${t.name}`);
48
+ assert.ok(t.summary && t.summary.length > 10, `summary required on ${t.name}`);
49
+ }
50
+ });
51
+ test("REGISTERED_TOOLS — matches REGISTERED_TOOL_NAMES 1:1 (no drift)", () => {
52
+ const a = REGISTERED_TOOLS.map((t) => t.name).sort();
53
+ const b = [...REGISTERED_TOOL_NAMES].sort();
54
+ assert.deepEqual(a, b, "REGISTERED_TOOLS and REGISTERED_TOOL_NAMES must agree");
55
+ });
56
+ test("REGISTERED_TOOLS — every category is one of the four valid values", () => {
57
+ const valid = new Set(["discovery", "query", "diagnose", "topology"]);
58
+ for (const t of REGISTERED_TOOLS) {
59
+ assert.ok(valid.has(t.category), `unknown category '${t.category}' on ${t.name}`);
60
+ }
61
+ });
@@ -12,7 +12,7 @@ interface AggregatedTopology {
12
12
  resources: Resource[];
13
13
  edges: Edge[];
14
14
  }
15
- export declare function aggregateTopology(registry: ConnectorRegistry): Promise<AggregatedTopology>;
15
+ export declare function aggregateTopology(registry: ConnectorRegistry, tenant?: string): Promise<AggregatedTopology>;
16
16
  /**
17
17
  * Resolve a caller-supplied identifier to a Resource. Accepts:
18
18
  * - exact canonical id (e.g. "k8s:pod:default/checkout-7f89d")
@@ -35,7 +35,7 @@ export interface GetTopologyArgs {
35
35
  scope?: string;
36
36
  limit?: number;
37
37
  }
38
- export declare function getTopologyHandler(registry: ConnectorRegistry, args?: GetTopologyArgs, _ctx?: RequestContext): Promise<{
38
+ export declare function getTopologyHandler(registry: ConnectorRegistry, args?: GetTopologyArgs, ctx?: RequestContext): Promise<{
39
39
  content: {
40
40
  type: "text";
41
41
  text: string;
@@ -48,7 +48,7 @@ export declare const getBlastRadiusDefinition: {
48
48
  export interface GetBlastRadiusArgs {
49
49
  resource: string;
50
50
  }
51
- export declare function getBlastRadiusHandler(registry: ConnectorRegistry, args: GetBlastRadiusArgs, _ctx?: RequestContext): Promise<{
51
+ export declare function getBlastRadiusHandler(registry: ConnectorRegistry, args: GetBlastRadiusArgs, ctx?: RequestContext): Promise<{
52
52
  isError: boolean;
53
53
  content: {
54
54
  type: "text";
@@ -14,11 +14,15 @@
14
14
  // connector later requires zero changes here.
15
15
  import { isTopologyProvider } from "../connectors/interface.js";
16
16
  import { defaultContext } from "../context.js";
17
- export async function aggregateTopology(registry) {
17
+ export async function aggregateTopology(registry, tenant) {
18
18
  const sources = [];
19
19
  const resources = [];
20
20
  const edges = [];
21
- for (const c of registry.getAll()) {
21
+ // Tenant-scoped when a tenant is supplied (call sites at the MCP
22
+ // tool layer pass ctx.tenant); undefined preserves the original
23
+ // global behaviour for internal / non-request callers.
24
+ const connectors = tenant ? registry.getByTenant(tenant) : registry.getAll();
25
+ for (const c of connectors) {
22
26
  if (!isTopologyProvider(c))
23
27
  continue;
24
28
  try {
@@ -79,8 +83,8 @@ export const getTopologyDefinition = {
79
83
  name: "get_topology",
80
84
  description: "Return the infrastructure topology graph as Resources and Edges. Use this when an agent needs to reason about which workload runs where, who owns whom, or which scope (namespace/project/folder) a resource belongs to.",
81
85
  };
82
- export async function getTopologyHandler(registry, args = {}, _ctx = defaultContext()) {
83
- const agg = await aggregateTopology(registry);
86
+ export async function getTopologyHandler(registry, args = {}, ctx = defaultContext()) {
87
+ const agg = await aggregateTopology(registry, ctx.tenant);
84
88
  // Filtering — all optional. Filters compose conjunctively.
85
89
  let resources = agg.resources;
86
90
  let edges = agg.edges;
@@ -133,8 +137,8 @@ export const getBlastRadiusDefinition = {
133
137
  name: "get_blast_radius",
134
138
  description: "Given a resource, return the impact set if its underlying host(s) fail. Pivots on the generic RUNS_ON relation, so it works for pod→node, vm→hypervisor, container→host alike. Use this for cross-cutting RCA when several services degrade together.",
135
139
  };
136
- export async function getBlastRadiusHandler(registry, args, _ctx = defaultContext()) {
137
- const agg = await aggregateTopology(registry);
140
+ export async function getBlastRadiusHandler(registry, args, ctx = defaultContext()) {
141
+ const agg = await aggregateTopology(registry, ctx.tenant);
138
142
  const found = resolveResource(args.resource, agg.resources);
139
143
  if ("error" in found) {
140
144
  return {
@@ -0,0 +1,22 @@
1
+ import type { Resource, Edge, TopologySnapshot } from "../types.js";
2
+ /** Labels we treat as canonical-name carriers. First-match wins. */
3
+ export declare const CANONICAL_LABEL_KEYS: readonly ["app.kubernetes.io/name", "app.kubernetes.io/instance", "app", "service", "service.name", "k8s-app"];
4
+ /** Lower-cased label-key lookup; first match in CANONICAL_LABEL_KEYS
5
+ * ordering wins so two providers with different label conventions
6
+ * still converge if they both ship a known label. */
7
+ export declare function canonicalNameFor(r: Resource): string | undefined;
8
+ export interface MergeResult {
9
+ resources: Resource[];
10
+ edges: Edge[];
11
+ /** Map from original (source-scoped) id → merged canonical id. Used
12
+ * to rewrite edges that referenced one of the collapsed nodes. */
13
+ idMap: Map<string, string>;
14
+ }
15
+ /**
16
+ * Merge a set of provider snapshots into one unified graph. The
17
+ * input is the flat union of every provider's resources+edges; the
18
+ * output collapses every group of nodes that share a canonical name
19
+ * (and a compatible kind) into a single node, then rewrites the
20
+ * edge endpoints accordingly.
21
+ */
22
+ export declare function mergeTopologies(snapshots: TopologySnapshot[]): MergeResult;