@thotischner/observability-mcp 1.8.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/dist/analysis/history.d.ts +70 -0
  2. package/dist/analysis/history.js +170 -0
  3. package/dist/analysis/history.test.d.ts +1 -0
  4. package/dist/analysis/history.test.js +141 -0
  5. package/dist/audit/log.d.ts +9 -0
  6. package/dist/audit/log.js +20 -0
  7. package/dist/audit/redaction-bypass.d.ts +67 -0
  8. package/dist/audit/redaction-bypass.js +64 -0
  9. package/dist/audit/redaction-bypass.test.d.ts +1 -0
  10. package/dist/audit/redaction-bypass.test.js +72 -0
  11. package/dist/audit/sinks/types.d.ts +18 -0
  12. package/dist/audit/sinks/types.js +1 -0
  13. package/dist/audit/sinks/webhook.d.ts +45 -0
  14. package/dist/audit/sinks/webhook.js +111 -0
  15. package/dist/audit/sinks/webhook.test.d.ts +1 -0
  16. package/dist/audit/sinks/webhook.test.js +162 -0
  17. package/dist/auth/credentials.d.ts +11 -0
  18. package/dist/auth/credentials.js +27 -0
  19. package/dist/auth/credentials.test.js +21 -1
  20. package/dist/auth/csrf.d.ts +26 -0
  21. package/dist/auth/csrf.js +128 -0
  22. package/dist/auth/csrf.test.d.ts +1 -0
  23. package/dist/auth/csrf.test.js +143 -0
  24. package/dist/auth/local-users.d.ts +6 -0
  25. package/dist/auth/local-users.js +11 -0
  26. package/dist/auth/local-users.test.js +41 -0
  27. package/dist/auth/middleware.d.ts +7 -6
  28. package/dist/auth/oidc/dcr.d.ts +70 -0
  29. package/dist/auth/oidc/dcr.js +160 -0
  30. package/dist/auth/oidc/dcr.test.d.ts +1 -0
  31. package/dist/auth/oidc/dcr.test.js +109 -0
  32. package/dist/auth/oidc/endpoints.js +44 -0
  33. package/dist/auth/oidc/profiles.d.ts +22 -0
  34. package/dist/auth/oidc/profiles.js +95 -0
  35. package/dist/auth/oidc/profiles.test.d.ts +1 -0
  36. package/dist/auth/oidc/profiles.test.js +51 -0
  37. package/dist/auth/oidc/runtime.d.ts +3 -0
  38. package/dist/auth/oidc/runtime.js +16 -3
  39. package/dist/auth/oidc/runtime.test.js +1 -0
  40. package/dist/auth/policy/batch-dry-run.d.ts +56 -0
  41. package/dist/auth/policy/batch-dry-run.js +129 -0
  42. package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
  43. package/dist/auth/policy/batch-dry-run.test.js +140 -0
  44. package/dist/auth/policy/engine.d.ts +20 -4
  45. package/dist/auth/policy/engine.js +16 -2
  46. package/dist/auth/policy/loader.d.ts +11 -1
  47. package/dist/auth/policy/loader.js +37 -0
  48. package/dist/auth/policy/loader.test.d.ts +1 -0
  49. package/dist/auth/policy/loader.test.js +86 -0
  50. package/dist/auth/policy/opa.d.ts +5 -5
  51. package/dist/auth/policy/opa.js +25 -14
  52. package/dist/auth/policy/opa.test.js +48 -0
  53. package/dist/auth/rbac.d.ts +23 -1
  54. package/dist/auth/rbac.js +43 -1
  55. package/dist/auth/rbac.test.js +62 -0
  56. package/dist/cli/index.js +3 -0
  57. package/dist/cli/inspector-config.d.ts +9 -0
  58. package/dist/cli/inspector-config.js +28 -0
  59. package/dist/cli/inspector-config.test.d.ts +1 -0
  60. package/dist/cli/inspector-config.test.js +33 -0
  61. package/dist/cli/lib.d.ts +1 -1
  62. package/dist/cli/lib.js +1 -0
  63. package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
  64. package/dist/conformance/mcp-2025-11-25.test.js +206 -0
  65. package/dist/connectors/interface.d.ts +5 -1
  66. package/dist/connectors/loader.js +6 -4
  67. package/dist/connectors/loader.test.d.ts +1 -0
  68. package/dist/connectors/loader.test.js +78 -0
  69. package/dist/connectors/prometheus.test.js +31 -13
  70. package/dist/connectors/registry.d.ts +13 -0
  71. package/dist/connectors/registry.js +30 -0
  72. package/dist/connectors/registry.test.js +56 -2
  73. package/dist/context.d.ts +32 -0
  74. package/dist/context.js +35 -0
  75. package/dist/context.test.d.ts +1 -0
  76. package/dist/context.test.js +58 -0
  77. package/dist/federation/registry.d.ts +32 -0
  78. package/dist/federation/registry.js +77 -0
  79. package/dist/federation/registry.test.d.ts +1 -0
  80. package/dist/federation/registry.test.js +130 -0
  81. package/dist/federation/upstream.d.ts +60 -0
  82. package/dist/federation/upstream.js +114 -0
  83. package/dist/index.js +1188 -120
  84. package/dist/middleware/ssrfGuard.d.ts +15 -0
  85. package/dist/middleware/ssrfGuard.js +103 -0
  86. package/dist/middleware/ssrfGuard.test.d.ts +1 -0
  87. package/dist/middleware/ssrfGuard.test.js +81 -0
  88. package/dist/observability/otel.d.ts +20 -0
  89. package/dist/observability/otel.js +118 -0
  90. package/dist/observability/otel.test.d.ts +1 -0
  91. package/dist/observability/otel.test.js +56 -0
  92. package/dist/openapi.js +215 -7
  93. package/dist/openapi.test.js +34 -0
  94. package/dist/postmortem/synthesizer.d.ts +83 -0
  95. package/dist/postmortem/synthesizer.js +205 -0
  96. package/dist/postmortem/synthesizer.test.d.ts +1 -0
  97. package/dist/postmortem/synthesizer.test.js +141 -0
  98. package/dist/products/loader.d.ts +31 -3
  99. package/dist/products/loader.js +77 -4
  100. package/dist/products/loader.test.js +90 -1
  101. package/dist/quota/charge.d.ts +28 -0
  102. package/dist/quota/charge.js +30 -0
  103. package/dist/quota/charge.test.d.ts +1 -0
  104. package/dist/quota/charge.test.js +83 -0
  105. package/dist/quota/limiter.d.ts +29 -4
  106. package/dist/quota/limiter.js +64 -8
  107. package/dist/quota/limiter.test.js +86 -0
  108. package/dist/scim/group-role-map.d.ts +4 -0
  109. package/dist/scim/group-role-map.js +33 -0
  110. package/dist/scim/group-role-map.test.d.ts +1 -0
  111. package/dist/scim/group-role-map.test.js +33 -0
  112. package/dist/scim/routes.d.ts +15 -0
  113. package/dist/scim/routes.js +249 -0
  114. package/dist/scim/store.d.ts +37 -0
  115. package/dist/scim/store.js +178 -0
  116. package/dist/scim/store.test.d.ts +1 -0
  117. package/dist/scim/store.test.js +121 -0
  118. package/dist/scim/types.d.ts +73 -0
  119. package/dist/scim/types.js +29 -0
  120. package/dist/sdk/hooks.d.ts +77 -0
  121. package/dist/sdk/hooks.js +72 -0
  122. package/dist/sdk/hooks.test.d.ts +1 -0
  123. package/dist/sdk/hooks.test.js +159 -0
  124. package/dist/sdk/index.d.ts +2 -0
  125. package/dist/sdk/index.js +1 -0
  126. package/dist/sdk/manifest-schema.d.ts +17 -0
  127. package/dist/sdk/manifest-schema.js +21 -0
  128. package/dist/tools/context-seam.test.js +6 -1
  129. package/dist/tools/detect-anomalies.d.ts +1 -1
  130. package/dist/tools/detect-anomalies.js +5 -4
  131. package/dist/tools/generate-postmortem.d.ts +35 -0
  132. package/dist/tools/generate-postmortem.js +191 -0
  133. package/dist/tools/get-anomaly-history.d.ts +35 -0
  134. package/dist/tools/get-anomaly-history.js +126 -0
  135. package/dist/tools/get-service-health.d.ts +1 -1
  136. package/dist/tools/get-service-health.js +4 -3
  137. package/dist/tools/list-services.d.ts +1 -1
  138. package/dist/tools/list-services.js +3 -2
  139. package/dist/tools/list-sources.d.ts +1 -1
  140. package/dist/tools/list-sources.js +6 -2
  141. package/dist/tools/query-logs.d.ts +1 -1
  142. package/dist/tools/query-logs.js +2 -2
  143. package/dist/tools/query-metrics.d.ts +1 -1
  144. package/dist/tools/query-metrics.js +19 -6
  145. package/dist/tools/query-traces.d.ts +47 -0
  146. package/dist/tools/query-traces.js +145 -0
  147. package/dist/tools/query-traces.test.d.ts +1 -0
  148. package/dist/tools/query-traces.test.js +110 -0
  149. package/dist/tools/registry-names.d.ts +35 -0
  150. package/dist/tools/registry-names.js +54 -0
  151. package/dist/tools/registry-names.test.d.ts +1 -0
  152. package/dist/tools/registry-names.test.js +61 -0
  153. package/dist/tools/topology.d.ts +3 -3
  154. package/dist/tools/topology.js +10 -6
  155. package/dist/topology/merge.d.ts +22 -0
  156. package/dist/topology/merge.js +178 -0
  157. package/dist/topology/merge.test.d.ts +1 -0
  158. package/dist/topology/merge.test.js +110 -0
  159. package/dist/transport/sessionStore.d.ts +66 -0
  160. package/dist/transport/sessionStore.js +138 -0
  161. package/dist/transport/sessionStore.test.d.ts +1 -0
  162. package/dist/transport/sessionStore.test.js +118 -0
  163. package/dist/transport/websocket.d.ts +35 -0
  164. package/dist/transport/websocket.js +133 -0
  165. package/dist/transport/websocket.test.d.ts +1 -0
  166. package/dist/transport/websocket.test.js +124 -0
  167. package/dist/types.d.ts +51 -0
  168. package/dist/ui/index.html +1729 -100
  169. package/package.json +13 -3
