@thotischner/observability-mcp 1.8.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/analysis/history.d.ts +70 -0
- package/dist/analysis/history.js +170 -0
- package/dist/analysis/history.test.d.ts +1 -0
- package/dist/analysis/history.test.js +141 -0
- package/dist/audit/log.d.ts +9 -0
- package/dist/audit/log.js +20 -0
- package/dist/audit/redaction-bypass.d.ts +67 -0
- package/dist/audit/redaction-bypass.js +64 -0
- package/dist/audit/redaction-bypass.test.d.ts +1 -0
- package/dist/audit/redaction-bypass.test.js +72 -0
- package/dist/audit/sinks/types.d.ts +18 -0
- package/dist/audit/sinks/types.js +1 -0
- package/dist/audit/sinks/webhook.d.ts +45 -0
- package/dist/audit/sinks/webhook.js +111 -0
- package/dist/audit/sinks/webhook.test.d.ts +1 -0
- package/dist/audit/sinks/webhook.test.js +162 -0
- package/dist/auth/credentials.d.ts +11 -0
- package/dist/auth/credentials.js +27 -0
- package/dist/auth/credentials.test.js +21 -1
- package/dist/auth/csrf.d.ts +26 -0
- package/dist/auth/csrf.js +128 -0
- package/dist/auth/csrf.test.d.ts +1 -0
- package/dist/auth/csrf.test.js +143 -0
- package/dist/auth/local-users.d.ts +6 -0
- package/dist/auth/local-users.js +11 -0
- package/dist/auth/local-users.test.js +41 -0
- package/dist/auth/middleware.d.ts +7 -6
- package/dist/auth/oidc/dcr.d.ts +70 -0
- package/dist/auth/oidc/dcr.js +160 -0
- package/dist/auth/oidc/dcr.test.d.ts +1 -0
- package/dist/auth/oidc/dcr.test.js +109 -0
- package/dist/auth/oidc/endpoints.js +44 -0
- package/dist/auth/oidc/profiles.d.ts +22 -0
- package/dist/auth/oidc/profiles.js +95 -0
- package/dist/auth/oidc/profiles.test.d.ts +1 -0
- package/dist/auth/oidc/profiles.test.js +51 -0
- package/dist/auth/oidc/runtime.d.ts +3 -0
- package/dist/auth/oidc/runtime.js +16 -3
- package/dist/auth/oidc/runtime.test.js +1 -0
- package/dist/auth/policy/batch-dry-run.d.ts +56 -0
- package/dist/auth/policy/batch-dry-run.js +129 -0
- package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
- package/dist/auth/policy/batch-dry-run.test.js +140 -0
- package/dist/auth/policy/engine.d.ts +20 -4
- package/dist/auth/policy/engine.js +16 -2
- package/dist/auth/policy/loader.d.ts +11 -1
- package/dist/auth/policy/loader.js +37 -0
- package/dist/auth/policy/loader.test.d.ts +1 -0
- package/dist/auth/policy/loader.test.js +86 -0
- package/dist/auth/policy/opa.d.ts +5 -5
- package/dist/auth/policy/opa.js +25 -14
- package/dist/auth/policy/opa.test.js +48 -0
- package/dist/auth/rbac.d.ts +23 -1
- package/dist/auth/rbac.js +43 -1
- package/dist/auth/rbac.test.js +62 -0
- package/dist/cli/index.js +3 -0
- package/dist/cli/inspector-config.d.ts +9 -0
- package/dist/cli/inspector-config.js +28 -0
- package/dist/cli/inspector-config.test.d.ts +1 -0
- package/dist/cli/inspector-config.test.js +33 -0
- package/dist/cli/lib.d.ts +1 -1
- package/dist/cli/lib.js +1 -0
- package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
- package/dist/conformance/mcp-2025-11-25.test.js +206 -0
- package/dist/connectors/interface.d.ts +5 -1
- package/dist/connectors/loader.js +6 -4
- package/dist/connectors/loader.test.d.ts +1 -0
- package/dist/connectors/loader.test.js +78 -0
- package/dist/connectors/prometheus.test.js +31 -13
- package/dist/connectors/registry.d.ts +13 -0
- package/dist/connectors/registry.js +30 -0
- package/dist/connectors/registry.test.js +56 -2
- package/dist/context.d.ts +32 -0
- package/dist/context.js +35 -0
- package/dist/context.test.d.ts +1 -0
- package/dist/context.test.js +58 -0
- package/dist/federation/registry.d.ts +32 -0
- package/dist/federation/registry.js +77 -0
- package/dist/federation/registry.test.d.ts +1 -0
- package/dist/federation/registry.test.js +130 -0
- package/dist/federation/upstream.d.ts +60 -0
- package/dist/federation/upstream.js +114 -0
- package/dist/index.js +1188 -120
- package/dist/middleware/ssrfGuard.d.ts +15 -0
- package/dist/middleware/ssrfGuard.js +103 -0
- package/dist/middleware/ssrfGuard.test.d.ts +1 -0
- package/dist/middleware/ssrfGuard.test.js +81 -0
- package/dist/observability/otel.d.ts +20 -0
- package/dist/observability/otel.js +118 -0
- package/dist/observability/otel.test.d.ts +1 -0
- package/dist/observability/otel.test.js +56 -0
- package/dist/openapi.js +215 -7
- package/dist/openapi.test.js +34 -0
- package/dist/postmortem/synthesizer.d.ts +83 -0
- package/dist/postmortem/synthesizer.js +205 -0
- package/dist/postmortem/synthesizer.test.d.ts +1 -0
- package/dist/postmortem/synthesizer.test.js +141 -0
- package/dist/products/loader.d.ts +31 -3
- package/dist/products/loader.js +77 -4
- package/dist/products/loader.test.js +90 -1
- package/dist/quota/charge.d.ts +28 -0
- package/dist/quota/charge.js +30 -0
- package/dist/quota/charge.test.d.ts +1 -0
- package/dist/quota/charge.test.js +83 -0
- package/dist/quota/limiter.d.ts +29 -4
- package/dist/quota/limiter.js +64 -8
- package/dist/quota/limiter.test.js +86 -0
- package/dist/scim/group-role-map.d.ts +4 -0
- package/dist/scim/group-role-map.js +33 -0
- package/dist/scim/group-role-map.test.d.ts +1 -0
- package/dist/scim/group-role-map.test.js +33 -0
- package/dist/scim/routes.d.ts +15 -0
- package/dist/scim/routes.js +249 -0
- package/dist/scim/store.d.ts +37 -0
- package/dist/scim/store.js +178 -0
- package/dist/scim/store.test.d.ts +1 -0
- package/dist/scim/store.test.js +121 -0
- package/dist/scim/types.d.ts +73 -0
- package/dist/scim/types.js +29 -0
- package/dist/sdk/hooks.d.ts +77 -0
- package/dist/sdk/hooks.js +72 -0
- package/dist/sdk/hooks.test.d.ts +1 -0
- package/dist/sdk/hooks.test.js +159 -0
- package/dist/sdk/index.d.ts +2 -0
- package/dist/sdk/index.js +1 -0
- package/dist/sdk/manifest-schema.d.ts +17 -0
- package/dist/sdk/manifest-schema.js +21 -0
- package/dist/tools/context-seam.test.js +6 -1
- package/dist/tools/detect-anomalies.d.ts +1 -1
- package/dist/tools/detect-anomalies.js +5 -4
- package/dist/tools/generate-postmortem.d.ts +35 -0
- package/dist/tools/generate-postmortem.js +191 -0
- package/dist/tools/get-anomaly-history.d.ts +35 -0
- package/dist/tools/get-anomaly-history.js +126 -0
- package/dist/tools/get-service-health.d.ts +1 -1
- package/dist/tools/get-service-health.js +4 -3
- package/dist/tools/list-services.d.ts +1 -1
- package/dist/tools/list-services.js +3 -2
- package/dist/tools/list-sources.d.ts +1 -1
- package/dist/tools/list-sources.js +6 -2
- package/dist/tools/query-logs.d.ts +1 -1
- package/dist/tools/query-logs.js +2 -2
- package/dist/tools/query-metrics.d.ts +1 -1
- package/dist/tools/query-metrics.js +19 -6
- package/dist/tools/query-traces.d.ts +47 -0
- package/dist/tools/query-traces.js +145 -0
- package/dist/tools/query-traces.test.d.ts +1 -0
- package/dist/tools/query-traces.test.js +110 -0
- package/dist/tools/registry-names.d.ts +35 -0
- package/dist/tools/registry-names.js +54 -0
- package/dist/tools/registry-names.test.d.ts +1 -0
- package/dist/tools/registry-names.test.js +61 -0
- package/dist/tools/topology.d.ts +3 -3
- package/dist/tools/topology.js +10 -6
- package/dist/topology/merge.d.ts +22 -0
- package/dist/topology/merge.js +178 -0
- package/dist/topology/merge.test.d.ts +1 -0
- package/dist/topology/merge.test.js +110 -0
- package/dist/transport/sessionStore.d.ts +66 -0
- package/dist/transport/sessionStore.js +138 -0
- package/dist/transport/sessionStore.test.d.ts +1 -0
- package/dist/transport/sessionStore.test.js +118 -0
- package/dist/transport/websocket.d.ts +35 -0
- package/dist/transport/websocket.js +133 -0
- package/dist/transport/websocket.test.d.ts +1 -0
- package/dist/transport/websocket.test.js +124 -0
- package/dist/types.d.ts +51 -0
- package/dist/ui/index.html +1729 -100
- package/package.json +13 -3
|
@@ -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
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SignalType, ConnectorHealth, ServiceInfo, MetricInfo, MetricQuery, MetricResult, LogQuery, LogResult, SourceConfig, MetricDefinition, Resource, Edge, TopologySnapshot, TopologyChangeListener } from "../types.js";
|
|
1
|
+
import type { SignalType, ConnectorHealth, ServiceInfo, MetricInfo, MetricQuery, MetricResult, LogQuery, LogResult, TraceQuery, TraceResult, SourceConfig, MetricDefinition, Resource, Edge, TopologySnapshot, TopologyChangeListener } from "../types.js";
|
|
2
2
|
export interface ObservabilityConnector {
|
|
3
3
|
readonly name: string;
|
|
4
4
|
readonly type: string;
|
|
@@ -14,6 +14,10 @@ export interface ObservabilityConnector {
|
|
|
14
14
|
listAvailableMetrics?(service: string): Promise<MetricInfo[]>;
|
|
15
15
|
queryMetrics?(params: MetricQuery): Promise<MetricResult>;
|
|
16
16
|
queryLogs?(params: LogQuery): Promise<LogResult>;
|
|
17
|
+
/** Optional traces capability — Tempo / Jaeger / OTLP backends
|
|
18
|
+
* implement this. The MCP `query_traces` tool fans out to every
|
|
19
|
+
* connector that has it. */
|
|
20
|
+
queryTraces?(params: TraceQuery): Promise<TraceResult>;
|
|
17
21
|
/** Current in-memory resource list. Should be O(1) — backed by the watch cache. */
|
|
18
22
|
listResources?(): Promise<Resource[]>;
|
|
19
23
|
/** Current in-memory edge list. Should be O(1) — backed by the watch cache. */
|
|
@@ -23,9 +23,11 @@ export class PluginLoader {
|
|
|
23
23
|
pluginsDir;
|
|
24
24
|
disabled;
|
|
25
25
|
// Fail-closed verification for filesystem plugins. Builtins are part
|
|
26
|
-
// of the trusted image and are never gated. Default
|
|
27
|
-
//
|
|
28
|
-
//
|
|
26
|
+
// of the trusted image and are never gated. Default ON — operators
|
|
27
|
+
// who want to load unsigned filesystem plugins must opt out with
|
|
28
|
+
// VERIFY_PLUGINS=false. Without a trust root configured, no
|
|
29
|
+
// filesystem plugins load (only builtins), so the demo and any
|
|
30
|
+
// deployment without /app/plugins is unaffected.
|
|
29
31
|
verify;
|
|
30
32
|
trustRootPath;
|
|
31
33
|
trustRoot;
|
|
@@ -39,7 +41,7 @@ export class PluginLoader {
|
|
|
39
41
|
.map((s) => s.trim())
|
|
40
42
|
.filter(Boolean);
|
|
41
43
|
this.disabled = new Set([...(opts.disabled ?? []), ...envDisabled]);
|
|
42
|
-
this.verify = opts.verify ??
|
|
44
|
+
this.verify = opts.verify ?? !/^(0|false|no|off)$/i.test(process.env.VERIFY_PLUGINS ?? "true");
|
|
43
45
|
this.trustRootPath = opts.trustRoot ?? process.env.PLUGIN_TRUST_ROOT;
|
|
44
46
|
}
|
|
45
47
|
async load() {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { PluginLoader } from "./loader.js";
|
|
7
|
+
function tmp() {
|
|
8
|
+
return mkdtempSync(join(tmpdir(), "loader-default-"));
|
|
9
|
+
}
|
|
10
|
+
function withEnv(overrides, fn) {
|
|
11
|
+
const saved = {};
|
|
12
|
+
for (const k of Object.keys(overrides)) {
|
|
13
|
+
saved[k] = process.env[k];
|
|
14
|
+
if (overrides[k] === undefined)
|
|
15
|
+
delete process.env[k];
|
|
16
|
+
else
|
|
17
|
+
process.env[k] = overrides[k];
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
fn();
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
for (const k of Object.keys(saved)) {
|
|
24
|
+
if (saved[k] === undefined)
|
|
25
|
+
delete process.env[k];
|
|
26
|
+
else
|
|
27
|
+
process.env[k] = saved[k];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
test("PluginLoader: VERIFY_PLUGINS defaults to ON when env var unset", () => {
|
|
32
|
+
withEnv({ VERIFY_PLUGINS: undefined, PLUGIN_TRUST_ROOT: undefined }, () => {
|
|
33
|
+
const loader = new PluginLoader({ pluginsDir: tmp() });
|
|
34
|
+
assert.equal(loader["verify"], true, "verify default should be true (fail-closed)");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
test("PluginLoader: VERIFY_PLUGINS=false opts out explicitly", () => {
|
|
38
|
+
withEnv({ VERIFY_PLUGINS: "false" }, () => {
|
|
39
|
+
const loader = new PluginLoader({ pluginsDir: tmp() });
|
|
40
|
+
assert.equal(loader["verify"], false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
test("PluginLoader: VERIFY_PLUGINS=0 / no / off also opt out", () => {
|
|
44
|
+
for (const v of ["0", "no", "off", "FALSE", "Off"]) {
|
|
45
|
+
withEnv({ VERIFY_PLUGINS: v }, () => {
|
|
46
|
+
const loader = new PluginLoader({ pluginsDir: tmp() });
|
|
47
|
+
assert.equal(loader["verify"], false, `value ${v} should disable verify`);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
test("PluginLoader: VERIFY_PLUGINS=true / 1 / yes keep verify on", () => {
|
|
52
|
+
for (const v of ["true", "1", "yes", "TRUE", "Yes"]) {
|
|
53
|
+
withEnv({ VERIFY_PLUGINS: v }, () => {
|
|
54
|
+
const loader = new PluginLoader({ pluginsDir: tmp() });
|
|
55
|
+
assert.equal(loader["verify"], true);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
test("PluginLoader: opts.verify overrides env var", () => {
|
|
60
|
+
withEnv({ VERIFY_PLUGINS: "false" }, () => {
|
|
61
|
+
const onLoader = new PluginLoader({ pluginsDir: tmp(), verify: true });
|
|
62
|
+
assert.equal(onLoader["verify"], true);
|
|
63
|
+
});
|
|
64
|
+
withEnv({ VERIFY_PLUGINS: "true" }, () => {
|
|
65
|
+
const offLoader = new PluginLoader({ pluginsDir: tmp(), verify: false });
|
|
66
|
+
assert.equal(offLoader["verify"], false);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
test("PluginLoader.load(): with verify on + no trust root → builtins still load, filesystem skipped", async () => {
|
|
70
|
+
withEnv({ VERIFY_PLUGINS: undefined, PLUGIN_TRUST_ROOT: undefined }, async () => {
|
|
71
|
+
const loader = new PluginLoader({ pluginsDir: tmp() });
|
|
72
|
+
await loader.load();
|
|
73
|
+
const names = loader.supportedTypes();
|
|
74
|
+
assert.ok(names.includes("prometheus"), "prometheus builtin must remain available");
|
|
75
|
+
assert.ok(names.includes("loki"), "loki builtin must remain available");
|
|
76
|
+
assert.ok(names.includes("kubernetes"), "kubernetes builtin must remain available");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -77,27 +77,45 @@ describe("PrometheusConnector", () => {
|
|
|
77
77
|
});
|
|
78
78
|
});
|
|
79
79
|
describe("buildQuery", () => {
|
|
80
|
-
|
|
80
|
+
// buildQuery is private async and returns `{ promql, label, candidate }`.
|
|
81
|
+
// To keep these tests off the network, every case uses a user-
|
|
82
|
+
// override metric — that short-circuits the candidate-probe path
|
|
83
|
+
// (which would otherwise call Prometheus to pick the best variant
|
|
84
|
+
// and resolveServiceLabel to discover the right scoping label).
|
|
85
|
+
// The label / candidate fields are exercised via the public
|
|
86
|
+
// queryMetrics path elsewhere.
|
|
87
|
+
const fakeSource = { name: "test", type: "prometheus", url: "http://localhost:9090", enabled: true };
|
|
88
|
+
it("replaces {{service}} placeholder in user-defined metrics", async () => {
|
|
81
89
|
const connector = new PrometheusConnector();
|
|
82
|
-
await connector.connect({
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
90
|
+
await connector.connect({
|
|
91
|
+
...fakeSource,
|
|
92
|
+
metrics: [{ name: "cpu", query: 'cpu_usage{svc="{{service}}"}', unit: "%", description: "CPU" }],
|
|
93
|
+
});
|
|
94
|
+
const { promql } = await proto.buildQuery.call(connector, "payment-service", "cpu");
|
|
95
|
+
assert.ok(promql.includes("payment-service"));
|
|
96
|
+
assert.ok(!promql.includes("{{service}}"));
|
|
97
|
+
});
|
|
98
|
+
it("respects an explicit {{service}} substitution outside the {{selector}} sugar", async () => {
|
|
99
|
+
// Different from the other two: the override here uses {{service}}
|
|
100
|
+
// directly inside a hand-written selector (no {{selector}} sugar).
|
|
101
|
+
// Confirms the substitution applies to the raw template, not only
|
|
102
|
+
// through the label-resolver path.
|
|
88
103
|
const connector = new PrometheusConnector();
|
|
89
|
-
await connector.connect({
|
|
90
|
-
|
|
91
|
-
|
|
104
|
+
await connector.connect({
|
|
105
|
+
...fakeSource,
|
|
106
|
+
metrics: [{ name: "explicit_selector", query: 'explicit_metric{job="{{service}}"}', unit: "", description: "" }],
|
|
107
|
+
});
|
|
108
|
+
const { promql } = await proto.buildQuery.call(connector, "my-svc", "explicit_selector");
|
|
109
|
+
assert.equal(promql, 'explicit_metric{job="my-svc"}');
|
|
92
110
|
});
|
|
93
111
|
it("uses custom metrics from source config", async () => {
|
|
94
112
|
const connector = new PrometheusConnector();
|
|
95
113
|
await connector.connect({
|
|
96
|
-
|
|
114
|
+
...fakeSource,
|
|
97
115
|
metrics: [{ name: "custom", query: 'my_custom_metric{svc="{{service}}"}', unit: "ops", description: "Custom" }],
|
|
98
116
|
});
|
|
99
|
-
const
|
|
100
|
-
assert.equal(
|
|
117
|
+
const { promql } = await proto.buildQuery.call(connector, "api", "custom");
|
|
118
|
+
assert.equal(promql, 'my_custom_metric{svc="api"}');
|
|
101
119
|
});
|
|
102
120
|
});
|
|
103
121
|
});
|
|
@@ -17,5 +17,18 @@ export declare class ConnectorRegistry {
|
|
|
17
17
|
getAll(): ObservabilityConnector[];
|
|
18
18
|
getByName(name: string): ObservabilityConnector | undefined;
|
|
19
19
|
getBySignal(signal: SignalType): ObservabilityConnector[];
|
|
20
|
+
/** Connectors visible to the named tenant: every source whose
|
|
21
|
+
* config.tenant matches OR is unset (global). Unset = available
|
|
22
|
+
* everywhere — keeps single-tenant deployments untouched.
|
|
23
|
+
* Anonymous traffic / the agent / internal callers can pass
|
|
24
|
+
* the DEFAULT_TENANT sentinel and see exactly what the default-
|
|
25
|
+
* tenant operator sees. */
|
|
26
|
+
getByTenant(tenant: string): ObservabilityConnector[];
|
|
27
|
+
/** Same as `getByName`, but enforces the tenant gate: a source
|
|
28
|
+
* whose config.tenant is set and differs from the calling tenant
|
|
29
|
+
* returns undefined — indistinguishable from "no such source",
|
|
30
|
+
* per the rest of the tenancy layer (no cross-tenant existence
|
|
31
|
+
* leak). Unset source tenant = global, always resolves. */
|
|
32
|
+
getByNameForTenant(name: string, tenant: string): ObservabilityConnector | undefined;
|
|
20
33
|
healthCheckAll(): Promise<Record<string, ConnectorHealth>>;
|
|
21
34
|
}
|
|
@@ -80,6 +80,36 @@ export class ConnectorRegistry {
|
|
|
80
80
|
getBySignal(signal) {
|
|
81
81
|
return this.getAll().filter((c) => c.signalType === signal);
|
|
82
82
|
}
|
|
83
|
+
/** Connectors visible to the named tenant: every source whose
|
|
84
|
+
* config.tenant matches OR is unset (global). Unset = available
|
|
85
|
+
* everywhere — keeps single-tenant deployments untouched.
|
|
86
|
+
* Anonymous traffic / the agent / internal callers can pass
|
|
87
|
+
* the DEFAULT_TENANT sentinel and see exactly what the default-
|
|
88
|
+
* tenant operator sees. */
|
|
89
|
+
getByTenant(tenant) {
|
|
90
|
+
const out = [];
|
|
91
|
+
for (const [name, c] of this.connectors) {
|
|
92
|
+
const cfg = this.sourceConfigs.get(name);
|
|
93
|
+
const srcTenant = cfg?.tenant;
|
|
94
|
+
if (!srcTenant || srcTenant === tenant)
|
|
95
|
+
out.push(c);
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
/** Same as `getByName`, but enforces the tenant gate: a source
|
|
100
|
+
* whose config.tenant is set and differs from the calling tenant
|
|
101
|
+
* returns undefined — indistinguishable from "no such source",
|
|
102
|
+
* per the rest of the tenancy layer (no cross-tenant existence
|
|
103
|
+
* leak). Unset source tenant = global, always resolves. */
|
|
104
|
+
getByNameForTenant(name, tenant) {
|
|
105
|
+
const c = this.connectors.get(name);
|
|
106
|
+
if (!c)
|
|
107
|
+
return undefined;
|
|
108
|
+
const cfg = this.sourceConfigs.get(name);
|
|
109
|
+
if (cfg?.tenant && cfg.tenant !== tenant)
|
|
110
|
+
return undefined;
|
|
111
|
+
return c;
|
|
112
|
+
}
|
|
83
113
|
async healthCheckAll() {
|
|
84
114
|
const results = {};
|
|
85
115
|
for (const [name, connector] of this.connectors) {
|
|
@@ -2,15 +2,21 @@ import { describe, it } from "node:test";
|
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { getSupportedTypes, ConnectorRegistry } from "./registry.js";
|
|
4
4
|
import { DEFAULT_SETTINGS, DEFAULT_HEALTH_THRESHOLDS } from "../config/loader.js";
|
|
5
|
+
import { getPluginLoader } from "./loader.js";
|
|
5
6
|
function makeConfig(sources = []) {
|
|
6
7
|
return { sources, settings: DEFAULT_SETTINGS, healthThresholds: DEFAULT_HEALTH_THRESHOLDS };
|
|
7
8
|
}
|
|
8
9
|
describe("getSupportedTypes", () => {
|
|
9
|
-
it("returns prometheus
|
|
10
|
+
it("returns the builtins (prometheus, loki, kubernetes) after loader.load()", async () => {
|
|
11
|
+
// The PluginLoader registers builtins inside load(), not the
|
|
12
|
+
// constructor — at server boot index.ts awaits load() before any
|
|
13
|
+
// tool registration code runs. Mirror that here so the test
|
|
14
|
+
// reflects the real wiring rather than a transient empty state.
|
|
15
|
+
await getPluginLoader().load();
|
|
10
16
|
const types = getSupportedTypes();
|
|
11
17
|
assert.ok(types.includes("prometheus"));
|
|
12
18
|
assert.ok(types.includes("loki"));
|
|
13
|
-
assert.
|
|
19
|
+
assert.ok(types.includes("kubernetes"));
|
|
14
20
|
});
|
|
15
21
|
});
|
|
16
22
|
describe("ConnectorRegistry", () => {
|
|
@@ -90,4 +96,52 @@ describe("ConnectorRegistry", () => {
|
|
|
90
96
|
assert.deepEqual(results, {});
|
|
91
97
|
});
|
|
92
98
|
});
|
|
99
|
+
describe("getByTenant / getByNameForTenant", () => {
|
|
100
|
+
it("untagged sources are visible to every tenant (pre-E7 single-tenant default)", async () => {
|
|
101
|
+
await getPluginLoader().load();
|
|
102
|
+
const reg = new ConnectorRegistry();
|
|
103
|
+
await reg.initialize(makeConfig([
|
|
104
|
+
// No tenant on either source — both are "global".
|
|
105
|
+
{ name: "prom-global", type: "prometheus", url: "http://p:9090", enabled: true },
|
|
106
|
+
{ name: "loki-global", type: "loki", url: "http://l:3100", enabled: true },
|
|
107
|
+
]));
|
|
108
|
+
const acmeVisible = reg.getByTenant("acme").map((c) => c.name).sort();
|
|
109
|
+
const bigcoVisible = reg.getByTenant("bigco").map((c) => c.name).sort();
|
|
110
|
+
assert.deepEqual(acmeVisible, ["loki-global", "prom-global"]);
|
|
111
|
+
assert.deepEqual(bigcoVisible, ["loki-global", "prom-global"]);
|
|
112
|
+
});
|
|
113
|
+
it("tenant-tagged source is invisible to other tenants", async () => {
|
|
114
|
+
await getPluginLoader().load();
|
|
115
|
+
const reg = new ConnectorRegistry();
|
|
116
|
+
await reg.initialize(makeConfig([
|
|
117
|
+
{ name: "shared", type: "prometheus", url: "http://p:9090", enabled: true },
|
|
118
|
+
{ name: "acme-only", type: "loki", url: "http://l:3100", enabled: true, tenant: "acme" },
|
|
119
|
+
]));
|
|
120
|
+
assert.deepEqual(reg.getByTenant("acme").map((c) => c.name).sort(), ["acme-only", "shared"]);
|
|
121
|
+
// bigco sees only the shared source — the acme-only one is hidden.
|
|
122
|
+
assert.deepEqual(reg.getByTenant("bigco").map((c) => c.name).sort(), ["shared"]);
|
|
123
|
+
});
|
|
124
|
+
it("getByNameForTenant returns undefined on cross-tenant probe (no existence leak)", async () => {
|
|
125
|
+
await getPluginLoader().load();
|
|
126
|
+
const reg = new ConnectorRegistry();
|
|
127
|
+
await reg.initialize(makeConfig([
|
|
128
|
+
{ name: "acme-loki", type: "loki", url: "http://l:3100", enabled: true, tenant: "acme" },
|
|
129
|
+
]));
|
|
130
|
+
// Within tenant: resolves.
|
|
131
|
+
assert.ok(reg.getByNameForTenant("acme-loki", "acme"));
|
|
132
|
+
// Cross-tenant: undefined — indistinguishable from "no such source".
|
|
133
|
+
assert.equal(reg.getByNameForTenant("acme-loki", "bigco"), undefined);
|
|
134
|
+
// Unknown name in own tenant: also undefined.
|
|
135
|
+
assert.equal(reg.getByNameForTenant("nope", "acme"), undefined);
|
|
136
|
+
});
|
|
137
|
+
it("a source whose tenant is unset resolves for every tenant via getByNameForTenant", async () => {
|
|
138
|
+
await getPluginLoader().load();
|
|
139
|
+
const reg = new ConnectorRegistry();
|
|
140
|
+
await reg.initialize(makeConfig([
|
|
141
|
+
{ name: "global", type: "prometheus", url: "http://p:9090", enabled: true },
|
|
142
|
+
]));
|
|
143
|
+
assert.ok(reg.getByNameForTenant("global", "acme"));
|
|
144
|
+
assert.ok(reg.getByNameForTenant("global", "bigco"));
|
|
145
|
+
});
|
|
146
|
+
});
|
|
93
147
|
});
|
package/dist/context.d.ts
CHANGED
|
@@ -27,6 +27,12 @@ export interface RequestContext {
|
|
|
27
27
|
* "default" for anonymous principals + missing-tenant credentials,
|
|
28
28
|
* preserving the single-namespace behaviour of pre-E7 deployments. */
|
|
29
29
|
tenant: string;
|
|
30
|
+
/** When set, the /mcp tools/list response is filtered to this
|
|
31
|
+
* allow-list. Resolved from the active credential's bound Product
|
|
32
|
+
* (OMCP_KEY_PRODUCTS) against the catalogue at request entry.
|
|
33
|
+
* Anonymous + Product-less credentials leave this unset and see
|
|
34
|
+
* every registered tool. */
|
|
35
|
+
allowedTools?: string[];
|
|
30
36
|
/** Correlates all tool calls within one transport request/session. */
|
|
31
37
|
correlationId: string;
|
|
32
38
|
}
|
|
@@ -36,4 +42,30 @@ export declare function defaultContext(): RequestContext;
|
|
|
36
42
|
export declare function principalContext(principalId: string, allowedSources?: string[], opts?: {
|
|
37
43
|
allowBypassRedaction?: boolean;
|
|
38
44
|
tenant?: string;
|
|
45
|
+
allowedTools?: string[];
|
|
39
46
|
}): RequestContext;
|
|
47
|
+
/** Context for an authenticated management-plane (browser / OIDC /
|
|
48
|
+
* basic-auth) request. The session-derived tenant flows into tool
|
|
49
|
+
* handlers exactly like the MCP-credential path, so a viewer in
|
|
50
|
+
* tenant Acme reading /api/services through the dashboard sees the
|
|
51
|
+
* same service set as an /mcp client bound to Acme. Anonymous mode
|
|
52
|
+
* (no session) → behaves like defaultContext(). */
|
|
53
|
+
export declare function sessionContext(session: {
|
|
54
|
+
sub?: string;
|
|
55
|
+
name?: string;
|
|
56
|
+
tenant?: string;
|
|
57
|
+
} | undefined): RequestContext;
|
|
58
|
+
/** Decide whether a given tool name is accessible under the active
|
|
59
|
+
* Product binding. Pure helper so the registration site stays
|
|
60
|
+
* declarative and the filtering policy is unit-testable in isolation.
|
|
61
|
+
*
|
|
62
|
+
* Semantics:
|
|
63
|
+
* - undefined allow-list → no Product binding, every tool allowed
|
|
64
|
+
* (anonymous + Product-less credentials — back-compat).
|
|
65
|
+
* - empty allow-list → a Product with no `tools` field. The schema
|
|
66
|
+
* treats this as "all tools allowed", matching the YAML loader's
|
|
67
|
+
* view that an absent / empty list means no restriction.
|
|
68
|
+
* - non-empty → the named tool must appear verbatim.
|
|
69
|
+
* Tool names are compared case-sensitively; the MCP spec is
|
|
70
|
+
* case-sensitive on `name`. */
|
|
71
|
+
export declare function allowsTool(allowedTools: string[] | undefined, toolName: string): boolean;
|
package/dist/context.js
CHANGED
|
@@ -17,6 +17,41 @@ export function principalContext(principalId, allowedSources, opts = {}) {
|
|
|
17
17
|
allowedSources: allowedSources && allowedSources.length > 0 ? allowedSources : undefined,
|
|
18
18
|
allowBypassRedaction: opts.allowBypassRedaction || undefined,
|
|
19
19
|
tenant: normaliseTenant(opts.tenant),
|
|
20
|
+
allowedTools: opts.allowedTools && opts.allowedTools.length > 0 ? opts.allowedTools : undefined,
|
|
20
21
|
correlationId: randomUUID(),
|
|
21
22
|
};
|
|
22
23
|
}
|
|
24
|
+
/** Context for an authenticated management-plane (browser / OIDC /
|
|
25
|
+
* basic-auth) request. The session-derived tenant flows into tool
|
|
26
|
+
* handlers exactly like the MCP-credential path, so a viewer in
|
|
27
|
+
* tenant Acme reading /api/services through the dashboard sees the
|
|
28
|
+
* same service set as an /mcp client bound to Acme. Anonymous mode
|
|
29
|
+
* (no session) → behaves like defaultContext(). */
|
|
30
|
+
export function sessionContext(session) {
|
|
31
|
+
if (!session)
|
|
32
|
+
return defaultContext();
|
|
33
|
+
return {
|
|
34
|
+
principalId: session.sub || session.name || "anonymous",
|
|
35
|
+
auth: "apikey",
|
|
36
|
+
tenant: normaliseTenant(session.tenant),
|
|
37
|
+
correlationId: randomUUID(),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Decide whether a given tool name is accessible under the active
|
|
41
|
+
* Product binding. Pure helper so the registration site stays
|
|
42
|
+
* declarative and the filtering policy is unit-testable in isolation.
|
|
43
|
+
*
|
|
44
|
+
* Semantics:
|
|
45
|
+
* - undefined allow-list → no Product binding, every tool allowed
|
|
46
|
+
* (anonymous + Product-less credentials — back-compat).
|
|
47
|
+
* - empty allow-list → a Product with no `tools` field. The schema
|
|
48
|
+
* treats this as "all tools allowed", matching the YAML loader's
|
|
49
|
+
* view that an absent / empty list means no restriction.
|
|
50
|
+
* - non-empty → the named tool must appear verbatim.
|
|
51
|
+
* Tool names are compared case-sensitively; the MCP spec is
|
|
52
|
+
* case-sensitive on `name`. */
|
|
53
|
+
export function allowsTool(allowedTools, toolName) {
|
|
54
|
+
if (!allowedTools || allowedTools.length === 0)
|
|
55
|
+
return true;
|
|
56
|
+
return allowedTools.includes(toolName);
|
|
57
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|