@thotischner/observability-mcp 1.8.1 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/s3.d.ts +61 -0
- package/dist/audit/sinks/s3.js +179 -0
- package/dist/audit/sinks/s3.test.d.ts +1 -0
- package/dist/audit/sinks/s3.test.js +175 -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 +144 -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.d.ts +8 -0
- package/dist/connectors/loader.js +55 -4
- package/dist/connectors/loader.test.d.ts +1 -0
- package/dist/connectors/loader.test.js +78 -0
- package/dist/connectors/manifest-hooks.test.d.ts +1 -0
- package/dist/connectors/manifest-hooks.test.js +206 -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 +54 -0
- package/dist/federation/registry.js +122 -0
- package/dist/federation/registry.test.d.ts +1 -0
- package/dist/federation/registry.test.js +206 -0
- package/dist/federation/upstream.d.ts +86 -0
- package/dist/federation/upstream.js +162 -0
- package/dist/federation/upstream.test.d.ts +1 -0
- package/dist/federation/upstream.test.js +118 -0
- package/dist/index.js +1435 -126
- package/dist/metrics/self.d.ts +1 -0
- package/dist/metrics/self.js +8 -0
- 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/policy/redact.js +1 -1
- package/dist/postmortem/store.d.ts +34 -0
- package/dist/postmortem/store.js +113 -0
- package/dist/postmortem/store.test.d.ts +1 -0
- package/dist/postmortem/store.test.js +118 -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/compliance.test.d.ts +1 -0
- package/dist/scim/compliance.test.js +169 -0
- package/dist/scim/factory.test.d.ts +1 -0
- package/dist/scim/factory.test.js +54 -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/patch-ops.test.d.ts +1 -0
- package/dist/scim/patch-ops.test.js +100 -0
- package/dist/scim/redis-store.d.ts +38 -0
- package/dist/scim/redis-store.js +178 -0
- package/dist/scim/redis-store.test.d.ts +1 -0
- package/dist/scim/redis-store.test.js +138 -0
- package/dist/scim/routes.d.ts +40 -0
- package/dist/scim/routes.js +395 -0
- package/dist/scim/store.d.ts +76 -0
- package/dist/scim/store.js +196 -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/hook-wrappers.d.ts +39 -0
- package/dist/sdk/hook-wrappers.js +113 -0
- package/dist/sdk/hook-wrappers.test.d.ts +1 -0
- package/dist/sdk/hook-wrappers.test.js +204 -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 +15 -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 +12 -1
- package/dist/tools/detect-anomalies.js +26 -5
- 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 +33 -11
- package/dist/tools/topology.test.js +45 -0
- 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/transportSessionMap.d.ts +70 -0
- package/dist/transport/transportSessionMap.js +128 -0
- package/dist/transport/transportSessionMap.test.d.ts +1 -0
- package/dist/transport/transportSessionMap.test.js +111 -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 +2529 -145
- package/package.json +13 -3
package/dist/auth/policy/opa.js
CHANGED
|
@@ -44,10 +44,13 @@ export class OpaPolicyEngine {
|
|
|
44
44
|
this.cfg = { timeoutMs: 1500, ...cfg };
|
|
45
45
|
this.fetcher = cfg.fetcher ?? ((u, i) => fetch(u, i));
|
|
46
46
|
}
|
|
47
|
-
cacheKey(roles, resource, action) {
|
|
47
|
+
cacheKey(roles, resource, action, tenant) {
|
|
48
48
|
// NUL-delimited so role names containing "," / "|" can't alias
|
|
49
49
|
// across role sets ({"a,b"} would otherwise collide with {"a","b"}).
|
|
50
|
-
|
|
50
|
+
// Tenant is part of the key so cross-tenant decisions don't share
|
|
51
|
+
// cache slots — required once we thread tenant into the OPA input.
|
|
52
|
+
const tk = tenant || "";
|
|
53
|
+
return roles.slice().sort().join("\x00") + "\x01" + resource + "\x01" + action + "\x01" + tk;
|
|
51
54
|
}
|
|
52
55
|
now() {
|
|
53
56
|
return Date.now();
|
|
@@ -74,14 +77,15 @@ export class OpaPolicyEngine {
|
|
|
74
77
|
clearTimeout(timer);
|
|
75
78
|
}
|
|
76
79
|
}
|
|
77
|
-
evaluate(roles, resource, action) {
|
|
80
|
+
evaluate(roles, resource, action, ctx) {
|
|
78
81
|
const rs = roles && roles.length > 0 ? roles : [];
|
|
82
|
+
const tenant = ctx?.tenant;
|
|
79
83
|
// The PolicyEngine.evaluate contract is sync to keep the hot
|
|
80
84
|
// gate path off the await stack, so we serve from the cache
|
|
81
85
|
// synchronously and warm the cache lazily on miss. Misses fall
|
|
82
86
|
// back to a conservative deny + the cache will be populated for
|
|
83
87
|
// next time.
|
|
84
|
-
const key = this.cacheKey(rs, resource, action);
|
|
88
|
+
const key = this.cacheKey(rs, resource, action, tenant);
|
|
85
89
|
const cached = this.cache.get(key);
|
|
86
90
|
if (cached && this.now() - cached.at < this.cacheTtlMs)
|
|
87
91
|
return cached.result;
|
|
@@ -89,16 +93,22 @@ export class OpaPolicyEngine {
|
|
|
89
93
|
// first miss (deny while warming) but the next call within the
|
|
90
94
|
// TTL returns the real verdict. For sync-required contracts we
|
|
91
95
|
// accept that trade-off vs. blocking every request handler.
|
|
92
|
-
void this.warmEvaluate(rs, resource, action);
|
|
96
|
+
void this.warmEvaluate(rs, resource, action, tenant);
|
|
93
97
|
return { allowed: false, reason: "OPA decision pending (warming cache); request again" };
|
|
94
98
|
}
|
|
95
99
|
/** Async warm of the evaluate cache. Public so a long-running
|
|
96
100
|
* caller can `await engine.warmEvaluate(...)` before the gate
|
|
97
101
|
* check if it cannot tolerate the warming-deny window. */
|
|
98
|
-
async warmEvaluate(roles, resource, action) {
|
|
99
|
-
const key = this.cacheKey(roles, resource, action);
|
|
102
|
+
async warmEvaluate(roles, resource, action, tenant) {
|
|
103
|
+
const key = this.cacheKey(roles, resource, action, tenant);
|
|
100
104
|
try {
|
|
101
|
-
|
|
105
|
+
// input.tenant is always included (undefined → null in JSON
|
|
106
|
+
// serialisation, omitted by JSON.stringify default) so Rego
|
|
107
|
+
// authors can write `input.tenant == "acme"` rules without
|
|
108
|
+
// tripping on missing-field. When the caller didn't supply
|
|
109
|
+
// tenant we still include the key with `undefined` value;
|
|
110
|
+
// JSON.stringify drops it cleanly.
|
|
111
|
+
const out = await this.query({ input: { roles, resource, action, tenant } });
|
|
102
112
|
const raw = out.result;
|
|
103
113
|
let result;
|
|
104
114
|
if (raw === true || raw === false) {
|
|
@@ -124,20 +134,21 @@ export class OpaPolicyEngine {
|
|
|
124
134
|
return result;
|
|
125
135
|
}
|
|
126
136
|
}
|
|
127
|
-
list(roles) {
|
|
137
|
+
list(roles, ctx) {
|
|
128
138
|
if (!roles || roles.length === 0)
|
|
129
139
|
return [];
|
|
130
|
-
const
|
|
140
|
+
const tenant = ctx?.tenant || "";
|
|
141
|
+
const key = roles.slice().sort().join("\x00") + "\x01" + tenant;
|
|
131
142
|
const cached = this.listCache.get(key);
|
|
132
143
|
if (cached && this.now() - cached.at < this.listCacheTtlMs)
|
|
133
144
|
return cached.perms;
|
|
134
|
-
void this.warmList(roles);
|
|
145
|
+
void this.warmList(roles, ctx?.tenant);
|
|
135
146
|
return [];
|
|
136
147
|
}
|
|
137
|
-
async warmList(roles) {
|
|
138
|
-
const key = roles.slice().sort().join("\x00");
|
|
148
|
+
async warmList(roles, tenant) {
|
|
149
|
+
const key = roles.slice().sort().join("\x00") + "\x01" + (tenant || "");
|
|
139
150
|
try {
|
|
140
|
-
const out = await this.query({ input: { roles, list: true } });
|
|
151
|
+
const out = await this.query({ input: { roles, list: true, tenant } });
|
|
141
152
|
const raw = out.result;
|
|
142
153
|
let perms = [];
|
|
143
154
|
if (raw && typeof raw === "object" && Array.isArray(raw.permissions)) {
|
|
@@ -141,6 +141,54 @@ test("OpaPolicyEngine — cache key delimiter prevents role-name collision (\"a,
|
|
|
141
141
|
assert.equal(r2.allowed, false);
|
|
142
142
|
assert.equal(calls.length, 2, "the two role-sets must not collide on the cache key");
|
|
143
143
|
});
|
|
144
|
+
test("OpaPolicyEngine — tenant flows into the OPA input shape on evaluate", async () => {
|
|
145
|
+
let seenBody = null;
|
|
146
|
+
const fetcher = (async (_url, init) => {
|
|
147
|
+
seenBody = init?.body ? JSON.parse(String(init.body)) : null;
|
|
148
|
+
return new Response(JSON.stringify({ result: true }), { status: 200 });
|
|
149
|
+
});
|
|
150
|
+
const e = new OpaPolicyEngine({ url: "http://opa.test", packagePath: "p", fetcher });
|
|
151
|
+
await e.warmEvaluate(["admin"], "sources", "write", "acme");
|
|
152
|
+
const body = seenBody;
|
|
153
|
+
assert.equal(body?.input?.tenant, "acme", "tenant must reach the Rego input as input.tenant");
|
|
154
|
+
assert.equal(body?.input?.resource, "sources");
|
|
155
|
+
assert.equal(body?.input?.action, "write");
|
|
156
|
+
});
|
|
157
|
+
test("OpaPolicyEngine — tenant flows into the OPA input shape on list", async () => {
|
|
158
|
+
let seenBody = null;
|
|
159
|
+
const fetcher = (async (_url, init) => {
|
|
160
|
+
seenBody = init?.body ? JSON.parse(String(init.body)) : null;
|
|
161
|
+
return new Response(JSON.stringify({ result: { permissions: [] } }), { status: 200 });
|
|
162
|
+
});
|
|
163
|
+
const e = new OpaPolicyEngine({ url: "http://opa.test", packagePath: "p", fetcher });
|
|
164
|
+
await e.warmList(["admin"], "acme");
|
|
165
|
+
const body = seenBody;
|
|
166
|
+
assert.equal(body?.input?.tenant, "acme");
|
|
167
|
+
assert.equal(body?.input?.list, true);
|
|
168
|
+
});
|
|
169
|
+
test("OpaPolicyEngine — cache key isolates tenants (same roles+resource+action, different tenants → two OPA calls)", async () => {
|
|
170
|
+
let calls = 0;
|
|
171
|
+
const fetcher = (async (_url, init) => {
|
|
172
|
+
calls++;
|
|
173
|
+
const body = init?.body ? JSON.parse(String(init.body)) : null;
|
|
174
|
+
// Return a different verdict per tenant so a cache mix-up would
|
|
175
|
+
// surface as a wrong allowed value, not just an extra call.
|
|
176
|
+
const allowed = body?.input?.tenant === "acme";
|
|
177
|
+
return new Response(JSON.stringify({ result: allowed }), { status: 200 });
|
|
178
|
+
});
|
|
179
|
+
const e = new OpaPolicyEngine({ url: "http://opa.test", packagePath: "p", fetcher });
|
|
180
|
+
const acme = await e.warmEvaluate(["admin"], "sources", "read", "acme");
|
|
181
|
+
const bigco = await e.warmEvaluate(["admin"], "sources", "read", "bigco");
|
|
182
|
+
assert.equal(acme.allowed, true);
|
|
183
|
+
assert.equal(bigco.allowed, false);
|
|
184
|
+
assert.equal(calls, 2, "tenants must not share cache slots");
|
|
185
|
+
// Repeat — both come from cache, no extra OPA hits.
|
|
186
|
+
const acme2 = e.evaluate(["admin"], "sources", "read", { tenant: "acme" });
|
|
187
|
+
const bigco2 = e.evaluate(["admin"], "sources", "read", { tenant: "bigco" });
|
|
188
|
+
assert.equal(acme2.allowed, true);
|
|
189
|
+
assert.equal(bigco2.allowed, false);
|
|
190
|
+
assert.equal(calls, 2);
|
|
191
|
+
});
|
|
144
192
|
test("OpaPolicyEngine — sort-stable cache key (role-set order doesn't matter)", async () => {
|
|
145
193
|
let calls = 0;
|
|
146
194
|
const fetcher = (async (_u) => {
|
package/dist/auth/rbac.d.ts
CHANGED
|
@@ -16,13 +16,18 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import type { RequestHandler } from "express";
|
|
18
18
|
import type { AuthRuntime } from "./middleware.js";
|
|
19
|
+
import type { PolicyEngine } from "./policy/engine.js";
|
|
19
20
|
export type Action = "read" | "write" | "delete" | "bypass";
|
|
20
21
|
export type Resource = "sources" | "services" | "health" | "topology" | "settings" | "connectors" | "audit" | "catalog" | "users" | "redaction" | "products";
|
|
21
22
|
export interface Permission {
|
|
22
23
|
resource: Resource;
|
|
23
24
|
action: Action;
|
|
24
25
|
}
|
|
25
|
-
/** Built-in default policy. Operators
|
|
26
|
+
/** Built-in default policy. Operators replace this via OMCP_RBAC_POLICY_FILE
|
|
27
|
+
* (YAML/JSON file → BuiltinPolicyEngine) or OMCP_OPA_URL (external
|
|
28
|
+
* OPA → OpaPolicyEngine). The gate consumes whichever via
|
|
29
|
+
* `buildRequirePermissionFromEngine` so tenant-conditional Rego
|
|
30
|
+
* rules can fire. */
|
|
26
31
|
export declare const DEFAULT_POLICY: Record<string, Permission[]>;
|
|
27
32
|
/** Resolve whether the given role set grants the requested permission. */
|
|
28
33
|
export declare function hasPermission(roles: string[] | undefined, resource: Resource, action: Action, policy?: Record<string, Permission[]>): boolean;
|
|
@@ -34,6 +39,23 @@ export declare function hasPermission(roles: string[] | undefined, resource: Res
|
|
|
34
39
|
* with no session means anonymous mode is active.
|
|
35
40
|
*/
|
|
36
41
|
export declare function buildRequirePermission(runtime: AuthRuntime, resource: Resource, action: Action, policy?: Record<string, Permission[]>): RequestHandler;
|
|
42
|
+
/**
|
|
43
|
+
* Engine-aware variant of `buildRequirePermission`. Prefer this when an
|
|
44
|
+
* external policy engine (OPA, custom Rego) is in play — the legacy
|
|
45
|
+
* `(roles, resource, action) → boolean` map cannot carry the active
|
|
46
|
+
* tenant, so a Rego rule like `allow { input.tenant == "acme" }` can
|
|
47
|
+
* never fire if you go through the map. This variant calls
|
|
48
|
+
* `engine.evaluate(roles, resource, action, { tenant: session.tenant })`
|
|
49
|
+
* so tenant-conditional rules see the input they need.
|
|
50
|
+
*
|
|
51
|
+
* Anonymous mode stays a no-op — same as the map variant.
|
|
52
|
+
*
|
|
53
|
+
* Performance: `evaluate` is sync by contract. OPA hits its cache; on
|
|
54
|
+
* first miss it returns a conservative deny and warms in the
|
|
55
|
+
* background — the second request inside the TTL gets the real
|
|
56
|
+
* verdict. Documented in opa.ts.
|
|
57
|
+
*/
|
|
58
|
+
export declare function buildRequirePermissionFromEngine(runtime: AuthRuntime, resource: Resource, action: Action, engine: PolicyEngine): RequestHandler;
|
|
37
59
|
/** Convenience snapshot for `/api/me` — list every permission the
|
|
38
60
|
* given role set unlocks. Used by the UI to hide write controls the
|
|
39
61
|
* current user can't trigger anyway. */
|
package/dist/auth/rbac.js
CHANGED
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
* Basic mode runs every mutating /api/* request through `requirePermission`
|
|
15
15
|
* middleware (mounted in index.ts alongside `requireSession`).
|
|
16
16
|
*/
|
|
17
|
-
/** Built-in default policy. Operators
|
|
17
|
+
/** Built-in default policy. Operators replace this via OMCP_RBAC_POLICY_FILE
|
|
18
|
+
* (YAML/JSON file → BuiltinPolicyEngine) or OMCP_OPA_URL (external
|
|
19
|
+
* OPA → OpaPolicyEngine). The gate consumes whichever via
|
|
20
|
+
* `buildRequirePermissionFromEngine` so tenant-conditional Rego
|
|
21
|
+
* rules can fire. */
|
|
18
22
|
export const DEFAULT_POLICY = {
|
|
19
23
|
viewer: [
|
|
20
24
|
{ resource: "sources", action: "read" },
|
|
@@ -96,6 +100,44 @@ export function buildRequirePermission(runtime, resource, action, policy = DEFAU
|
|
|
96
100
|
});
|
|
97
101
|
};
|
|
98
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Engine-aware variant of `buildRequirePermission`. Prefer this when an
|
|
105
|
+
* external policy engine (OPA, custom Rego) is in play — the legacy
|
|
106
|
+
* `(roles, resource, action) → boolean` map cannot carry the active
|
|
107
|
+
* tenant, so a Rego rule like `allow { input.tenant == "acme" }` can
|
|
108
|
+
* never fire if you go through the map. This variant calls
|
|
109
|
+
* `engine.evaluate(roles, resource, action, { tenant: session.tenant })`
|
|
110
|
+
* so tenant-conditional rules see the input they need.
|
|
111
|
+
*
|
|
112
|
+
* Anonymous mode stays a no-op — same as the map variant.
|
|
113
|
+
*
|
|
114
|
+
* Performance: `evaluate` is sync by contract. OPA hits its cache; on
|
|
115
|
+
* first miss it returns a conservative deny and warms in the
|
|
116
|
+
* background — the second request inside the TTL gets the real
|
|
117
|
+
* verdict. Documented in opa.ts.
|
|
118
|
+
*/
|
|
119
|
+
export function buildRequirePermissionFromEngine(runtime, resource, action, engine) {
|
|
120
|
+
if (runtime.mode === "anonymous") {
|
|
121
|
+
return function rbacNoop(_req, _res, next) {
|
|
122
|
+
next();
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
return function rbacGateEngine(req, res, next) {
|
|
126
|
+
const sess = req.session;
|
|
127
|
+
const verdict = engine.evaluate(sess?.roles, resource, action, { tenant: sess?.tenant });
|
|
128
|
+
if (verdict.allowed) {
|
|
129
|
+
next();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
res.status(403).json({
|
|
133
|
+
error: "permission denied",
|
|
134
|
+
code: "OMCP_PERMISSION_DENIED",
|
|
135
|
+
required: { resource, action },
|
|
136
|
+
have: sess?.roles ?? [],
|
|
137
|
+
reason: verdict.reason,
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
}
|
|
99
141
|
/** Convenience snapshot for `/api/me` — list every permission the
|
|
100
142
|
* given role set unlocks. Used by the UI to hide write controls the
|
|
101
143
|
* current user can't trigger anyway. */
|
package/dist/auth/rbac.test.js
CHANGED
|
@@ -119,3 +119,65 @@ test("DEFAULT_POLICY shape — has the three built-in roles", () => {
|
|
|
119
119
|
assert.ok(DEFAULT_POLICY.operator);
|
|
120
120
|
assert.ok(DEFAULT_POLICY.admin);
|
|
121
121
|
});
|
|
122
|
+
import { buildRequirePermissionFromEngine } from "./rbac.js";
|
|
123
|
+
import { BuiltinPolicyEngine } from "./policy/engine.js";
|
|
124
|
+
test("buildRequirePermissionFromEngine — anonymous always allows (no-op middleware)", () => {
|
|
125
|
+
const engine = new BuiltinPolicyEngine(DEFAULT_POLICY);
|
|
126
|
+
const mw = buildRequirePermissionFromEngine({ mode: "anonymous" }, "sources", "write", engine);
|
|
127
|
+
const res = mkRes();
|
|
128
|
+
let called = false;
|
|
129
|
+
mw(mkReq(), res, () => { called = true; });
|
|
130
|
+
assert.equal(called, true);
|
|
131
|
+
assert.equal(res.statusCode, 0);
|
|
132
|
+
});
|
|
133
|
+
test("buildRequirePermissionFromEngine — engine.evaluate verdict is surfaced verbatim in the 403 body", () => {
|
|
134
|
+
// Spy engine returns a custom reason so we can prove the gate forwards it.
|
|
135
|
+
const engine = {
|
|
136
|
+
evaluate: () => ({ allowed: false, reason: "test deny reason from engine" }),
|
|
137
|
+
list: () => [],
|
|
138
|
+
roles: () => [],
|
|
139
|
+
kind: () => "test",
|
|
140
|
+
};
|
|
141
|
+
const runtime = { mode: "basic", session: { secret: "x".repeat(48) } };
|
|
142
|
+
const mw = buildRequirePermissionFromEngine(runtime, "sources", "write", engine);
|
|
143
|
+
const res = mkRes();
|
|
144
|
+
mw(mkReq(["viewer"]), res, () => undefined);
|
|
145
|
+
assert.equal(res.statusCode, 403);
|
|
146
|
+
const body = res.body;
|
|
147
|
+
assert.equal(body.code, "OMCP_PERMISSION_DENIED");
|
|
148
|
+
assert.equal(body.reason, "test deny reason from engine");
|
|
149
|
+
});
|
|
150
|
+
test("buildRequirePermissionFromEngine — passes session.tenant into engine.evaluate's EvalContext (load-bearing for OPA tenant rules)", () => {
|
|
151
|
+
// This is the property the OPA-input-tenant refine relies on:
|
|
152
|
+
// the gate MUST pass session.tenant to engine.evaluate so a Rego
|
|
153
|
+
// rule like `allow { input.tenant == "acme" }` can fire. Capture
|
|
154
|
+
// the ctx and assert it carries the tenant verbatim.
|
|
155
|
+
let seenCtx;
|
|
156
|
+
const engine = {
|
|
157
|
+
evaluate: (_roles, _r, _a, ctx) => {
|
|
158
|
+
seenCtx = ctx;
|
|
159
|
+
return { allowed: true, reason: "ok" };
|
|
160
|
+
},
|
|
161
|
+
list: () => [],
|
|
162
|
+
roles: () => [],
|
|
163
|
+
kind: () => "spy",
|
|
164
|
+
};
|
|
165
|
+
const runtime = { mode: "basic", session: { secret: "x".repeat(48) } };
|
|
166
|
+
const mw = buildRequirePermissionFromEngine(runtime, "sources", "read", engine);
|
|
167
|
+
const res = mkRes();
|
|
168
|
+
const req = {
|
|
169
|
+
session: { sub: "u", name: "u", roles: ["viewer"], tenant: "acme", iat: 0, exp: Date.now() / 1000 + 60 },
|
|
170
|
+
};
|
|
171
|
+
mw(req, res, () => undefined);
|
|
172
|
+
assert.equal(seenCtx?.tenant, "acme", "tenant from session must flow into engine.evaluate ctx");
|
|
173
|
+
});
|
|
174
|
+
test("buildRequirePermissionFromEngine — sessionless basic-mode request denies via engine (roles undefined → no permission)", () => {
|
|
175
|
+
const engine = new BuiltinPolicyEngine(DEFAULT_POLICY);
|
|
176
|
+
const runtime = { mode: "basic", session: { secret: "x".repeat(48) } };
|
|
177
|
+
const mw = buildRequirePermissionFromEngine(runtime, "sources", "read", engine);
|
|
178
|
+
const res = mkRes();
|
|
179
|
+
let called = false;
|
|
180
|
+
mw(mkReq(), res, () => { called = true; });
|
|
181
|
+
assert.equal(called, false);
|
|
182
|
+
assert.equal(res.statusCode, 403);
|
|
183
|
+
});
|
package/dist/cli/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { tmpdir } from "node:os";
|
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { parseArgs, pickFreePort, composeOverride, resolveCatalogSource, formatPluginList, formatPluginInfo, resolveInstall, splitPassthrough, helmReleaseArgs, HELM_REPO_NAME, HELM_REPO_URL, HELP, } from "./lib.js";
|
|
9
9
|
import { loadTrustRoot, verifyIntegrity, verifyManifestSignature, PluginVerificationError, } from "../connectors/verify.js";
|
|
10
|
+
import { inspectorConfigCommand } from "./inspector-config.js";
|
|
10
11
|
function pkgVersion() {
|
|
11
12
|
try {
|
|
12
13
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
@@ -363,6 +364,8 @@ async function main() {
|
|
|
363
364
|
return plugin(sub, positionals, flags);
|
|
364
365
|
case "helm":
|
|
365
366
|
return helm(sub, positionals[0] ?? "observability-mcp", passthrough);
|
|
367
|
+
case "inspector-config":
|
|
368
|
+
return inspectorConfigCommand();
|
|
366
369
|
default:
|
|
367
370
|
fail(`unknown command: ${command}\n\n${HELP}`);
|
|
368
371
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface InspectorConfig {
|
|
2
|
+
mcpServers: Record<string, {
|
|
3
|
+
url: string;
|
|
4
|
+
headers?: Record<string, string>;
|
|
5
|
+
}>;
|
|
6
|
+
}
|
|
7
|
+
export declare function buildInspectorConfig(env?: NodeJS.ProcessEnv): InspectorConfig;
|
|
8
|
+
/** CLI entrypoint. Prints JSON to stdout; exits 0 on success. */
|
|
9
|
+
export declare function inspectorConfigCommand(): void;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// `omcp inspector-config` — emit a config JSON the official MCP
|
|
2
|
+
// Inspector can consume. Reads OMCP_BASE_URL (default
|
|
3
|
+
// http://localhost:3000) and optionally OMCP_INSPECTOR_TOKEN to put
|
|
4
|
+
// in the Authorization header.
|
|
5
|
+
//
|
|
6
|
+
// Pipe straight into Inspector:
|
|
7
|
+
// npx @modelcontextprotocol/inspector --config <(omcp inspector-config)
|
|
8
|
+
//
|
|
9
|
+
// Or write to a file:
|
|
10
|
+
// omcp inspector-config > inspector.json
|
|
11
|
+
// npx @modelcontextprotocol/inspector --config inspector.json
|
|
12
|
+
export function buildInspectorConfig(env = process.env) {
|
|
13
|
+
const baseRaw = env.OMCP_BASE_URL?.trim() || "http://localhost:3000";
|
|
14
|
+
const base = baseRaw.replace(/\/$/, "");
|
|
15
|
+
const url = `${base}/mcp`;
|
|
16
|
+
const token = env.OMCP_INSPECTOR_TOKEN?.trim();
|
|
17
|
+
const name = env.OMCP_INSPECTOR_SERVER_NAME?.trim() || "observability-mcp";
|
|
18
|
+
const server = { url };
|
|
19
|
+
if (token) {
|
|
20
|
+
server.headers = { Authorization: `Bearer ${token}` };
|
|
21
|
+
}
|
|
22
|
+
return { mcpServers: { [name]: server } };
|
|
23
|
+
}
|
|
24
|
+
/** CLI entrypoint. Prints JSON to stdout; exits 0 on success. */
|
|
25
|
+
export function inspectorConfigCommand() {
|
|
26
|
+
const cfg = buildInspectorConfig();
|
|
27
|
+
process.stdout.write(JSON.stringify(cfg, null, 2) + "\n");
|
|
28
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { buildInspectorConfig } from "./inspector-config.js";
|
|
4
|
+
test("buildInspectorConfig: defaults to localhost:3000/mcp, no headers, observability-mcp server name", () => {
|
|
5
|
+
const cfg = buildInspectorConfig({});
|
|
6
|
+
assert.deepEqual(cfg, {
|
|
7
|
+
mcpServers: {
|
|
8
|
+
"observability-mcp": {
|
|
9
|
+
url: "http://localhost:3000/mcp",
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
test("buildInspectorConfig: trims trailing slash from base URL", () => {
|
|
15
|
+
const cfg = buildInspectorConfig({ OMCP_BASE_URL: "https://gw.example.com/" });
|
|
16
|
+
assert.equal(cfg.mcpServers["observability-mcp"].url, "https://gw.example.com/mcp");
|
|
17
|
+
});
|
|
18
|
+
test("buildInspectorConfig: token populates Authorization Bearer", () => {
|
|
19
|
+
const cfg = buildInspectorConfig({
|
|
20
|
+
OMCP_INSPECTOR_TOKEN: "tok-abc",
|
|
21
|
+
});
|
|
22
|
+
assert.equal(cfg.mcpServers["observability-mcp"].headers?.Authorization, "Bearer tok-abc");
|
|
23
|
+
});
|
|
24
|
+
test("buildInspectorConfig: custom server name", () => {
|
|
25
|
+
const cfg = buildInspectorConfig({
|
|
26
|
+
OMCP_INSPECTOR_SERVER_NAME: "my-gateway",
|
|
27
|
+
});
|
|
28
|
+
assert.ok("my-gateway" in cfg.mcpServers);
|
|
29
|
+
});
|
|
30
|
+
test("buildInspectorConfig: empty token trimmed → no headers key", () => {
|
|
31
|
+
const cfg = buildInspectorConfig({ OMCP_INSPECTOR_TOKEN: " " });
|
|
32
|
+
assert.equal(cfg.mcpServers["observability-mcp"].headers, undefined);
|
|
33
|
+
});
|
package/dist/cli/lib.d.ts
CHANGED
|
@@ -92,4 +92,4 @@ export declare function splitPassthrough(argv: string[]): {
|
|
|
92
92
|
};
|
|
93
93
|
/** The helm argv for the install/upgrade step (repo add/update are fixed). */
|
|
94
94
|
export declare function helmReleaseArgs(action: "install" | "upgrade", release: string, passthrough: string[]): string[];
|
|
95
|
-
export declare const HELP = "omcp \u2014 observability-mcp control CLI\n\nUsage:\n omcp version Print CLI + server package version\n omcp doctor Check the local toolchain (docker, compose, helm, node)\n omcp demo up Start the full demo stack (auto-picks free host ports)\n omcp demo down Stop and remove the demo stack\n omcp demo status Show demo container status\n omcp plugin list List connectors from the hub catalog\n omcp plugin info <name> Show one connector's versions + verification info\n omcp plugin install <ref> Install name[@version]: download, verify, extract\n omcp plugin verify <dir> Verify an installed plugin dir against a trust root\n omcp helm install [release] helm repo add+update, then install the signed chart\n omcp helm upgrade [release] Same, as 'helm upgrade --install'\n omcp help Show this help\n\nPass extra helm flags after a literal --, e.g.:\n omcp helm upgrade obs -- -n monitoring --set sources.prometheusUrl=http://prom:9090\n\nFlags:\n --json Machine-readable output (doctor, status, plugin)\n --from <url|path> Catalog source (default: local checkout or the public hub)\n --offline-dir <dir> Airgapped: read <name>-<ver>.tgz[.sig] + manifest from <dir>\n --trust-root <pem> Verify signature+integrity against this PEM (fail-closed)\n --insecure Skip verification (NOT recommended; explicit opt-out)\n --dest <dir> Install target (default: $PLUGINS_DIR or ./plugins)\n --force Overwrite an existing install dir\n";
|
|
95
|
+
export declare const HELP = "omcp \u2014 observability-mcp control CLI\n\nUsage:\n omcp version Print CLI + server package version\n omcp doctor Check the local toolchain (docker, compose, helm, node)\n omcp demo up Start the full demo stack (auto-picks free host ports)\n omcp demo down Stop and remove the demo stack\n omcp demo status Show demo container status\n omcp plugin list List connectors from the hub catalog\n omcp plugin info <name> Show one connector's versions + verification info\n omcp plugin install <ref> Install name[@version]: download, verify, extract\n omcp plugin verify <dir> Verify an installed plugin dir against a trust root\n omcp helm install [release] helm repo add+update, then install the signed chart\n omcp helm upgrade [release] Same, as 'helm upgrade --install'\n omcp inspector-config Print an MCP Inspector config JSON pointing at the local gateway\n omcp help Show this help\n\nPass extra helm flags after a literal --, e.g.:\n omcp helm upgrade obs -- -n monitoring --set sources.prometheusUrl=http://prom:9090\n\nFlags:\n --json Machine-readable output (doctor, status, plugin)\n --from <url|path> Catalog source (default: local checkout or the public hub)\n --offline-dir <dir> Airgapped: read <name>-<ver>.tgz[.sig] + manifest from <dir>\n --trust-root <pem> Verify signature+integrity against this PEM (fail-closed)\n --insecure Skip verification (NOT recommended; explicit opt-out)\n --dest <dir> Install target (default: $PLUGINS_DIR or ./plugins)\n --force Overwrite an existing install dir\n";
|
package/dist/cli/lib.js
CHANGED
|
@@ -169,6 +169,7 @@ Usage:
|
|
|
169
169
|
omcp plugin verify <dir> Verify an installed plugin dir against a trust root
|
|
170
170
|
omcp helm install [release] helm repo add+update, then install the signed chart
|
|
171
171
|
omcp helm upgrade [release] Same, as 'helm upgrade --install'
|
|
172
|
+
omcp inspector-config Print an MCP Inspector config JSON pointing at the local gateway
|
|
172
173
|
omcp help Show this help
|
|
173
174
|
|
|
174
175
|
Pass extra helm flags after a literal --, e.g.:
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// MCP 2025-11-25 conformance harness.
|
|
2
|
+
//
|
|
3
|
+
// Run against a running gateway by setting OMCP_CONFORMANCE_URL to
|
|
4
|
+
// its Streamable HTTP endpoint (default http://localhost:3000/mcp).
|
|
5
|
+
// When the env var is unset, every test skips — this lets the suite
|
|
6
|
+
// live in `find src -name "*.test.ts"` without requiring a server
|
|
7
|
+
// during a plain unit-test run.
|
|
8
|
+
//
|
|
9
|
+
// OMCP_CONFORMANCE_URL=http://localhost:3000/mcp \
|
|
10
|
+
// npx tsx --test src/conformance/mcp-2025-11-25.test.ts
|
|
11
|
+
//
|
|
12
|
+
// The `make conformance` target boots the demo stack, waits for
|
|
13
|
+
// /healthz, then runs this file with the URL pointed at the live
|
|
14
|
+
// server.
|
|
15
|
+
import { test } from "node:test";
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
const URL_ENV = process.env.OMCP_CONFORMANCE_URL;
|
|
18
|
+
const PROTOCOL_VERSION = "2025-11-25";
|
|
19
|
+
const skip = !URL_ENV;
|
|
20
|
+
const opts = skip ? { skip: "OMCP_CONFORMANCE_URL not set" } : {};
|
|
21
|
+
async function jsonRpc(method, params, opts = {}) {
|
|
22
|
+
if (!URL_ENV)
|
|
23
|
+
throw new Error("OMCP_CONFORMANCE_URL not set");
|
|
24
|
+
const reqHeaders = {
|
|
25
|
+
"content-type": "application/json",
|
|
26
|
+
accept: "application/json, text/event-stream",
|
|
27
|
+
};
|
|
28
|
+
if (opts.session)
|
|
29
|
+
reqHeaders["mcp-session-id"] = opts.session;
|
|
30
|
+
const body = {
|
|
31
|
+
jsonrpc: "2.0",
|
|
32
|
+
id: opts.id ?? 1,
|
|
33
|
+
method,
|
|
34
|
+
params: params ?? {},
|
|
35
|
+
};
|
|
36
|
+
const res = await fetch(URL_ENV, {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: reqHeaders,
|
|
39
|
+
body: JSON.stringify(body),
|
|
40
|
+
});
|
|
41
|
+
const headers = {};
|
|
42
|
+
res.headers.forEach((v, k) => {
|
|
43
|
+
headers[k] = v;
|
|
44
|
+
});
|
|
45
|
+
// Streamable HTTP may answer with either JSON or SSE; both carry a
|
|
46
|
+
// single JSON-RPC envelope for unary calls. Strip the SSE framing
|
|
47
|
+
// if present so the test only deals with the JSON shape.
|
|
48
|
+
const text = await res.text();
|
|
49
|
+
let response;
|
|
50
|
+
if (text.startsWith("event:") || text.includes("data: ")) {
|
|
51
|
+
const match = text.match(/^data:\s*(.+)$/m);
|
|
52
|
+
response = match ? JSON.parse(match[1]) : {};
|
|
53
|
+
}
|
|
54
|
+
else if (text.trim().startsWith("{")) {
|
|
55
|
+
response = JSON.parse(text);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
response = {};
|
|
59
|
+
}
|
|
60
|
+
return { response, headers, status: res.status };
|
|
61
|
+
}
|
|
62
|
+
async function notify(method, session) {
|
|
63
|
+
if (!URL_ENV)
|
|
64
|
+
return;
|
|
65
|
+
await fetch(URL_ENV, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: {
|
|
68
|
+
"content-type": "application/json",
|
|
69
|
+
accept: "application/json, text/event-stream",
|
|
70
|
+
"mcp-session-id": session,
|
|
71
|
+
},
|
|
72
|
+
body: JSON.stringify({ jsonrpc: "2.0", method, params: {} }),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
async function newSession() {
|
|
76
|
+
const { headers, response } = await jsonRpc("initialize", {
|
|
77
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
78
|
+
capabilities: {},
|
|
79
|
+
clientInfo: { name: "conformance-harness", version: "0" },
|
|
80
|
+
}, { id: 1 });
|
|
81
|
+
assert.ok(response.result, "initialize must return a result");
|
|
82
|
+
const session = headers["mcp-session-id"];
|
|
83
|
+
assert.ok(session, "server must issue mcp-session-id on initialize");
|
|
84
|
+
await notify("notifications/initialized", session);
|
|
85
|
+
return session;
|
|
86
|
+
}
|
|
87
|
+
test("MCP 2025-11-25: initialize returns spec-compliant InitializeResult", opts, async () => {
|
|
88
|
+
const { response, headers } = await jsonRpc("initialize", {
|
|
89
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
90
|
+
capabilities: {},
|
|
91
|
+
clientInfo: { name: "harness", version: "0" },
|
|
92
|
+
});
|
|
93
|
+
assert.equal(response.jsonrpc, "2.0");
|
|
94
|
+
assert.equal(response.id, 1);
|
|
95
|
+
assert.ok(response.result && typeof response.result === "object");
|
|
96
|
+
const r = response.result;
|
|
97
|
+
assert.ok(r.protocolVersion, "InitializeResult must include protocolVersion");
|
|
98
|
+
assert.ok(r.capabilities && typeof r.capabilities === "object", "capabilities object required");
|
|
99
|
+
assert.ok(r.serverInfo && typeof r.serverInfo === "object", "serverInfo required");
|
|
100
|
+
assert.ok(r.serverInfo?.name, "serverInfo.name required");
|
|
101
|
+
assert.ok(r.serverInfo?.version, "serverInfo.version required");
|
|
102
|
+
assert.ok(headers["mcp-session-id"], "Mcp-Session-Id header required on initialize response");
|
|
103
|
+
});
|
|
104
|
+
test("MCP 2025-11-25: tools/list returns a Tool[] each with name + inputSchema", opts, async () => {
|
|
105
|
+
const session = await newSession();
|
|
106
|
+
const { response } = await jsonRpc("tools/list", {}, { id: 2, session });
|
|
107
|
+
assert.ok(response.result, JSON.stringify(response.error ?? {}));
|
|
108
|
+
const r = response.result;
|
|
109
|
+
assert.ok(Array.isArray(r.tools), "tools must be an array");
|
|
110
|
+
assert.ok(r.tools && r.tools.length > 0, "gateway must expose at least one tool");
|
|
111
|
+
for (const t of r.tools) {
|
|
112
|
+
assert.ok(t.name && typeof t.name === "string", `tool name required, got ${JSON.stringify(t)}`);
|
|
113
|
+
assert.ok(t.inputSchema && typeof t.inputSchema === "object", `tool ${t.name} missing inputSchema`);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
test("MCP 2025-11-25: tools/call dispatches and returns CallToolResult", opts, async () => {
|
|
117
|
+
const session = await newSession();
|
|
118
|
+
const { response } = await jsonRpc("tools/call", { name: "list_sources", arguments: {} }, { id: 3, session });
|
|
119
|
+
// Either a result (success path) or a JSON-RPC error — both are
|
|
120
|
+
// spec-compliant; we just verify shape.
|
|
121
|
+
if (response.error) {
|
|
122
|
+
assert.equal(typeof response.error.code, "number");
|
|
123
|
+
assert.equal(typeof response.error.message, "string");
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
const r = response.result;
|
|
127
|
+
assert.ok(Array.isArray(r.content), "CallToolResult.content must be an array");
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
test("MCP 2025-11-25: unknown method returns -32601 Method not found", opts, async () => {
|
|
131
|
+
const session = await newSession();
|
|
132
|
+
const { response } = await jsonRpc("this/method/does/not/exist", {}, { id: 99, session });
|
|
133
|
+
assert.ok(response.error, "expected an error envelope");
|
|
134
|
+
assert.equal(response.error?.code, -32601, "spec-mandated error code for unknown method");
|
|
135
|
+
});
|
|
136
|
+
test("MCP 2025-11-25: ping returns an empty result", opts, async () => {
|
|
137
|
+
const session = await newSession();
|
|
138
|
+
const { response } = await jsonRpc("ping", {}, { id: 4, session });
|
|
139
|
+
assert.ok(response.result !== undefined, "ping must return a result (may be empty object)");
|
|
140
|
+
});
|
|
141
|
+
test("MCP 2025-11-25: resources/list returns Resource[] or method-not-found", opts, async () => {
|
|
142
|
+
const session = await newSession();
|
|
143
|
+
const { response } = await jsonRpc("resources/list", {}, { id: 5, session });
|
|
144
|
+
if (response.error) {
|
|
145
|
+
assert.equal(response.error.code, -32601, "if not supported, must be -32601");
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
const r = response.result;
|
|
149
|
+
assert.ok(Array.isArray(r.resources), "resources must be an array");
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
test("MCP 2025-11-25: prompts/list returns Prompt[] or method-not-found", opts, async () => {
|
|
153
|
+
const session = await newSession();
|
|
154
|
+
const { response } = await jsonRpc("prompts/list", {}, { id: 6, session });
|
|
155
|
+
if (response.error) {
|
|
156
|
+
assert.equal(response.error.code, -32601, "if not supported, must be -32601");
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
const r = response.result;
|
|
160
|
+
assert.ok(Array.isArray(r.prompts), "prompts must be an array");
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
test("MCP 2025-11-25: logging/setLevel accepts spec levels or method-not-found", opts, async () => {
|
|
164
|
+
const session = await newSession();
|
|
165
|
+
const { response } = await jsonRpc("logging/setLevel", { level: "info" }, { id: 7, session });
|
|
166
|
+
if (response.error) {
|
|
167
|
+
assert.equal(response.error.code, -32601, "if not supported, must be -32601");
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
// Spec says the result is `EmptyResult` — we don't enforce
|
|
171
|
+
// strictly empty (some implementations include diagnostics) but
|
|
172
|
+
// it must be a JSON object.
|
|
173
|
+
assert.ok(typeof response.result === "object");
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
test("MCP 2025-11-25: tools/call with invalid params returns -32602 or isError result", opts, async () => {
|
|
177
|
+
const session = await newSession();
|
|
178
|
+
const { response } = await jsonRpc("tools/call", { name: "list_sources", arguments: { __invalid_arg: { nested: 1 } } }, { id: 8, session });
|
|
179
|
+
// The spec allows either a JSON-RPC error or an isError CallToolResult.
|
|
180
|
+
// We accept either; reject only on a successful non-error result for
|
|
181
|
+
// input that should not validate.
|
|
182
|
+
if (response.error) {
|
|
183
|
+
assert.ok([-32602, -32600].includes(response.error.code) || response.error.code <= -32000);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
const r = response.result;
|
|
187
|
+
// list_sources happens to ignore unknown args — that's fine, the
|
|
188
|
+
// spec doesn't require strict input rejection for tools that opt
|
|
189
|
+
// out. Just confirm we got a shape-conformant CallToolResult.
|
|
190
|
+
assert.ok(Array.isArray(r.content));
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
test("MCP 2025-11-25: server advertises protocolVersion equal to or newer than 2025-11-25", opts, async () => {
|
|
194
|
+
const { response } = await jsonRpc("initialize", {
|
|
195
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
196
|
+
capabilities: {},
|
|
197
|
+
clientInfo: { name: "harness", version: "0" },
|
|
198
|
+
}, { id: 100 });
|
|
199
|
+
const r = response.result;
|
|
200
|
+
assert.ok(r.protocolVersion, "protocolVersion must be present in InitializeResult");
|
|
201
|
+
// Spec contract: the server picks the highest version it supports
|
|
202
|
+
// that the client also offered, OR returns the highest it knows
|
|
203
|
+
// about and lets the client decide. We just require it's a
|
|
204
|
+
// recognised date-style version string.
|
|
205
|
+
assert.match(r.protocolVersion, /^\d{4}-\d{2}-\d{2}$/, "protocolVersion must be a YYYY-MM-DD date");
|
|
206
|
+
});
|