package/dist/openapi.js CHANGED
@@ -23,6 +23,10 @@ const SOURCE_SCHEMA = {
23
23
  },
24
24
  tls: { type: "object", additionalProperties: true },
25
25
  signalType: { type: "string", enum: ["metrics", "logs", "traces"] },
26
+ tenant: {
27
+ type: "string",
28
+ description: "Tenant this source belongs to. Omitted = global (visible to every tenant). Tagged sources are visible only inside their named tenant; cross-tenant probes return 404 with no existence leak.",
29
+ },
26
30
  },
27
31
  additionalProperties: true,
28
32
  };
@@ -60,21 +64,27 @@ export function buildOpenApiSpec(version) {
60
64
  "/api/sources": {
61
65
  get: {
62
66
  tags: ["sources"],
63
- summary: "List configured sources with live health.",
67
+ summary: "List configured sources with live health (tenant-scoped).",
68
+ description: "Non-admin callers see only their own tenant's sources + globals (untagged). Admins (users:delete) see every source; pass ?tenant=X for an admin drill-down to that tenant + globals. Anonymous mode bypasses scoping (single-tenant default).",
69
+ parameters: [
70
+ { name: "tenant", in: "query", schema: { type: "string" }, description: "Admin-only tenant drill-down (silently ignored for non-admins, who are scoped to their own tenant)." },
71
+ ],
64
72
  responses: {
65
73
  "200": {
66
- description: "Sources with status, latency, signal type.",
74
+ description: "Sources with status, latency, signal type. The `tenant` field is present when the source is tagged.",
67
75
  content: { "application/json": { schema: { type: "array", items: SOURCE_SCHEMA } } },
68
76
  },
69
77
  },
70
78
  },
71
79
  post: {
72
80
  tags: ["sources"],
73
- summary: "Add a new source.",
81
+ summary: "Add a new source (tenant-aware).",
82
+ description: "Body may include `tenant` to tag the source. Non-admins may only create within their own tenant; setting body.tenant to another value returns 403. Admins may leave tenant unset (global) or set any value.",
74
83
  requestBody: { required: true, content: { "application/json": { schema: SOURCE_SCHEMA } } },
75
84
  responses: {
76
85
  "201": { description: "Source created." },
77
86
  "400": { description: "Validation error." },
87
+ "403": { description: "Non-admin attempting to create in another tenant." },
78
88
  "409": { description: "Source with that name already exists." },
79
89
  },
80
90
  },
@@ -107,14 +117,23 @@ export function buildOpenApiSpec(version) {
107
117
  parameters: [{ name: "name", in: "path", required: true, schema: { type: "string" } }],
108
118
  put: {
109
119
  tags: ["sources"],
110
- summary: "Replace an existing source.",
120
+ summary: "Replace an existing source (tenant-aware).",
121
+ description: "Non-admin probes of a cross-tenant source return 404 (no existence leak — same posture as /api/products). Non-admins attempting to reassign body.tenant return 403. Admins may move sources between tenants.",
111
122
  requestBody: { required: true, content: { "application/json": { schema: SOURCE_SCHEMA } } },
112
- responses: { "200": { description: "Updated." }, "404": { description: "Not found." } },
123
+ responses: {
124
+ "200": { description: "Updated." },
125
+ "403": { description: "Non-admin attempting tenant reassignment." },
126
+ "404": { description: "Not found (or hidden by tenant scope)." },
127
+ },
113
128
  },
114
129
  delete: {
115
130
  tags: ["sources"],
116
131
  summary: "Remove a source.",
117
- responses: { "204": { description: "Removed." }, "404": { description: "Not found." } },
132
+ description: "Cross-tenant deletes return 404 (no existence leak).",
133
+ responses: {
134
+ "204": { description: "Removed." },
135
+ "404": { description: "Not found (or hidden by tenant scope)." },
136
+ },
118
137
  },
119
138
  },
120
139
  "/api/source-types": {
@@ -129,6 +148,35 @@ export function buildOpenApiSpec(version) {
129
148
  },
130
149
  },
131
150
  },
151
+ "/api/tools/registry": {
152
+ get: {
153
+ tags: ["products"],
154
+ summary: "MCP tool catalogue — name + category + one-line summary, used by the Products picker.",
155
+ description: "Static metadata derived from REGISTERED_TOOLS. The Products modal pulls this to populate a multi-select picker grouped by category (discovery / query / diagnose / topology); the server-side typo guard (PR #343) stays as defence-in-depth.",
156
+ responses: {
157
+ "200": {
158
+ description: "Tool registry.",
159
+ content: { "application/json": { schema: {
160
+ type: "object",
161
+ properties: {
162
+ tools: {
163
+ type: "array",
164
+ items: {
165
+ type: "object",
166
+ required: ["name", "category", "summary"],
167
+ properties: {
168
+ name: { type: "string" },
169
+ category: { type: "string", enum: ["discovery", "query", "diagnose", "topology"] },
170
+ summary: { type: "string" },
171
+ },
172
+ },
173
+ },
174
+ },
175
+ } } },
176
+ },
177
+ },
178
+ },
179
+ },
132
180
  "/api/services": {
133
181
  get: {
134
182
  tags: ["services"],
@@ -478,11 +526,12 @@ export function buildOpenApiSpec(version) {
478
526
  "/api/policy": {
479
527
  get: {
480
528
  tags: ["auth"],
481
- summary: "Read-only view of the active RBAC policy (admin-only). Dry-run probe with ?resource=&action=&roles=.",
529
+ summary: "Read-only view of the active RBAC policy (admin-only). Dry-run probe with ?resource=&action=&roles=[&tenant=].",
482
530
  parameters: [
483
531
  { name: "roles", in: "query", required: false, schema: { type: "string" }, description: "Comma-separated role names to probe. Defaults to none (treated as anonymous → always denied)." },
484
532
  { name: "resource", in: "query", required: false, schema: { type: "string" }, description: "Resource to probe. Pair with `action` to enter dry-run mode." },
485
533
  { name: "action", in: "query", required: false, schema: { type: "string" }, description: "Action to probe. Pair with `resource` to enter dry-run mode." },
534
+ { name: "tenant", in: "query", required: false, schema: { type: "string" }, description: "Tenant to probe under (dry-run only). Defaults to the caller's session tenant; admins may override to probe verdicts for any tenant — exactly how to debug tenant-conditional Rego rules under the OPA engine." },
486
535
  ],
487
536
  responses: {
488
537
  "200": {
@@ -493,6 +542,7 @@ export function buildOpenApiSpec(version) {
493
542
  type: "object",
494
543
  properties: {
495
544
  engine: { type: "string", description: "Identifier of the active engine: 'builtin', 'file:<path>', 'opa:<url>'." },
545
+ tenantAware: { type: "boolean", description: "True when the active engine honours session.tenant on .evaluate() — i.e. OPA. Built-in / file-loaded engines ignore tenant ctx (false)." },
496
546
  policy: { type: "object", additionalProperties: true },
497
547
  roles: { type: "array", items: { type: "string" } },
498
548
  note: { type: "string" },
@@ -502,6 +552,7 @@ export function buildOpenApiSpec(version) {
502
552
  roles: { type: "array", items: { type: "string" } },
503
553
  resource: { type: "string" },
504
554
  action: { type: "string" },
555
+ tenant: { type: "string", description: "Tenant the probe ran under (echoed from ?tenant= or the caller's session)." },
505
556
  allowed: { type: "boolean" },
506
557
  reason: { type: "string" },
507
558
  },
@@ -515,6 +566,97 @@ export function buildOpenApiSpec(version) {
515
566
  },
516
567
  },
517
568
  },
569
+ "/api/policy/roles/{name}": {
570
+ put: {
571
+ tags: ["auth"],
572
+ summary: "Upsert a role in the file-backed RBAC policy (admin-only, file engine only).",
573
+ description: "Writes through OMCP_RBAC_POLICY_FILE atomically. 409 if the active engine isn't `file:…` or the env var is unset. Permissions are validated against VALID_RESOURCES + VALID_ACTIONS; unknown values return 422 with code OMCP_POLICY_UNKNOWN_RESOURCE / OMCP_POLICY_UNKNOWN_ACTION. On success the in-memory PolicyEngine is hot-swapped — existing gates pick up the new policy without a server restart.",
574
+ parameters: [
575
+ { name: "name", in: "path", required: true, schema: { type: "string" } },
576
+ ],
577
+ requestBody: {
578
+ required: true,
579
+ content: { "application/json": { schema: {
580
+ type: "object",
581
+ required: ["permissions"],
582
+ properties: { permissions: { type: "array", items: { type: "object", properties: {
583
+ resource: { type: "string" },
584
+ action: { type: "string" },
585
+ } } } },
586
+ } } },
587
+ },
588
+ responses: {
589
+ "200": { description: "Role saved + hot-swapped." },
590
+ "400": { description: "Body shape invalid OR role name pattern rejected." },
591
+ "403": { description: "Missing users:delete permission (admin-only)." },
592
+ "409": { description: "Active engine isn't `file:…`, or OMCP_RBAC_POLICY_FILE unset (codes: OMCP_POLICY_ENGINE_NOT_FILE / OMCP_POLICY_FILE_NOT_SET)." },
593
+ "422": { description: "Unknown resource or action (codes: OMCP_POLICY_UNKNOWN_RESOURCE / OMCP_POLICY_UNKNOWN_ACTION)." },
594
+ },
595
+ },
596
+ },
597
+ "/api/users/{username}/roles": {
598
+ put: {
599
+ tags: ["auth"],
600
+ summary: "Update a local user's role assignments (admin-only, file-backed).",
601
+ description: "Writes through OMCP_USERS_FILE. Roles are validated against the active policy engine's role catalogue; unknown role names return 422 with OMCP_USER_UNKNOWN_ROLE. The in-memory user store is refreshed atomically after the file write so the next login picks up the new roles without a server restart.",
602
+ parameters: [
603
+ { name: "username", in: "path", required: true, schema: { type: "string" } },
604
+ ],
605
+ requestBody: {
606
+ required: true,
607
+ content: { "application/json": { schema: {
608
+ type: "object",
609
+ required: ["roles"],
610
+ properties: { roles: { type: "array", items: { type: "string" } } },
611
+ } } },
612
+ },
613
+ responses: {
614
+ "200": { description: "Roles updated." },
615
+ "400": { description: "Body must be { roles: string[] }." },
616
+ "403": { description: "Missing users:delete permission (admin-only)." },
617
+ "404": { description: "User not found OR users file unreadable." },
618
+ "409": { description: "OMCP_USERS_FILE is not configured — basic-mode user roles can't be edited via the API." },
619
+ "422": { description: "tools[] references unknown role names. Body includes `unknown` + `available`; error code OMCP_USER_UNKNOWN_ROLE." },
620
+ },
621
+ },
622
+ },
623
+ "/api/subjects": {
624
+ get: {
625
+ tags: ["auth"],
626
+ summary: "Aggregated subjects view — local users + API-key names + OIDC group mappings (admin-only).",
627
+ description: "Read-only catalogue of the principals an OMCP deployment knows about. Three independent sources: OMCP_USERS_FILE (users), OMCP_API_KEYS (apiKeys), OMCP_OIDC_ROLE_MAP (oidcGroups). Tokens + password hashes are never returned — only metadata.",
628
+ responses: {
629
+ "200": {
630
+ description: "Subjects payload.",
631
+ content: { "application/json": { schema: {
632
+ type: "object",
633
+ properties: {
634
+ users: { type: "array", items: { type: "object", properties: {
635
+ username: { type: "string" }, name: { type: "string" },
636
+ roles: { type: "array", items: { type: "string" } },
637
+ tenant: { type: "string" },
638
+ } } },
639
+ apiKeys: { type: "array", items: { type: "object", properties: {
640
+ name: { type: "string" }, tenant: { type: "string" },
641
+ productId: { type: "string" },
642
+ bypassRedaction: { type: "boolean" },
643
+ allowedSources: { type: "array", items: { type: "string" } },
644
+ } } },
645
+ oidcGroups: { type: "array", items: { type: "object", properties: {
646
+ claim: { type: "string" }, role: { type: "string" },
647
+ } } },
648
+ sources: { type: "object", properties: {
649
+ users: { type: ["string", "null"] },
650
+ apiKeys: { type: ["string", "null"] },
651
+ oidcGroups: { type: ["string", "null"] },
652
+ } },
653
+ },
654
+ } } },
655
+ },
656
+ "403": { description: "Missing users:delete permission (admin-only)." },
657
+ },
658
+ },
659
+ },
518
660
  "/api/products": {
519
661
  get: {
520
662
  tags: ["products"],
@@ -542,6 +684,25 @@ export function buildOpenApiSpec(version) {
542
684
  "403": { description: "Missing products:read permission." },
543
685
  },
544
686
  },
687
+ post: {
688
+ tags: ["products"],
689
+ summary: "Create a new product (strict create — 409 on conflict; PUT for upsert).",
690
+ description: "Strict create-only variant of PUT. Same tenancy + typo-guard posture. Returns 409 when a product with body.id already exists. Body must include id + name; tools[] entries must reference registered MCP tool names (typo → 422).",
691
+ requestBody: {
692
+ required: true,
693
+ content: { "application/json": { schema: { type: "object", additionalProperties: true } } },
694
+ },
695
+ responses: {
696
+ "201": { description: "Created.", content: { "application/json": { schema: { type: "object", properties: {
697
+ product: { type: "object", additionalProperties: true },
698
+ persisted: { type: "boolean" },
699
+ } } } } },
700
+ "400": { description: "Body invalid (missing id / shape rejected by validateProduct)." },
701
+ "403": { description: "Missing products:write permission, or non-admin attempting to create in another tenant." },
702
+ "409": { description: "Product with that id already exists — use PUT to update." },
703
+ "422": { description: "tools[] references unknown tool names. Body includes `unknown` + `available`; error code OMCP_PRODUCT_UNKNOWN_TOOL." },
704
+ },
705
+ },
545
706
  },
546
707
  "/api/products/{id}": {
547
708
  get: {
@@ -577,6 +738,7 @@ export function buildOpenApiSpec(version) {
577
738
  "400": { description: "Body shape invalid (validateProduct rejected — typo, unknown key, wrong type, ...)." },
578
739
  "403": { description: "Missing products:write permission, or non-admin attempting to write into another tenant." },
579
740
  "404": { description: "Existing product belongs to a different tenant (non-admin)." },
741
+ "422": { description: "tools[] references unknown tool names. Body includes `unknown` + `available`; error code OMCP_PRODUCT_UNKNOWN_TOOL." },
580
742
  },
581
743
  },
582
744
  delete: {
@@ -592,6 +754,52 @@ export function buildOpenApiSpec(version) {
592
754
  },
593
755
  },
594
756
  },
757
+ "/api/products/{id}/preview": {
758
+ get: {
759
+ tags: ["products"],
760
+ summary: "Agent preview — the filtered tools/list a credential bound to this product would receive.",
761
+ description: "Same tenancy + staging filter as GET /api/products/{id}. Returns the product's branding/identity metadata + the registered MCP tools after applying its tools allow-list. The UI uses this for the per-card 'Preview as agent' affordance.",
762
+ parameters: [
763
+ { name: "id", in: "path", required: true, schema: { type: "string" } },
764
+ ],
765
+ responses: {
766
+ "200": {
767
+ description: "Preview payload.",
768
+ content: { "application/json": { schema: {
769
+ type: "object",
770
+ required: ["product", "unrestricted", "tools"],
771
+ properties: {
772
+ product: {
773
+ type: "object",
774
+ properties: {
775
+ id: { type: "string" },
776
+ name: { type: "string" },
777
+ version: { type: "string" },
778
+ branding: { type: "object", additionalProperties: true },
779
+ tenant: { type: "string" },
780
+ status: { type: "string" },
781
+ },
782
+ },
783
+ unrestricted: { type: "boolean", description: "True when the product has no tools allow-list — the bound agent sees every registered tool." },
784
+ tools: {
785
+ type: "array",
786
+ items: {
787
+ type: "object",
788
+ properties: {
789
+ name: { type: "string" },
790
+ category: { type: "string", enum: ["discovery", "query", "diagnose", "topology"] },
791
+ summary: { type: "string" },
792
+ },
793
+ },
794
+ },
795
+ },
796
+ } } },
797
+ },
798
+ "404": { description: "Not found (or hidden by tenant / staging scope)." },
799
+ "403": { description: "Missing products:read permission." },
800
+ },
801
+ },
802
+ },
595
803
  "/api/catalog": {
596
804
  get: {
597
805
  tags: ["catalog"],
@@ -13,6 +13,7 @@ test("openapi — every user-visible /api path is documented", () => {
13
13
  "/api/sources/{name}",
14
14
  "/api/sources/{name}/metrics",
15
15
  "/api/source-types",
16
+ "/api/tools/registry",
16
17
  "/api/settings",
17
18
  "/api/health-thresholds",
18
19
  "/api/me",
@@ -24,9 +25,13 @@ test("openapi — every user-visible /api path is documented", () => {
24
25
  "/api/audit",
25
26
  "/api/usage",
26
27
  "/api/policy",
28
+ "/api/subjects",
29
+ "/api/users/{username}/roles",
30
+ "/api/policy/roles/{name}",
27
31
  "/api/catalog",
28
32
  "/api/products",
29
33
  "/api/products/{id}",
34
+ "/api/products/{id}/preview",
30
35
  "/api/info",
31
36
  "/api/openapi.json",
32
37
  ];
@@ -62,3 +67,32 @@ test("openapi — info.version is the version string the caller passed in", () =
62
67
  const spec = buildOpenApiSpec("9.9.9-test");
63
68
  assert.equal(spec.info?.version, "9.9.9-test");
64
69
  });
70
+ test("openapi — SOURCE_SCHEMA exposes the tenant field (tenant-aware sources contract)", () => {
71
+ // Source entries gained a `tenant` field when per-tenant connector
72
+ // scoping shipped. The spec is the contract operators write
73
+ // generated clients against — drift = broken downstream clients.
74
+ const spec = buildOpenApiSpec("test-1.0.0");
75
+ // SOURCE_SCHEMA is inlined into both `items` of GET /api/sources and
76
+ // the requestBody of POST/PUT — pick the GET response, the canonical
77
+ // read path.
78
+ const sources = spec.paths?.["/api/sources"]?.get;
79
+ const items = sources.responses["200"].content["application/json"].schema.items;
80
+ assert.ok(items.properties.tenant, "SOURCE_SCHEMA must document `tenant` (added when per-tenant scoping shipped)");
81
+ });
82
+ test("openapi — /api/sources GET documents the admin `?tenant=` drill-down query param", () => {
83
+ const spec = buildOpenApiSpec("test-1.0.0");
84
+ const get = spec.paths?.["/api/sources"]?.get;
85
+ const params = get.parameters || [];
86
+ const tenantParam = params.find((p) => p.name === "tenant" && p.in === "query");
87
+ assert.ok(tenantParam, "GET /api/sources must document the admin `?tenant=` drill-down param");
88
+ });
89
+ test("openapi — /api/policy GET documents the `?tenant=` probe param + `tenantAware` snapshot field", () => {
90
+ const spec = buildOpenApiSpec("test-1.0.0");
91
+ const get = spec.paths?.["/api/policy"]?.get;
92
+ const params = get.parameters || [];
93
+ assert.ok(params.some((p) => p.name === "tenant" && p.in === "query"), "GET /api/policy must document the `?tenant=` probe param");
94
+ const schema = get.responses["200"].content["application/json"].schema;
95
+ assert.ok(schema.properties.tenantAware, "snapshot must document the `tenantAware` field");
96
+ const dryRun = schema.properties.dryRun;
97
+ assert.ok(dryRun?.properties?.tenant, "dryRun must echo the `tenant` field so operators see which tenant the verdict ran under");
98
+ });
@@ -0,0 +1,83 @@
1
+ export interface AnomalySample {
2
+ ts: string;
3
+ service: string;
4
+ score: number;
5
+ method: string;
6
+ severity: string;
7
+ signal?: string;
8
+ }
9
+ export interface BlastRadiusNode {
10
+ id: string;
11
+ kind: string;
12
+ name: string;
13
+ /** Whether this node is the suspected root cause (the input service). */
14
+ root?: boolean;
15
+ }
16
+ export interface TraceSummary {
17
+ traceId: string;
18
+ rootName: string;
19
+ rootService: string;
20
+ durationMs: number;
21
+ hasError: boolean;
22
+ }
23
+ export interface PostmortemInput {
24
+ /** Suspected root-cause service (the operator's first guess). */
25
+ service: string;
26
+ /** Rolling window the incident took place in, e.g. "2h", "6h". */
27
+ window: string;
28
+ /** Tenant the incident occurred in. */
29
+ tenant: string;
30
+ /** RFC-3339 start + end of the incident window for human display. */
31
+ fromIso: string;
32
+ toIso: string;
33
+ /** Live anomaly samples within the window. */
34
+ anomalies: AnomalySample[];
35
+ /** Blast-radius graph at peak. */
36
+ blastRadius: {
37
+ nodes: BlastRadiusNode[];
38
+ edges: Array<{
39
+ from: string;
40
+ to: string;
41
+ relation: string;
42
+ }>;
43
+ };
44
+ /** Trace summaries (top by duration). */
45
+ traces: TraceSummary[];
46
+ /** Optional log-error summary lines, e.g. ["payment-service: 412 5xx in window"]. */
47
+ logHighlights?: string[];
48
+ }
49
+ export interface PostmortemReport {
50
+ service: string;
51
+ window: string;
52
+ fromIso: string;
53
+ toIso: string;
54
+ /** Compact synopsis the UI puts at the top of the report. */
55
+ synopsis: string;
56
+ /** Markdown body of the full report. */
57
+ markdown: string;
58
+ /** Structured form for callers that want to render their own UI. */
59
+ sections: {
60
+ timeline: Array<{
61
+ ts: string;
62
+ service: string;
63
+ score: number;
64
+ severity: string;
65
+ method: string;
66
+ }>;
67
+ blastRadius: {
68
+ nodes: BlastRadiusNode[];
69
+ edgeCount: number;
70
+ };
71
+ topTraces: TraceSummary[];
72
+ contributingSignals: Array<{
73
+ signal: string;
74
+ count: number;
75
+ meanScore: number;
76
+ }>;
77
+ followUps: string[];
78
+ logHighlights: string[];
79
+ };
80
+ }
81
+ /** Synthesise one report from already-fetched primitives. Pure
82
+ * compute — no I/O. */
83
+ export declare function synthesizePostmortem(input: PostmortemInput): PostmortemReport;
@@ -0,0 +1,205 @@
1
+ // Auto-post-mortem synthesizer — Phase F19.
2
+ //
3
+ // Stitches together the existing observability primitives — anomaly
4
+ // history (F15), blast-radius (F13/topology), trace summaries (F13),
5
+ // log-derived error patterns (existing query_logs) — into a single
6
+ // markdown report a human (or LLM) can read in one shot.
7
+ //
8
+ // The synthesizer is pure-ish: it accepts the upstream queries as
9
+ // injected functions so the tool layer can compose them without the
10
+ // synthesizer depending on the entire ConnectorRegistry API. Tests
11
+ // inject fake data and don't need a live demo stack.
12
+ /** Synthesise one report from already-fetched primitives. Pure
13
+ * compute — no I/O. */
14
+ export function synthesizePostmortem(input) {
15
+ const timeline = [...input.anomalies]
16
+ .sort((a, b) => a.ts.localeCompare(b.ts))
17
+ .map((a) => ({ ts: a.ts, service: a.service, score: a.score, severity: a.severity, method: a.method }));
18
+ const contributingSignals = aggregateBySignal(input.anomalies);
19
+ const peakScore = input.anomalies.reduce((m, a) => Math.max(m, a.score), 0);
20
+ const errorTraces = input.traces.filter((t) => t.hasError).length;
21
+ const peakNode = input.blastRadius.nodes.find((n) => n.root) ?? input.blastRadius.nodes[0];
22
+ const blastSize = input.blastRadius.nodes.length;
23
+ const followUps = inferFollowUps(input, { peakScore, errorTraces, blastSize });
24
+ const synopsis = synopsisFor(input, peakScore, errorTraces, blastSize);
25
+ const markdown = renderMarkdown({
26
+ input,
27
+ timeline,
28
+ contributingSignals,
29
+ peakNode,
30
+ peakScore,
31
+ errorTraces,
32
+ blastSize,
33
+ followUps,
34
+ synopsis,
35
+ });
36
+ return {
37
+ service: input.service,
38
+ window: input.window,
39
+ fromIso: input.fromIso,
40
+ toIso: input.toIso,
41
+ synopsis,
42
+ markdown,
43
+ sections: {
44
+ timeline,
45
+ blastRadius: { nodes: input.blastRadius.nodes, edgeCount: input.blastRadius.edges.length },
46
+ topTraces: input.traces.slice(0, 10),
47
+ contributingSignals,
48
+ followUps,
49
+ logHighlights: input.logHighlights ?? [],
50
+ },
51
+ };
52
+ }
53
+ function aggregateBySignal(anomalies) {
54
+ const groups = new Map();
55
+ for (const a of anomalies) {
56
+ const sig = a.signal ?? a.method;
57
+ const prev = groups.get(sig);
58
+ if (prev)
59
+ prev.push(a.score);
60
+ else
61
+ groups.set(sig, [a.score]);
62
+ }
63
+ return [...groups.entries()]
64
+ .map(([signal, scores]) => ({
65
+ signal,
66
+ count: scores.length,
67
+ meanScore: Math.round((scores.reduce((s, x) => s + x, 0) / scores.length) * 100) / 100,
68
+ }))
69
+ .sort((a, b) => b.meanScore - a.meanScore);
70
+ }
71
+ function inferFollowUps(input, ctx) {
72
+ const out = [];
73
+ if (input.anomalies.length === 0) {
74
+ out.push("No anomaly history found for this service in the window — confirm OMCP_ANOMALY_HISTORY_REMOTE_WRITE is wired and Prometheus is scraping the same TSDB.");
75
+ return out;
76
+ }
77
+ if (ctx.peakScore >= 0.9) {
78
+ out.push(`Peak anomaly score ${ctx.peakScore} is critical — review the detector's threshold for service '${input.service}' and consider whether the chosen method (${dominantMethod(input.anomalies)}) suits this signal's distribution.`);
79
+ }
80
+ if (ctx.errorTraces > 0) {
81
+ out.push(`${ctx.errorTraces} trace(s) carried error spans during the window — drill into the slowest via \`query_traces(service="${input.service}", errorsOnly=true)\`.`);
82
+ }
83
+ if (ctx.blastSize > 5) {
84
+ out.push(`Blast radius spans ${ctx.blastSize} nodes — verify that the dependency edges are still accurate (a stale topology snapshot can blow up the radius and miss the real cause).`);
85
+ }
86
+ if ((input.logHighlights ?? []).length > 0) {
87
+ out.push("Log highlights above point at concrete error patterns — promote the recurring ones to an alert or SLO so the next regression catches itself.");
88
+ }
89
+ if (out.length === 0) {
90
+ out.push("All signals look stable for this window — consider closing the incident as a transient anomaly or expanding the time window.");
91
+ }
92
+ return out;
93
+ }
94
+ function dominantMethod(anomalies) {
95
+ const c = new Map();
96
+ for (const a of anomalies)
97
+ c.set(a.method, (c.get(a.method) ?? 0) + 1);
98
+ return [...c.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? "unknown";
99
+ }
100
+ function synopsisFor(input, peakScore, errorTraces, blastSize) {
101
+ const anomalyCount = input.anomalies.length;
102
+ if (anomalyCount === 0) {
103
+ return `No anomalies recorded for service '${input.service}' between ${input.fromIso} and ${input.toIso}. Either the window was clean, or the history sink wasn't writing at the time.`;
104
+ }
105
+ return [
106
+ `Service '${input.service}' produced ${anomalyCount} anomaly sample(s) between ${input.fromIso} and ${input.toIso}, peaking at ${peakScore}.`,
107
+ `Blast radius at peak covered ${blastSize} node(s); ${errorTraces} trace(s) carried error spans.`,
108
+ ].join(" ");
109
+ }
110
+ function renderMarkdown(ctx) {
111
+ const { input, timeline, contributingSignals, peakNode, peakScore, errorTraces, followUps, synopsis } = ctx;
112
+ const lines = [];
113
+ lines.push(`# Post-mortem — ${input.service}`);
114
+ lines.push("");
115
+ lines.push(`> **Window:** \`${input.fromIso}\` → \`${input.toIso}\` (\`${input.window}\`) `);
116
+ lines.push(`> **Tenant:** \`${input.tenant}\` `);
117
+ lines.push(`> **Generated by:** observability-mcp \`generate_postmortem\``);
118
+ lines.push("");
119
+ lines.push("## Synopsis");
120
+ lines.push("");
121
+ lines.push(synopsis);
122
+ lines.push("");
123
+ lines.push("## Anomaly timeline");
124
+ lines.push("");
125
+ if (timeline.length === 0) {
126
+ lines.push("_No anomaly samples in this window._");
127
+ }
128
+ else {
129
+ lines.push("| ts | service | score | severity | method |");
130
+ lines.push("|---|---|---|---|---|");
131
+ for (const t of timeline.slice(0, 20)) {
132
+ lines.push(`| \`${t.ts}\` | \`${t.service}\` | ${t.score} | ${t.severity} | ${t.method} |`);
133
+ }
134
+ if (timeline.length > 20)
135
+ lines.push(`| … | _${timeline.length - 20} more rows_ | | | |`);
136
+ }
137
+ lines.push("");
138
+ lines.push("## Blast radius at peak");
139
+ lines.push("");
140
+ if (peakNode) {
141
+ lines.push(`Root node: **\`${peakNode.name}\`** (\`${peakNode.kind}\`).`);
142
+ }
143
+ else {
144
+ lines.push("_Topology snapshot empty._");
145
+ }
146
+ lines.push("");
147
+ if (input.blastRadius.nodes.length > 0) {
148
+ lines.push("| node | kind |");
149
+ lines.push("|---|---|");
150
+ for (const n of input.blastRadius.nodes.slice(0, 30)) {
151
+ lines.push(`| \`${n.name}\`${n.root ? " *(root)*" : ""} | \`${n.kind}\` |`);
152
+ }
153
+ }
154
+ lines.push("");
155
+ lines.push(`Edges in radius: **${input.blastRadius.edges.length}**.`);
156
+ lines.push("");
157
+ lines.push("## Contributing signals (ranked)");
158
+ lines.push("");
159
+ if (contributingSignals.length === 0) {
160
+ lines.push("_No anomaly samples to rank._");
161
+ }
162
+ else {
163
+ lines.push("| signal | samples | mean score |");
164
+ lines.push("|---|---|---|");
165
+ for (const s of contributingSignals.slice(0, 10)) {
166
+ lines.push(`| \`${s.signal}\` | ${s.count} | ${s.meanScore} |`);
167
+ }
168
+ }
169
+ lines.push("");
170
+ lines.push("## Related traces");
171
+ lines.push("");
172
+ if (input.traces.length === 0) {
173
+ lines.push("_No traces returned for the window. Configure a Tempo / Jaeger source if traces are expected._");
174
+ }
175
+ else {
176
+ lines.push("| trace | service | duration ms | error |");
177
+ lines.push("|---|---|---|---|");
178
+ for (const t of input.traces.slice(0, 10)) {
179
+ lines.push(`| \`${t.traceId}\` | \`${t.rootService}\` | ${t.durationMs} | ${t.hasError ? "yes" : "no"} |`);
180
+ }
181
+ if (errorTraces > 0)
182
+ lines.push(`\n_${errorTraces} of the returned traces carried error spans._`);
183
+ }
184
+ lines.push("");
185
+ if ((input.logHighlights ?? []).length > 0) {
186
+ lines.push("## Log highlights");
187
+ lines.push("");
188
+ for (const l of input.logHighlights)
189
+ lines.push(`- ${l}`);
190
+ lines.push("");
191
+ }
192
+ lines.push("## Suggested follow-ups");
193
+ lines.push("");
194
+ for (const f of followUps)
195
+ lines.push(`- ${f}`);
196
+ lines.push("");
197
+ lines.push("---");
198
+ lines.push("");
199
+ lines.push(`*Generated by observability-mcp \`generate_postmortem\` — see \`docs/postmortems.md\` for the prompt sources.*`);
200
+ lines.push("");
201
+ // Bound the chunk to keep memory predictable; the rendered report
202
+ // is normally a few KB but a pathological 10k-sample timeline
203
+ // could approach MB without the slice() caps above.
204
+ return lines.join("\n");
205
+ }