sentinelayer-cli 0.35.4 → 0.36.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/README.md +14 -8
- package/package.json +1 -1
- package/src/commands/mcp.js +79 -0
- package/src/mcp/smoke.js +328 -0
package/README.md
CHANGED
|
@@ -52,14 +52,15 @@ Live MCP entry points:
|
|
|
52
52
|
|
|
53
53
|
- Local stdio server: `sl mcp server run --path .`
|
|
54
54
|
- Hosted token mint: `sl mcp token mint --scope "sessions:read" --ttl-seconds 300 --json`
|
|
55
|
-
- Hosted
|
|
55
|
+
- Hosted MCP paste URL: `https://api.sentinelayer.com/mcp`
|
|
56
|
+
- Hosted resource smoke: `sl mcp smoke --session <senti-session-id> --json`
|
|
56
57
|
- CLI bridge registry: `sl mcp registry init-cli`; credential-bearing commands are blocked from bridge execution
|
|
57
58
|
|
|
58
59
|
First-debug path:
|
|
59
60
|
|
|
60
61
|
1. Run `sl auth status`.
|
|
61
62
|
2. For local MCP, run `sl mcp server validate --file .sentinelayer/mcp/server.<id>.json`.
|
|
62
|
-
3. For hosted MCP,
|
|
63
|
+
3. For hosted MCP, run `sl mcp doctor`, then `sl mcp smoke --session <senti-session-id> --json`.
|
|
63
64
|
4. If a bearer or bridge output is exposed, set `SENTINELAYER_MCP_CLI_BRIDGE_DISABLED=1`, restart the MCP host, revoke the requesting CLI auth session with `sl auth revoke`, and follow [docs/mcp.md](docs/mcp.md#bearer-exposure-response).
|
|
64
65
|
|
|
65
66
|
Architecture map: local stdio runtime and incident response are in
|
|
@@ -692,6 +693,7 @@ The CLI now includes deterministic MCP registry commands:
|
|
|
692
693
|
Use `init-aidenid` to scaffold an Anthropic-compatible tool schema wrapper for AIdenID provisioning APIs, then customize transport/auth before runtime wiring.
|
|
693
694
|
Use `init-aidenid-adapter` to scaffold a deterministic AIdenID provisioning API contract (tool binding -> HTTP path/method -> response field mapping) and cross-check it against the registry with `validate-aidenid-adapter`.
|
|
694
695
|
Use `token mint` to request a short-lived hosted MCP bearer token from the Sentinelayer API. Text output hides the bearer value; `--json` is the explicit operator path that includes `accessToken`. The command is blocked from the generated CLI MCP bridge.
|
|
696
|
+
Use `smoke` to verify the hosted MCP resource without printing the bearer. The canonical remote MCP paste URL is `https://api.sentinelayer.com/mcp`; the token audience/resource value is `https://mcp.sentinelayer.com` and is handled by the OAuth/MCP flow. `sl mcp smoke` works today through the CLI direct-mint path; third-party browser connectors such as Claude.ai still require the OAuth browser-flow and connector-registration work to complete before users can authenticate from that product.
|
|
695
697
|
Use `init-session` plus `server run` for the local stdio Senti MCP server. It exposes session inbox, durable posts, actions/replies/reactions, file locks, and attention requests to local MCP clients. Hosted Claude-web/ChatGPT connectors need a separate HTTPS/OAuth service and are not shipped by the local stdio server. See [docs/mcp.md](docs/mcp.md).
|
|
696
698
|
Use `init-cli` to generate a bridge registry for every `sl` leaf command from Commander metadata. This exposes the full CLI surface as `sl.<command.path>` tool schemas, but execution still requires a bridge-capable MCP host with OAuth/session-seat policy and human approval enforcement. The hosted connector target contract is documented in [docs/mcp-hosted-connector.md](docs/mcp-hosted-connector.md).
|
|
697
699
|
|
|
@@ -700,10 +702,9 @@ MCP operator verification:
|
|
|
700
702
|
1. Local stdio demo: run `sl mcp registry init-session`, then
|
|
701
703
|
`sl mcp server init --id sentinelayer-session --registry-file .sentinelayer/mcp/tool-registry.session-tools.json`,
|
|
702
704
|
then configure the MCP host to execute `sl mcp server run --path .`.
|
|
703
|
-
2. Hosted
|
|
704
|
-
`sl mcp
|
|
705
|
-
|
|
706
|
-
`tools/list` as shown in [docs/mcp.md](docs/mcp.md).
|
|
705
|
+
2. Hosted resource smoke: run `sl mcp doctor`, then
|
|
706
|
+
`sl mcp smoke --session <senti-session-id> --json`. The smoke mints the
|
|
707
|
+
short-lived bearer in memory, calls `/mcp`, and prints only redacted proof.
|
|
707
708
|
3. Kill switch: set `SENTINELAYER_MCP_CLI_BRIDGE_DISABLED=1` and restart the
|
|
708
709
|
MCP host if a CLI bridge or bearer credential is exposed.
|
|
709
710
|
|
|
@@ -728,8 +729,8 @@ sl mcp registry init-cli --json
|
|
|
728
729
|
`.sentinelayer/mcp/server.<id>.json`.
|
|
729
730
|
- Hosted token mint: `sl mcp token mint --scope "sessions:read" --ttl-seconds 300 --json`,
|
|
730
731
|
backed by `POST /api/v1/auth/mcp-token`.
|
|
731
|
-
- Hosted
|
|
732
|
-
|
|
732
|
+
- Hosted MCP paste URL: `https://api.sentinelayer.com/mcp`.
|
|
733
|
+
- Hosted resource smoke: `sl mcp smoke --session <senti-session-id> --json`.
|
|
733
734
|
- Generated CLI bridge registry: `sl mcp registry init-cli`; secret-bearing
|
|
734
735
|
commands such as `sl mcp token mint` are blocked from bridge execution.
|
|
735
736
|
|
|
@@ -740,6 +741,9 @@ Architecture decisions:
|
|
|
740
741
|
OAuth, session-seat binding, and server-side authorization.
|
|
741
742
|
- Hosted bearer minting is API-owned. The CLI requests a token with existing
|
|
742
743
|
auth and never signs, verifies, or derives token claims locally.
|
|
744
|
+
- The hosted endpoint and token audience are deliberately separate today:
|
|
745
|
+
paste `https://api.sentinelayer.com/mcp` into remote MCP clients; the bearer
|
|
746
|
+
audience/resource is `https://mcp.sentinelayer.com` and must remain strict.
|
|
743
747
|
- The generated CLI bridge is fail-closed for credential-bearing commands; use
|
|
744
748
|
direct operator execution for token minting and the documented kill switch for
|
|
745
749
|
containment.
|
|
@@ -1119,6 +1123,7 @@ The CLI now supports a command tree, while keeping slash-command compatibility:
|
|
|
1119
1123
|
- `sentinelayer-cli daemon reliability run|status` and `daemon maintenance status|on|off` operate the midnight synthetic lane and maintenance billboard lifecycle
|
|
1120
1124
|
- `sentinelayer-cli mcp schema|registry|server|bridge ...` manages MCP registry schema, server configs, and VS Code bridge scaffolds
|
|
1121
1125
|
- `sentinelayer-cli mcp token mint --scope "sessions:read" --ttl-seconds 300 --json` mints a short-lived hosted MCP bearer; text mode hides the secret, and generated CLI bridge execution blocks this command
|
|
1126
|
+
- `sentinelayer-cli mcp smoke --session <id> --json` verifies hosted `/mcp` tools and optional Senti room reads without printing bearer values
|
|
1122
1127
|
- `sentinelayer-cli plugin init|validate|list|order` manages plugin/template/policy packs and deterministic load-order governance
|
|
1123
1128
|
- `sentinelayer-cli policy list|use <pack-id>` manages active policy pack selection (`community`, `strict`, `compliance-soc2`, `compliance-hipaa`, plugin packs)
|
|
1124
1129
|
- `sentinelayer-cli ai provision-email` scaffolds and optionally executes AIdenID identity provisioning requests
|
|
@@ -1151,5 +1156,6 @@ Roadmap:
|
|
|
1151
1156
|
- `GitHub CLI not installed`: install `gh` or run manual fallback.
|
|
1152
1157
|
- `Invalid repo format`: use exact `owner/repo`.
|
|
1153
1158
|
- `Missing token in workflow`: ensure `.github/workflows/omar-gate.yml` maps `sentinelayer_token: ${{ secrets.SENTINELAYER_TOKEN }}`.
|
|
1159
|
+
- `INVALID_MCP_TOKEN` on hosted MCP: verify the client is calling `https://api.sentinelayer.com/mcp` and using a fresh token whose audience/resource is `https://mcp.sentinelayer.com`; run `sentinelayer-cli mcp smoke --session <id> --json` for redacted proof.
|
|
1154
1160
|
- `Hosted MCP bearer exposed`: set `SENTINELAYER_MCP_CLI_BRIDGE_DISABLED=1`, restart the MCP host, revoke/rotate the requesting CLI auth session with `sentinelayer-cli auth revoke`, and follow [docs/mcp.md](docs/mcp.md#bearer-exposure-response).
|
|
1155
1161
|
|
package/package.json
CHANGED
package/src/commands/mcp.js
CHANGED
|
@@ -32,6 +32,7 @@ import { requestHostedMcpAccessToken } from "../mcp/token-service.js";
|
|
|
32
32
|
import { buildSentinelayerCliRegistryTemplate } from "../mcp/cli-registry.js";
|
|
33
33
|
import { runMcpStdioServer } from "../mcp/session-stdio-server.js";
|
|
34
34
|
import { runMcpDoctorProbes } from "../mcp/doctor.js";
|
|
35
|
+
import { runHostedMcpSmoke } from "../mcp/smoke.js";
|
|
35
36
|
import { resolveActiveAuthSession } from "../auth/service.js";
|
|
36
37
|
import { resolveOutputRoot } from "../config/service.js";
|
|
37
38
|
|
|
@@ -230,6 +231,84 @@ Docs: https://github.com/mrrCarter/create-sentinelayer/blob/main/docs/mcp.md`,
|
|
|
230
231
|
console.log(pc.gray("Secret value hidden in text output; use --json when you need the bearer value."));
|
|
231
232
|
});
|
|
232
233
|
|
|
234
|
+
mcp
|
|
235
|
+
.command("smoke")
|
|
236
|
+
.description(
|
|
237
|
+
"Smoke the hosted MCP resource with an in-memory bearer; prints only redacted proof"
|
|
238
|
+
)
|
|
239
|
+
.option("--session <id>", "Optional Senti session id to read through sessions.events.list")
|
|
240
|
+
.option("--limit <n>", "Maximum session events to read when --session is set", "5")
|
|
241
|
+
.option(
|
|
242
|
+
"--scope <scopes>",
|
|
243
|
+
"Space- or comma-separated MCP scopes for the smoke token",
|
|
244
|
+
"sessions:read"
|
|
245
|
+
)
|
|
246
|
+
.option("--ttl-seconds <seconds>", "Token lifetime in seconds (API default and maximum are server-configured)")
|
|
247
|
+
.option("--timeout-ms <ms>", "Sentinelayer API/MCP request timeout in milliseconds", String(DEFAULT_REQUEST_TIMEOUT_MS))
|
|
248
|
+
.option("--api-url <url>", "Override Sentinelayer API base URL")
|
|
249
|
+
.option("--no-auto-rotate", "Disable stored CLI token rotation before minting")
|
|
250
|
+
.option("--json", "Emit machine-readable redacted proof")
|
|
251
|
+
.action(async (options, command) => {
|
|
252
|
+
const emitJson = shouldEmitJson(options, command);
|
|
253
|
+
let result;
|
|
254
|
+
try {
|
|
255
|
+
result = await runHostedMcpSmoke({
|
|
256
|
+
cwd: process.cwd(),
|
|
257
|
+
env: process.env,
|
|
258
|
+
explicitApiUrl: options.apiUrl,
|
|
259
|
+
autoRotate: options.autoRotate !== false,
|
|
260
|
+
scope: options.scope,
|
|
261
|
+
ttlSeconds: options.ttlSeconds,
|
|
262
|
+
timeoutMs: options.timeoutMs,
|
|
263
|
+
sessionId: options.session,
|
|
264
|
+
limit: options.limit,
|
|
265
|
+
});
|
|
266
|
+
} catch (error) {
|
|
267
|
+
throw new Error(formatApiError(error));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const payload = { command: "mcp smoke", ...result };
|
|
271
|
+
if (emitJson) {
|
|
272
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
273
|
+
} else {
|
|
274
|
+
console.log(pc.bold(`Hosted MCP smoke — ${result.mcpUrl}`));
|
|
275
|
+
console.log(pc.gray("Bearer value is redacted and never printed by this command."));
|
|
276
|
+
const credentialMetadata = result.token;
|
|
277
|
+
console.log(`Issuer: ${credentialMetadata.issuer}`);
|
|
278
|
+
console.log(`Audience: ${credentialMetadata.audience}`);
|
|
279
|
+
console.log(`Scope: ${credentialMetadata.scope}`);
|
|
280
|
+
if (credentialMetadata.expiresAt) {
|
|
281
|
+
console.log(`Expires at: ${credentialMetadata.expiresAt}`);
|
|
282
|
+
}
|
|
283
|
+
for (const probe of result.probes) {
|
|
284
|
+
const mark = probe.verdict === "PASS" ? pc.green("PASS") : pc.red("FAIL");
|
|
285
|
+
console.log(` [${mark}] ${probe.label} (HTTP ${probe.status})`);
|
|
286
|
+
if (Array.isArray(probe.toolNames) && probe.toolNames.length > 0) {
|
|
287
|
+
console.log(pc.gray(` tools=${probe.toolNames.join(", ")}`));
|
|
288
|
+
}
|
|
289
|
+
if (probe.eventCount !== undefined) {
|
|
290
|
+
console.log(
|
|
291
|
+
pc.gray(
|
|
292
|
+
` events=${probe.eventCount} first=${probe.firstSequenceId ?? "-"} last=${probe.lastSequenceId ?? "-"}`
|
|
293
|
+
)
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
if (probe.detail) {
|
|
297
|
+
console.log(pc.gray(` ${probe.detail}`));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
console.log(
|
|
301
|
+
result.ok
|
|
302
|
+
? pc.green("Hosted MCP smoke passed.")
|
|
303
|
+
: pc.red("Hosted MCP smoke FAILED — inspect the failing probe above.")
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (!result.ok) {
|
|
308
|
+
process.exitCode = 1;
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
233
312
|
mcp
|
|
234
313
|
.command("doctor")
|
|
235
314
|
.description(
|
package/src/mcp/smoke.js
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
|
|
3
|
+
import { DEFAULT_REQUEST_TIMEOUT_MS } from "../auth/http.js";
|
|
4
|
+
import { requestHostedMcpAccessToken } from "./token-service.js";
|
|
5
|
+
|
|
6
|
+
const PASS = "PASS";
|
|
7
|
+
const FAIL = "FAIL";
|
|
8
|
+
|
|
9
|
+
function joinUrl(base, suffix) {
|
|
10
|
+
const normalizedBase = String(base || "").replace(/\/+$/, "");
|
|
11
|
+
const normalizedSuffix = String(suffix || "").replace(/^\/+/, "");
|
|
12
|
+
return `${normalizedBase}/${normalizedSuffix}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizePositiveNumber(rawValue, field, fallbackValue) {
|
|
16
|
+
if (rawValue === undefined || rawValue === null || String(rawValue).trim() === "") {
|
|
17
|
+
return fallbackValue;
|
|
18
|
+
}
|
|
19
|
+
const normalized = Number(rawValue);
|
|
20
|
+
if (!Number.isFinite(normalized) || normalized <= 0) {
|
|
21
|
+
throw new Error(`${field} must be a positive number.`);
|
|
22
|
+
}
|
|
23
|
+
return normalized;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalizeOptionalPositiveInteger(rawValue, field) {
|
|
27
|
+
if (rawValue === undefined || rawValue === null || String(rawValue).trim() === "") {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const normalized = Number(rawValue);
|
|
31
|
+
if (!Number.isFinite(normalized) || normalized <= 0 || !Number.isInteger(normalized)) {
|
|
32
|
+
throw new Error(`${field} must be a positive integer.`);
|
|
33
|
+
}
|
|
34
|
+
return normalized;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeSessionId(rawValue) {
|
|
38
|
+
return String(rawValue || "").trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function redactExactSecret(text, secret) {
|
|
42
|
+
const normalizedSecret = String(secret || "");
|
|
43
|
+
if (!normalizedSecret) {
|
|
44
|
+
return text;
|
|
45
|
+
}
|
|
46
|
+
return String(text).split(normalizedSecret).join("[REDACTED]");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function redactMcpSmokeText(value, secrets = []) {
|
|
50
|
+
let text = String(value || "");
|
|
51
|
+
for (const secret of Array.isArray(secrets) ? secrets : [secrets]) {
|
|
52
|
+
text = redactExactSecret(text, secret);
|
|
53
|
+
}
|
|
54
|
+
return text
|
|
55
|
+
.replace(/\bBearer\s+[A-Za-z0-9._~+/-]+=*/gi, "Bearer [REDACTED]")
|
|
56
|
+
.replace(
|
|
57
|
+
/\b(?:access[_-]?token|refresh[_-]?token|id[_-]?token|api[_-]?key|token|secret|password)\b\s*[:=]\s*["']?[^"',\s}]+/gi,
|
|
58
|
+
(match) => {
|
|
59
|
+
const [prefix = "token"] = match.split(/[:=]/);
|
|
60
|
+
return `${prefix.trim()}=[REDACTED]`;
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function summarizeJsonRpcError(error, secrets = []) {
|
|
66
|
+
if (!error) {
|
|
67
|
+
return "unknown JSON-RPC error";
|
|
68
|
+
}
|
|
69
|
+
const code = error.code === undefined || error.code === null ? "unknown" : String(error.code);
|
|
70
|
+
const message = redactMcpSmokeText(error.message || "JSON-RPC error", secrets);
|
|
71
|
+
return `JSON-RPC error ${code}: ${message}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function postJsonRpc({
|
|
75
|
+
fetchImpl,
|
|
76
|
+
url,
|
|
77
|
+
accessToken,
|
|
78
|
+
payload,
|
|
79
|
+
timeoutMs,
|
|
80
|
+
redactionSecrets = [],
|
|
81
|
+
}) {
|
|
82
|
+
const controller = new AbortController();
|
|
83
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
84
|
+
try {
|
|
85
|
+
const response = await fetchImpl(url, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers: {
|
|
88
|
+
Authorization: `Bearer ${accessToken}`,
|
|
89
|
+
"Content-Type": "application/json",
|
|
90
|
+
},
|
|
91
|
+
body: JSON.stringify(payload),
|
|
92
|
+
signal: controller.signal,
|
|
93
|
+
});
|
|
94
|
+
const text = await response.text().catch(() => "");
|
|
95
|
+
let json = null;
|
|
96
|
+
if (text) {
|
|
97
|
+
try {
|
|
98
|
+
json = JSON.parse(text);
|
|
99
|
+
} catch {
|
|
100
|
+
json = null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
reached: true,
|
|
105
|
+
status: response.status,
|
|
106
|
+
json,
|
|
107
|
+
detail: json ? "" : redactMcpSmokeText(text, redactionSecrets),
|
|
108
|
+
};
|
|
109
|
+
} catch (error) {
|
|
110
|
+
const aborted = Boolean(error) && error.name === "AbortError";
|
|
111
|
+
return {
|
|
112
|
+
reached: false,
|
|
113
|
+
status: 0,
|
|
114
|
+
json: null,
|
|
115
|
+
detail: aborted
|
|
116
|
+
? `timed out after ${timeoutMs}ms`
|
|
117
|
+
: redactMcpSmokeText(
|
|
118
|
+
error instanceof Error ? error.message : String(error || "network error"),
|
|
119
|
+
redactionSecrets,
|
|
120
|
+
),
|
|
121
|
+
};
|
|
122
|
+
} finally {
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function structuredEvents(result) {
|
|
128
|
+
const structured = result?.structuredContent;
|
|
129
|
+
if (structured && Array.isArray(structured.events)) {
|
|
130
|
+
return structured.events;
|
|
131
|
+
}
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Smoke the hosted MCP resource with a short-lived in-memory bearer.
|
|
137
|
+
*
|
|
138
|
+
* This intentionally returns token metadata only. The bearer value is used for
|
|
139
|
+
* the /mcp calls and then discarded so JSON/text output can be pasted into
|
|
140
|
+
* Senti or PRs without exposing credentials.
|
|
141
|
+
*/
|
|
142
|
+
export async function runHostedMcpSmoke({
|
|
143
|
+
cwd = process.cwd(),
|
|
144
|
+
env = process.env,
|
|
145
|
+
explicitApiUrl = "",
|
|
146
|
+
autoRotate = true,
|
|
147
|
+
scope = "sessions:read",
|
|
148
|
+
ttlSeconds = null,
|
|
149
|
+
timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
|
|
150
|
+
sessionId = "",
|
|
151
|
+
limit = 5,
|
|
152
|
+
homeDir,
|
|
153
|
+
requestTokenImpl = requestHostedMcpAccessToken,
|
|
154
|
+
fetchImpl = fetch,
|
|
155
|
+
} = {}) {
|
|
156
|
+
const normalizedTimeoutMs = normalizePositiveNumber(timeoutMs, "timeoutMs", DEFAULT_REQUEST_TIMEOUT_MS);
|
|
157
|
+
const normalizedLimit = normalizeOptionalPositiveInteger(limit, "limit") || 5;
|
|
158
|
+
const normalizedSessionId = normalizeSessionId(sessionId);
|
|
159
|
+
|
|
160
|
+
const minted = await requestTokenImpl({
|
|
161
|
+
cwd,
|
|
162
|
+
env,
|
|
163
|
+
explicitApiUrl,
|
|
164
|
+
autoRotate,
|
|
165
|
+
scope,
|
|
166
|
+
ttlSeconds,
|
|
167
|
+
timeoutMs: normalizedTimeoutMs,
|
|
168
|
+
homeDir,
|
|
169
|
+
});
|
|
170
|
+
const accessToken = String(minted.accessToken || "");
|
|
171
|
+
if (!accessToken) {
|
|
172
|
+
throw new Error("Hosted MCP token mint succeeded but returned no access token.");
|
|
173
|
+
}
|
|
174
|
+
const redactionSecrets = [accessToken];
|
|
175
|
+
|
|
176
|
+
const mcpUrl = joinUrl(minted.apiUrl, "/mcp");
|
|
177
|
+
const probes = [];
|
|
178
|
+
const toolNames = [];
|
|
179
|
+
|
|
180
|
+
const toolsResponse = await postJsonRpc({
|
|
181
|
+
fetchImpl,
|
|
182
|
+
url: mcpUrl,
|
|
183
|
+
accessToken,
|
|
184
|
+
timeoutMs: normalizedTimeoutMs,
|
|
185
|
+
redactionSecrets,
|
|
186
|
+
payload: {
|
|
187
|
+
jsonrpc: "2.0",
|
|
188
|
+
id: "mcp-smoke-tools",
|
|
189
|
+
method: "tools/list",
|
|
190
|
+
params: {},
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
if (!toolsResponse.reached) {
|
|
195
|
+
probes.push({
|
|
196
|
+
id: "tools_list",
|
|
197
|
+
label: "MCP tools/list",
|
|
198
|
+
status: toolsResponse.status,
|
|
199
|
+
verdict: FAIL,
|
|
200
|
+
detail: toolsResponse.detail,
|
|
201
|
+
});
|
|
202
|
+
} else if (toolsResponse.status !== 200) {
|
|
203
|
+
probes.push({
|
|
204
|
+
id: "tools_list",
|
|
205
|
+
label: "MCP tools/list",
|
|
206
|
+
status: toolsResponse.status,
|
|
207
|
+
verdict: FAIL,
|
|
208
|
+
detail: `expected HTTP 200, got HTTP ${toolsResponse.status}`,
|
|
209
|
+
});
|
|
210
|
+
} else if (toolsResponse.json?.error) {
|
|
211
|
+
probes.push({
|
|
212
|
+
id: "tools_list",
|
|
213
|
+
label: "MCP tools/list",
|
|
214
|
+
status: toolsResponse.status,
|
|
215
|
+
verdict: FAIL,
|
|
216
|
+
detail: summarizeJsonRpcError(toolsResponse.json.error, redactionSecrets),
|
|
217
|
+
});
|
|
218
|
+
} else {
|
|
219
|
+
for (const tool of toolsResponse.json?.result?.tools || []) {
|
|
220
|
+
const name = String(tool?.name || "").trim();
|
|
221
|
+
if (name) {
|
|
222
|
+
toolNames.push(name);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
probes.push({
|
|
226
|
+
id: "tools_list",
|
|
227
|
+
label: "MCP tools/list",
|
|
228
|
+
status: toolsResponse.status,
|
|
229
|
+
verdict: toolNames.length > 0 ? PASS : FAIL,
|
|
230
|
+
detail: toolNames.length > 0 ? `${toolNames.length} tool(s) returned` : "tools/list returned no tools",
|
|
231
|
+
toolNames,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (normalizedSessionId) {
|
|
236
|
+
if (!toolNames.includes("sessions.events.list")) {
|
|
237
|
+
probes.push({
|
|
238
|
+
id: "session_events_list",
|
|
239
|
+
label: "MCP sessions.events.list",
|
|
240
|
+
status: 0,
|
|
241
|
+
verdict: FAIL,
|
|
242
|
+
detail: "sessions.events.list tool was not advertised by tools/list",
|
|
243
|
+
sessionId: normalizedSessionId,
|
|
244
|
+
});
|
|
245
|
+
} else {
|
|
246
|
+
const eventsResponse = await postJsonRpc({
|
|
247
|
+
fetchImpl,
|
|
248
|
+
url: mcpUrl,
|
|
249
|
+
accessToken,
|
|
250
|
+
timeoutMs: normalizedTimeoutMs,
|
|
251
|
+
redactionSecrets,
|
|
252
|
+
payload: {
|
|
253
|
+
jsonrpc: "2.0",
|
|
254
|
+
id: "mcp-smoke-events",
|
|
255
|
+
method: "tools/call",
|
|
256
|
+
params: {
|
|
257
|
+
name: "sessions.events.list",
|
|
258
|
+
arguments: {
|
|
259
|
+
sessionId: normalizedSessionId,
|
|
260
|
+
limit: normalizedLimit,
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
if (!eventsResponse.reached) {
|
|
267
|
+
probes.push({
|
|
268
|
+
id: "session_events_list",
|
|
269
|
+
label: "MCP sessions.events.list",
|
|
270
|
+
status: eventsResponse.status,
|
|
271
|
+
verdict: FAIL,
|
|
272
|
+
detail: eventsResponse.detail,
|
|
273
|
+
sessionId: normalizedSessionId,
|
|
274
|
+
});
|
|
275
|
+
} else if (eventsResponse.status !== 200) {
|
|
276
|
+
probes.push({
|
|
277
|
+
id: "session_events_list",
|
|
278
|
+
label: "MCP sessions.events.list",
|
|
279
|
+
status: eventsResponse.status,
|
|
280
|
+
verdict: FAIL,
|
|
281
|
+
detail: `expected HTTP 200, got HTTP ${eventsResponse.status}`,
|
|
282
|
+
sessionId: normalizedSessionId,
|
|
283
|
+
});
|
|
284
|
+
} else if (eventsResponse.json?.error) {
|
|
285
|
+
probes.push({
|
|
286
|
+
id: "session_events_list",
|
|
287
|
+
label: "MCP sessions.events.list",
|
|
288
|
+
status: eventsResponse.status,
|
|
289
|
+
verdict: FAIL,
|
|
290
|
+
detail: summarizeJsonRpcError(eventsResponse.json.error, redactionSecrets),
|
|
291
|
+
sessionId: normalizedSessionId,
|
|
292
|
+
});
|
|
293
|
+
} else {
|
|
294
|
+
const events = structuredEvents(eventsResponse.json?.result);
|
|
295
|
+
const sequences = events
|
|
296
|
+
.map((event) => Number(event?.sequenceId))
|
|
297
|
+
.filter((value) => Number.isFinite(value));
|
|
298
|
+
probes.push({
|
|
299
|
+
id: "session_events_list",
|
|
300
|
+
label: "MCP sessions.events.list",
|
|
301
|
+
status: eventsResponse.status,
|
|
302
|
+
verdict: PASS,
|
|
303
|
+
detail: `${events.length} event(s) returned`,
|
|
304
|
+
sessionId: normalizedSessionId,
|
|
305
|
+
eventCount: events.length,
|
|
306
|
+
firstSequenceId: sequences.length ? Math.min(...sequences) : null,
|
|
307
|
+
lastSequenceId: sequences.length ? Math.max(...sequences) : null,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
apiUrl: minted.apiUrl,
|
|
315
|
+
mcpUrl,
|
|
316
|
+
ok: probes.every((probe) => probe.verdict !== FAIL),
|
|
317
|
+
token: {
|
|
318
|
+
redacted: true,
|
|
319
|
+
tokenType: minted.tokenType,
|
|
320
|
+
expiresIn: minted.expiresIn,
|
|
321
|
+
expiresAt: minted.expiresAt,
|
|
322
|
+
issuer: minted.issuer,
|
|
323
|
+
audience: minted.audience,
|
|
324
|
+
scope: minted.scope,
|
|
325
|
+
},
|
|
326
|
+
probes,
|
|
327
|
+
};
|
|
328
|
+
}
|