@certscore/mcp 0.2.15 → 0.2.17

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/server.js CHANGED
@@ -1,9 +1,41 @@
1
- import { CertScoreClient, CertScoreTimeoutError } from "@certscore/sdk";
2
- import { certScoreMcpToolContracts } from "@certscore/api-contracts";
1
+ import { CertScoreClient } from "@certscore/sdk";
2
+ import { certScoreMcpToolContracts, isCanonicalScanId } from "@certscore/api-contracts";
3
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  import { CERTSCORE_MCP_VERSION } from "./version.js";
5
- import { boundEvidencePacket, buildScanBundle, exportFindings, limitPreConsentRows, normalizeDetail, normalizeFormat, paginateFindingList, toInvalidArgumentsToolError, toToolError, toToolResult, withMcpAgentGuidance } from "./tools.js";
6
- const DEFAULT_MCP_SCAN_WAIT_MS = 45_000;
5
+ import { boundEvidencePacket, buildScanBundle, exportFindings, findingListText, limitPreConsentRows, markdownReportText, MAX_EVIDENCE_PACKET_CHARS, normalizeDetail, normalizeFormat, paginateFindingList, preConsentInventoryText, pulseReportText, scanBundleText, scanSiteText, scanStatusText, toInvalidArgumentsToolError, toInvalidScanIdToolError, toToolError, toToolResult, withMcpAgentGuidance, withMcpScanProvenanceGuidance } from "./tools.js";
6
+ const LIGHT_MCP_BUNDLE_RESPONSE_CEILING_BYTES = 25_000;
7
+ const MCP_SCAN_SITE_TARGET_RESPONSE_MS = 11_000;
8
+ const MCP_SCAN_SITE_TARGET_RESPONSE_RESERVE_MS = 250;
9
+ const MCP_SCAN_SITE_PREVIEW_POLL_INTERVAL_MS = 750;
10
+ const MCP_SCAN_SITE_MAX_PREVIEW_WAIT_MS = 10_000;
11
+ function exampleDomainDemoSubstitution(requestedUrl, demoUrl) {
12
+ if (!demoUrl)
13
+ return null;
14
+ try {
15
+ const parsed = new URL(requestedUrl.includes("://") ? requestedUrl : `https://${requestedUrl}`);
16
+ const hostname = parsed.hostname.toLowerCase().replace(/\.$/, "");
17
+ const reserved = ["example.com", "example.net", "example.org"].some((domain) => hostname === domain || hostname.endsWith(`.${domain}`));
18
+ if (!reserved)
19
+ return null;
20
+ return {
21
+ requestedUrl,
22
+ effectiveUrl: demoUrl,
23
+ reason: "iana_example_domain",
24
+ message: "The requested IANA example domain is a documentation placeholder, so CertScore scanned its controlled demonstration site instead. Findings describe the effective URL, not the requested placeholder."
25
+ };
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ function withExampleDomainDemo(value, substitution) {
32
+ return substitution ? { ...value, demoSubstitution: substitution } : value;
33
+ }
34
+ function exampleDomainDemoText(value, substitution) {
35
+ return scanSiteText(value, substitution
36
+ ? [`${substitution.message} Substitution provenance is in structuredContent.`]
37
+ : []);
38
+ }
7
39
  async function retryTransientOriginFailure(operation) {
8
40
  try {
9
41
  return await operation();
@@ -30,6 +62,15 @@ function scanCreationMetadata(value) {
30
62
  upgradeMessage: value.upgradeMessage
31
63
  };
32
64
  }
65
+ function activeScan(value) {
66
+ return value.status === "queued" || value.status === "running" || value.status === "finalizing";
67
+ }
68
+ function hasPreConsentPreview(value) {
69
+ return Boolean(value.preConsentPreview && typeof value.preConsentPreview === "object" && !Array.isArray(value.preConsentPreview));
70
+ }
71
+ function delay(ms) {
72
+ return new Promise((resolve) => setTimeout(resolve, ms));
73
+ }
33
74
  function toolContract(name) {
34
75
  const contract = certScoreMcpToolContracts.find((candidate) => candidate.name === name);
35
76
  if (!contract) {
@@ -43,15 +84,161 @@ function toolContract(name) {
43
84
  annotations: contract.annotations
44
85
  };
45
86
  }
87
+ function boundedTelemetryToken(value, maxLength) {
88
+ return typeof value === "string" && /^[a-zA-Z0-9_.:-]+$/.test(value) && value.length <= maxLength
89
+ ? value
90
+ : null;
91
+ }
92
+ function telemetryResultRecord(result) {
93
+ if (!result || typeof result !== "object" || Array.isArray(result))
94
+ return {};
95
+ const toolResult = result;
96
+ if (toolResult.structuredContent && typeof toolResult.structuredContent === "object" && !Array.isArray(toolResult.structuredContent)) {
97
+ return toolResult.structuredContent;
98
+ }
99
+ const firstText = Array.isArray(toolResult.content)
100
+ ? toolResult.content.find((item) => item && typeof item === "object" && item.type === "text")
101
+ : null;
102
+ if (!firstText || typeof firstText.text !== "string")
103
+ return {};
104
+ try {
105
+ const parsed = JSON.parse(firstText.text);
106
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
107
+ }
108
+ catch {
109
+ return {};
110
+ }
111
+ }
112
+ function telemetryHostname(value) {
113
+ if (typeof value !== "string")
114
+ return null;
115
+ try {
116
+ const parsed = new URL(value.includes("://") ? value : `https://${value}`);
117
+ return parsed.hostname.toLowerCase().replace(/\.$/, "").slice(0, 253) || null;
118
+ }
119
+ catch {
120
+ return null;
121
+ }
122
+ }
123
+ function telemetryUrl(value) {
124
+ if (typeof value !== "string")
125
+ return null;
126
+ try {
127
+ const parsed = new URL(value.includes("://") ? value : `https://${value}`);
128
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
129
+ return null;
130
+ return parsed.origin.slice(0, 512);
131
+ }
132
+ catch {
133
+ return null;
134
+ }
135
+ }
136
+ function requestedTelemetryResource(args) {
137
+ const scanId = boundedTelemetryToken(args.scanId, 128);
138
+ if (scanId)
139
+ return { requestedResource: scanId, requestedResourceType: "scan_id" };
140
+ const jobId = boundedTelemetryToken(args.jobId, 128);
141
+ if (jobId)
142
+ return { requestedResource: jobId, requestedResourceType: "job_id" };
143
+ const url = telemetryUrl(args.url);
144
+ if (url)
145
+ return { requestedResource: url, requestedResourceType: "url" };
146
+ const domain = telemetryHostname(args.domain);
147
+ if (domain)
148
+ return { requestedResource: domain, requestedResourceType: "domain" };
149
+ return { requestedResource: null, requestedResourceType: null };
150
+ }
151
+ function isCertScoreCanaryUrl(value) {
152
+ if (typeof value !== "string")
153
+ return false;
154
+ try {
155
+ const parsed = new URL(/^https?:\/\//i.test(value) ? value : `https://${value}`);
156
+ return parsed.pathname.startsWith("/.well-known/certscore-canary/");
157
+ }
158
+ catch {
159
+ return false;
160
+ }
161
+ }
162
+ export function projectMcpToolInvocationObservation(input) {
163
+ const args = input.args && typeof input.args === "object" && !Array.isArray(input.args)
164
+ ? input.args
165
+ : {};
166
+ const result = telemetryResultRecord(input.result);
167
+ const error = result.error && typeof result.error === "object" && !Array.isArray(result.error)
168
+ ? result.error
169
+ : null;
170
+ const errorCode = boundedTelemetryToken(error?.code ?? result.errorCode, 100);
171
+ const rateLimited = errorCode === "rate_limited" || result.status === "rate_limited";
172
+ const isError = Boolean(input.result?.isError) || Boolean(error);
173
+ const outcome = rateLimited ? "rate_limited" : isError ? "error" : "success";
174
+ const resultScanId = boundedTelemetryToken(result.scanId ?? result.scan_id ?? result.jobId, 128);
175
+ const inputScanId = boundedTelemetryToken(args.scanId, 128);
176
+ const requestedResource = requestedTelemetryResource(args);
177
+ const targetHostname = input.toolName === "certscore_scan_site"
178
+ ? telemetryHostname(args.url)
179
+ : input.toolName === "certscore_get_latest_domain_scan"
180
+ || input.toolName === "certscore_get_latest_domain_pre_consent_cookies_trackers"
181
+ ? telemetryHostname(args.domain)
182
+ : null;
183
+ const isCanary = isCertScoreCanaryUrl(args.url);
184
+ const executionMode = result.executionMode;
185
+ const scanDecision = input.toolName !== "certscore_scan_site"
186
+ ? "not_applicable"
187
+ : outcome !== "success"
188
+ ? "unavailable"
189
+ : result.reused === true || executionMode === "reused_scan"
190
+ ? "reused"
191
+ : result.reused === false || executionMode === "new_scan" || result.quotaConsumed === true
192
+ ? "new"
193
+ : "unavailable";
194
+ return {
195
+ durationMs: Math.max(0, Math.min(Math.round(input.durationMs), 3_600_000)),
196
+ errorCode: rateLimited ? "rate_limited" : errorCode,
197
+ freshness: args.freshness === "refresh" ? "refresh" : input.toolName === "certscore_scan_site" ? "latest" : null,
198
+ isCanary,
199
+ outcome,
200
+ quotaOutcome: rateLimited ? "rate_limited" : "allowed",
201
+ ...requestedResource,
202
+ scanDecision,
203
+ scanFrom: args.scanFrom === "eu_de" || args.scanFrom === "eu_ie" || args.scanFrom === "california"
204
+ ? args.scanFrom
205
+ : result.scanFrom === "eu_de" || result.scanFrom === "eu_ie" || result.scanFrom === "california"
206
+ ? result.scanFrom
207
+ : null,
208
+ scanId: resultScanId ?? inputScanId,
209
+ scanStatus: boundedTelemetryToken(result.status, 64),
210
+ targetHostname,
211
+ toolName: input.toolName,
212
+ transportOutcome: isError ? "mcp_error" : "mcp_result",
213
+ };
214
+ }
215
+ function observeToolInvocation(observer, observation, requestContext) {
216
+ if (!observer)
217
+ return;
218
+ queueMicrotask(() => {
219
+ Promise.resolve().then(() => observer(observation, requestContext)).catch((error) => {
220
+ console.error("[certscore-mcp] telemetry observer failed", {
221
+ errorName: error instanceof Error ? error.name : "UnknownError",
222
+ toolName: observation.toolName,
223
+ });
224
+ });
225
+ });
226
+ }
46
227
  export function createCertScoreMcpServer(options = {}) {
47
- const client = new CertScoreClient({
228
+ const createClient = (forwardedClientIp, anonymousRequesterSession) => new CertScoreClient({
48
229
  apiKey: options.apiKey,
49
230
  baseUrl: options.baseUrl,
50
231
  clientName: "mcp",
51
- forwardedClientIp: options.forwardedClientIp,
232
+ forwardedClientIp,
52
233
  anonymousRequesterSecret: options.anonymousRequesterSecret,
234
+ anonymousSurface: options.anonymousSurface,
235
+ anonymousRequesterSession,
53
236
  timeout: options.timeout
54
237
  });
238
+ const client = createClient(options.forwardedClientIp, options.resolveAnonymousRequesterSession?.());
239
+ const clientForRequest = (extra) => options.resolveForwardedClientIp
240
+ ? createClient(options.resolveForwardedClientIp(extra.requestInfo?.headers ?? {}), options.resolveAnonymousRequesterSession?.())
241
+ : client;
55
242
  const server = new McpServer({
56
243
  name: "certscore",
57
244
  version: CERTSCORE_MCP_VERSION
@@ -61,60 +248,145 @@ export function createCertScoreMcpServer(options = {}) {
61
248
  ? toInvalidArgumentsToolError(message)
62
249
  : sdkCreateToolError(message);
63
250
  const lightTools = new Set(["certscore_scan_site", "certscore_get_scan_status", "certscore_get_scan_bundle"]);
251
+ const scanIdTools = new Set([
252
+ "certscore_explain_finding",
253
+ "certscore_export_findings",
254
+ "certscore_get_evidence",
255
+ "certscore_get_pre_consent_cookies_trackers",
256
+ "certscore_get_report",
257
+ "certscore_get_scan",
258
+ "certscore_get_scan_bundle",
259
+ "certscore_get_scan_status",
260
+ "certscore_list_findings",
261
+ ]);
64
262
  const registerMcpTool = server.registerTool.bind(server);
65
263
  const registerTool = (name, contract, handler) => {
66
264
  if (options.toolProfile === "light" && !lightTools.has(name)) {
67
265
  return;
68
266
  }
69
- registerMcpTool(name, contract, handler);
267
+ const typedHandler = handler;
268
+ registerMcpTool(name, contract, async (input, extra) => {
269
+ const startedAt = Date.now();
270
+ try {
271
+ const scanId = input && typeof input === "object" && !Array.isArray(input)
272
+ ? input.scanId
273
+ : null;
274
+ const result = scanIdTools.has(name) && !isCanonicalScanId(scanId)
275
+ ? toInvalidScanIdToolError()
276
+ : await typedHandler(input, extra);
277
+ observeToolInvocation(options.onToolInvocation, projectMcpToolInvocationObservation({
278
+ args: input,
279
+ durationMs: Date.now() - startedAt,
280
+ result,
281
+ toolName: name,
282
+ }), { headers: extra.requestInfo?.headers ?? null });
283
+ return result;
284
+ }
285
+ catch (error) {
286
+ observeToolInvocation(options.onToolInvocation, {
287
+ ...projectMcpToolInvocationObservation({
288
+ args: input,
289
+ durationMs: Date.now() - startedAt,
290
+ result: { isError: true, structuredContent: { error: { code: "handler_exception" } } },
291
+ toolName: name,
292
+ }),
293
+ errorCode: "handler_exception",
294
+ outcome: "error",
295
+ transportOutcome: "mcp_error",
296
+ }, { headers: extra.requestInfo?.headers ?? null });
297
+ throw error;
298
+ }
299
+ });
70
300
  };
71
- registerTool("certscore_scan_site", toolContract("certscore_scan_site"), async (input) => {
301
+ registerTool("certscore_scan_site", toolContract("certscore_scan_site"), async (input, extra) => {
302
+ const toolStartedAtMs = Date.now();
303
+ const creationStartedAtMs = toolStartedAtMs;
304
+ const client = clientForRequest(extra);
305
+ const demoSubstitution = exampleDomainDemoSubstitution(input.url, options.exampleDomainDemoUrl);
306
+ const effectiveUrl = demoSubstitution?.effectiveUrl ?? input.url;
72
307
  try {
73
- const created = await client.scans.create(input.url, {
308
+ const created = await client.scans.create(effectiveUrl, {
74
309
  freshness: input.freshness ?? "latest",
75
310
  scanFrom: input.scanFrom
76
311
  });
77
- if (input.waitForCompletion === false || created.type === "certscore_scan") {
78
- return toToolResult(withMcpAgentGuidance(created));
79
- }
80
- try {
81
- const completed = await client.scans.wait(created, {
82
- maxWaitMs: Math.min(input.maxWaitSeconds ? input.maxWaitSeconds * 1_000 : DEFAULT_MCP_SCAN_WAIT_MS, DEFAULT_MCP_SCAN_WAIT_MS)
83
- });
84
- return toToolResult(withMcpAgentGuidance({
85
- ...completed,
86
- ...scanCreationMetadata(created)
87
- }));
88
- }
89
- catch (error) {
90
- const scanId = created.scanId ?? created.scan_id;
91
- console.warn(JSON.stringify({
92
- event: "mcp.certscore_scan_site.wait_deferred",
93
- errorName: error instanceof Error ? error.name : "UnknownError",
94
- jobId: created.jobId ?? null,
95
- scanId: scanId ?? null,
96
- status: created.status ?? null
97
- }));
98
- if (error instanceof CertScoreTimeoutError && scanId) {
99
- try {
100
- return toToolResult(withMcpAgentGuidance({
101
- ...(await client.scans.status(scanId)),
102
- ...scanCreationMetadata(created)
103
- }));
104
- }
105
- catch {
106
- // Creation already succeeded. Preserve that stable identity when
107
- // the follow-up status read is briefly unavailable.
312
+ console.log(JSON.stringify({
313
+ event: "mcp.certscore_scan_site.creation_completed",
314
+ durationMs: Date.now() - creationStartedAtMs,
315
+ executionMode: created.executionMode ?? null,
316
+ hasScanId: Boolean(created.scanId ?? created.scan_id),
317
+ reused: created.reused === true,
318
+ status: created.status ?? null,
319
+ }));
320
+ let initialResult = created;
321
+ const stableScanId = typeof created.scanId === "string" && created.scanId
322
+ ? created.scanId
323
+ : typeof created.scan_id === "string" && created.scan_id
324
+ ? created.scan_id
325
+ : typeof created.jobId === "string" && created.jobId
326
+ ? created.jobId
327
+ : null;
328
+ const configuredPreviewWaitMs = options.toolProfile === "light"
329
+ ? Math.min(Math.max(0, options.initialPreConsentPreviewWaitMs ?? 10_000), MCP_SCAN_SITE_MAX_PREVIEW_WAIT_MS)
330
+ : 0;
331
+ const totalRemainingMs = Math.max(0, toolStartedAtMs + MCP_SCAN_SITE_TARGET_RESPONSE_MS - MCP_SCAN_SITE_TARGET_RESPONSE_RESERVE_MS - Date.now());
332
+ const previewWaitMs = Math.min(configuredPreviewWaitMs, totalRemainingMs);
333
+ if (stableScanId && previewWaitMs > 0 && activeScan(initialResult)) {
334
+ const previewWaitStartedAtMs = Date.now();
335
+ const previewDeadlineMs = previewWaitStartedAtMs + previewWaitMs;
336
+ let internalReadCount = 0;
337
+ try {
338
+ while (Date.now() < previewDeadlineMs && activeScan(initialResult) && !hasPreConsentPreview(initialResult)) {
339
+ await delay(Math.min(MCP_SCAN_SITE_PREVIEW_POLL_INTERVAL_MS, previewDeadlineMs - Date.now()));
340
+ const requestRemainingMs = previewDeadlineMs - Date.now();
341
+ if (requestRemainingMs <= 0)
342
+ break;
343
+ const waitAbortController = new AbortController();
344
+ const waitAbortTimer = setTimeout(() => waitAbortController.abort(), requestRemainingMs);
345
+ try {
346
+ internalReadCount += 1;
347
+ const status = await client.scans.status(stableScanId, {
348
+ internalMcpOperation: { operation: "scan_site_wait", scanId: stableScanId },
349
+ signal: waitAbortController.signal,
350
+ });
351
+ initialResult = {
352
+ ...status,
353
+ ...scanCreationMetadata(created),
354
+ };
355
+ }
356
+ finally {
357
+ clearTimeout(waitAbortTimer);
358
+ }
108
359
  }
109
360
  }
110
- // Waiting is a convenience layered on top of scan creation. Once the
111
- // API has accepted a scan, never turn a transient polling or hydration
112
- // failure into an identity-less tool error that encourages callers to
113
- // submit a second, non-idempotent certscore_scan_site request.
114
- return toToolResult(withMcpAgentGuidance(created));
361
+ catch (error) {
362
+ console.warn(JSON.stringify({
363
+ event: "mcp.certscore_scan_site.preview_wait_deferred",
364
+ durationMs: Date.now() - previewWaitStartedAtMs,
365
+ errorName: error instanceof Error ? error.name : "UnknownError",
366
+ hasPreview: hasPreConsentPreview(initialResult),
367
+ internalReadCount,
368
+ scanId: stableScanId,
369
+ }));
370
+ }
371
+ console.log(JSON.stringify({
372
+ event: "mcp.certscore_scan_site.preview_wait_completed",
373
+ durationMs: Date.now() - previewWaitStartedAtMs,
374
+ hasPreview: hasPreConsentPreview(initialResult),
375
+ internalReadCount,
376
+ scanId: stableScanId,
377
+ status: initialResult.status ?? null,
378
+ totalDurationMs: Date.now() - toolStartedAtMs,
379
+ }));
115
380
  }
381
+ const guided = withExampleDomainDemo(withMcpAgentGuidance(initialResult, "unknown", "scan_creation"), demoSubstitution);
382
+ return toToolResult(guided, exampleDomainDemoText(guided, demoSubstitution));
116
383
  }
117
384
  catch (error) {
385
+ console.warn(JSON.stringify({
386
+ event: "mcp.certscore_scan_site.creation_failed",
387
+ durationMs: Date.now() - creationStartedAtMs,
388
+ errorName: error instanceof Error ? error.name : "UnknownError",
389
+ }));
118
390
  return toToolError(error);
119
391
  }
120
392
  });
@@ -126,41 +398,17 @@ export function createCertScoreMcpServer(options = {}) {
126
398
  return toToolError(error);
127
399
  }
128
400
  });
129
- registerTool("certscore_get_scan_status", toolContract("certscore_get_scan_status"), async ({ scanId }) => {
401
+ registerTool("certscore_get_scan_status", toolContract("certscore_get_scan_status"), async ({ scanId }, extra) => {
402
+ const client = clientForRequest(extra);
130
403
  try {
131
- const status = await client.scans.status(scanId);
132
- const needsTerminalHydration = status.status === "completed" || status.status === "completed_limited";
133
- if (needsTerminalHydration) {
134
- try {
135
- const scan = await client.scans.get(scanId);
136
- return toToolResult(withMcpAgentGuidance({
137
- ...status,
138
- type: "certscore_scan_job",
139
- status: scan.status,
140
- domain: scan.domain,
141
- url: scan.url ?? null,
142
- resultDisposition: scan.resultDisposition,
143
- noGo: scan.noGo,
144
- createdAt: scan.createdAt ?? null,
145
- startedAt: scan.startedAt,
146
- completedAt: scan.completedAt,
147
- scanTimeSeconds: scan.scanTimeSeconds,
148
- score: scan.score ?? null,
149
- scoreStatus: scan.scoreStatus,
150
- scoreVersion: scan.scoreVersion ?? null,
151
- scoreUpdatedAt: scan.scoreUpdatedAt ?? null,
152
- riskLevel: scan.riskLevel ?? null,
153
- coverage: scan.coverage ?? null,
154
- reportUrl: scan.links?.report ?? status.reportUrl ?? null,
155
- links: { ...status.links, ...scan.links }
156
- }));
157
- }
158
- catch {
159
- // Preserve the API status response if the terminal scan resource is
160
- // briefly unavailable during eventual-consistency windows.
161
- }
162
- }
163
- return toToolResult(withMcpAgentGuidance(status));
404
+ const internalMcpOperation = { operation: "scan_status", scanId };
405
+ const status = await client.scans.status(scanId, { internalMcpOperation });
406
+ const guided = withMcpScanProvenanceGuidance({
407
+ ...status,
408
+ jobId: undefined,
409
+ scanFrom: status.scanFrom ?? null
410
+ }, "existing_scan_retrieved");
411
+ return toToolResult(guided, scanStatusText(guided));
164
412
  }
165
413
  catch (error) {
166
414
  return toToolError(error);
@@ -178,7 +426,16 @@ export function createCertScoreMcpServer(options = {}) {
178
426
  detail: normalizeDetail(detail),
179
427
  format: "json"
180
428
  });
181
- return toToolResult(result);
429
+ if (typeof result === "string") {
430
+ const guided = withMcpAgentGuidance({
431
+ type: "certscore_pulse_markdown",
432
+ scanId,
433
+ value: result
434
+ }, "existing_scan_retrieved");
435
+ return toToolResult(guided, markdownReportText(guided));
436
+ }
437
+ const guided = withMcpAgentGuidance(result, "existing_scan_retrieved");
438
+ return toToolResult(guided, pulseReportText(guided));
182
439
  }
183
440
  catch (error) {
184
441
  return toToolError(error);
@@ -186,49 +443,65 @@ export function createCertScoreMcpServer(options = {}) {
186
443
  });
187
444
  registerTool("certscore_get_evidence", toolContract("certscore_get_evidence"), async ({ scanId }) => {
188
445
  try {
189
- return toToolResult(boundEvidencePacket(await client.getScan(scanId, { detail: "evidence", format: "json" })));
446
+ const bounded = boundEvidencePacket(await client.getScan(scanId, { detail: "evidence", format: "json" }), MAX_EVIDENCE_PACKET_CHARS - 2_500);
447
+ const guided = withMcpAgentGuidance(bounded, "existing_scan_retrieved");
448
+ return toToolResult(guided, pulseReportText(guided, "CertScore evidence result"));
190
449
  }
191
450
  catch (error) {
192
451
  return toToolError(error);
193
452
  }
194
453
  });
195
- registerTool("certscore_get_scan_bundle", toolContract("certscore_get_scan_bundle"), async ({ scanId, detail = "summary", maxBytes, maxFindings, maxPreConsentRows }) => {
454
+ registerTool("certscore_get_scan_bundle", toolContract("certscore_get_scan_bundle"), async ({ scanId, detail = "summary", maxBytes, maxFindings, maxPreConsentRows }, extra) => {
455
+ const client = clientForRequest(extra);
196
456
  try {
197
- const scan = await retryTransientOriginFailure(() => client.scans.get(scanId));
457
+ const responseCeilingBytes = options.toolProfile === "light"
458
+ ? LIGHT_MCP_BUNDLE_RESPONSE_CEILING_BYTES
459
+ : 200_000;
460
+ const requestedMaxBytes = maxBytes ?? (options.toolProfile === "light"
461
+ ? LIGHT_MCP_BUNDLE_RESPONSE_CEILING_BYTES
462
+ : 50_000);
463
+ const internalMcpOperation = { operation: "scan_bundle", scanId };
464
+ const scan = await retryTransientOriginFailure(() => client.scans.get(scanId, { internalMcpOperation }));
198
465
  if (scan.status === "completed_limited" && scan.resultDisposition === "no_go") {
199
- return toToolResult(buildScanBundle({
466
+ const bundle = buildScanBundle({
200
467
  detail,
201
468
  evidence: null,
202
469
  findings: { type: "certscore_finding_list", scanId, findings: [] },
203
- maxBytes,
470
+ maxBytes: requestedMaxBytes,
204
471
  maxFindings,
205
472
  maxPreConsentRows,
206
473
  preConsentCookiesTrackers: null,
207
474
  report: null,
475
+ requestedMaxBytes,
476
+ responseCeilingBytes,
208
477
  scan
209
- }));
478
+ });
479
+ return toToolResult(bundle, scanBundleText(bundle));
210
480
  }
211
481
  const includeEvidence = detail === "evidence" || detail === "full";
212
482
  const reportDetail = detail === "full" ? "full" : includeEvidence ? "evidence" : "summary";
213
483
  const [report, findings, preConsentCookiesTrackers] = await Promise.all([
214
- retryTransientOriginFailure(() => client.getScan(scanId, { detail: reportDetail, format: "json" })),
215
- retryTransientOriginFailure(() => client.findings.list(scanId)),
216
- includeEvidence
217
- ? retryTransientOriginFailure(() => client.scans.preConsentCookiesTrackers(scanId))
484
+ retryTransientOriginFailure(() => client.getScan(scanId, { detail: reportDetail, format: "json", internalMcpOperation })),
485
+ retryTransientOriginFailure(() => client.findings.list(scanId, { internalMcpOperation })),
486
+ scan.status === "completed"
487
+ ? retryTransientOriginFailure(() => client.scans.preConsentCookiesTrackers(scanId, { internalMcpOperation }))
218
488
  : Promise.resolve(null)
219
489
  ]);
220
490
  const evidence = includeEvidence ? report : null;
221
- return toToolResult(buildScanBundle({
491
+ const bundle = buildScanBundle({
222
492
  detail,
223
493
  evidence,
224
494
  findings,
225
- maxBytes,
495
+ maxBytes: requestedMaxBytes,
226
496
  maxFindings,
227
497
  maxPreConsentRows,
228
498
  preConsentCookiesTrackers,
229
499
  report,
500
+ requestedMaxBytes,
501
+ responseCeilingBytes,
230
502
  scan
231
- }));
503
+ });
504
+ return toToolResult(bundle, scanBundleText(bundle));
232
505
  }
233
506
  catch (error) {
234
507
  return toToolError(error);
@@ -237,7 +510,8 @@ export function createCertScoreMcpServer(options = {}) {
237
510
  registerTool("certscore_export_findings", toolContract("certscore_export_findings"), async ({ scanId }) => {
238
511
  try {
239
512
  const report = await client.getScan(scanId, { detail: "full", format: "json" });
240
- return toToolResult(exportFindings(report));
513
+ const guided = withMcpAgentGuidance(exportFindings(report), "existing_scan_retrieved");
514
+ return toToolResult(guided, findingListText(guided, "Exported canonical projected findings"));
241
515
  }
242
516
  catch (error) {
243
517
  return toToolError(error);
@@ -245,7 +519,8 @@ export function createCertScoreMcpServer(options = {}) {
245
519
  });
246
520
  registerTool("certscore_list_findings", toolContract("certscore_list_findings"), async ({ limit, offset, scanId }) => {
247
521
  try {
248
- return toToolResult(paginateFindingList(await client.findings.list(scanId), { limit, offset }));
522
+ const guided = withMcpAgentGuidance(paginateFindingList(await client.findings.list(scanId), { limit, offset }), "existing_scan_retrieved");
523
+ return toToolResult(guided, findingListText(guided));
249
524
  }
250
525
  catch (error) {
251
526
  return toToolError(error);
@@ -253,7 +528,8 @@ export function createCertScoreMcpServer(options = {}) {
253
528
  });
254
529
  registerTool("certscore_get_pre_consent_cookies_trackers", toolContract("certscore_get_pre_consent_cookies_trackers"), async ({ maxRows, scanId }) => {
255
530
  try {
256
- return toToolResult(limitPreConsentRows(await client.scans.preConsentCookiesTrackers(scanId), { maxRows }));
531
+ const guided = withMcpAgentGuidance(limitPreConsentRows(await client.scans.preConsentCookiesTrackers(scanId), { maxRows }), "existing_scan_retrieved");
532
+ return toToolResult(guided, preConsentInventoryText(guided));
257
533
  }
258
534
  catch (error) {
259
535
  return toToolError(error);
@@ -277,7 +553,8 @@ export function createCertScoreMcpServer(options = {}) {
277
553
  });
278
554
  registerTool("certscore_get_latest_domain_pre_consent_cookies_trackers", toolContract("certscore_get_latest_domain_pre_consent_cookies_trackers"), async ({ domain, maxRows, scanFrom }) => {
279
555
  try {
280
- return toToolResult(limitPreConsentRows(await client.domains.latestPreConsentCookiesTrackers(domain, { scanFrom }), { maxRows }));
556
+ const guided = withMcpAgentGuidance(limitPreConsentRows(await client.domains.latestPreConsentCookiesTrackers(domain, { scanFrom }), { maxRows }), "existing_scan_retrieved");
557
+ return toToolResult(guided, preConsentInventoryText(guided));
281
558
  }
282
559
  catch (error) {
283
560
  return toToolError(error);
package/dist/tools.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
2
  import { type FindingList, type JobStatus, type PreConsentCookiesTrackers, type PulseDetail, type PulseFormat, type PulseResult, type ScanResource, type TopFinding } from "@certscore/sdk";
3
3
  export declare const MAX_EVIDENCE_PACKET_CHARS = 250000;
4
+ type McpPollGuidanceContext = "scan_creation" | "scan_status" | "upstream";
5
+ type ScanProvenanceMode = "new_scan_started" | "existing_completed_scan_reused" | "existing_scan_retrieved" | "unknown";
4
6
  type ActionableError = {
5
7
  code: string;
6
8
  message: string;
@@ -10,10 +12,22 @@ type ActionableError = {
10
12
  field?: string;
11
13
  mcpCode?: number;
12
14
  };
13
- export declare function toToolResult(payload: unknown): CallToolResult;
15
+ export declare function toToolResult(payload: unknown, text?: string): CallToolResult;
14
16
  export declare function toToolError(error: unknown): CallToolResult;
15
17
  export declare function toInvalidArgumentsToolError(errorMessage: string): CallToolResult;
16
- export declare function withMcpAgentGuidance<T extends Record<string, any>>(value: T): T & {
18
+ export declare function toInvalidScanIdToolError(): CallToolResult;
19
+ export declare function withMcpAgentGuidance<T extends Record<string, any>>(value: T, fallbackProvenanceMode?: ScanProvenanceMode, pollGuidanceContext?: McpPollGuidanceContext): T & {
20
+ error: ActionableError | null;
21
+ observationOnlyDisclaimer: string;
22
+ };
23
+ export declare function withMcpScanProvenanceGuidance(value: Record<string, any>, fallbackProvenanceMode: ScanProvenanceMode): {
24
+ interpretationGuidance: {
25
+ scoreLabel: "CertScore score";
26
+ observableSignalsOnly: true;
27
+ doNotInferUnobservedTechnologies: true;
28
+ doNotInferLegalComplianceStatus: true;
29
+ statement: string;
30
+ };
17
31
  error: ActionableError | null;
18
32
  observationOnlyDisclaimer: string;
19
33
  };
@@ -51,6 +65,13 @@ export declare function paginateFindingList<T extends Record<string, unknown>>(p
51
65
  export declare function limitPreConsentRows<T extends Record<string, unknown>>(payload: T, options?: {
52
66
  maxRows?: number;
53
67
  }): T;
68
+ export declare function scanStatusText(value: Record<string, any>): string;
69
+ export declare function scanSiteText(value: Record<string, any>, leadingLines?: string[]): string;
70
+ export declare function findingListText(value: Record<string, any>, label?: string): string;
71
+ export declare function preConsentInventoryText(value: Record<string, any>): string;
72
+ export declare function pulseReportText(value: Record<string, any>, label?: string): string;
73
+ export declare function markdownReportText(value: Record<string, any>): string;
74
+ export declare function scanBundleText(bundle: Record<string, any>): string;
54
75
  export declare function buildScanBundle(input: {
55
76
  detail?: "summary" | "findings" | "evidence" | "full";
56
77
  evidence?: PulseResult | null;
@@ -60,6 +81,8 @@ export declare function buildScanBundle(input: {
60
81
  maxPreConsentRows?: number;
61
82
  preConsentCookiesTrackers?: PreConsentCookiesTrackers | null;
62
83
  report: PulseResult | null;
84
+ requestedMaxBytes?: number;
85
+ responseCeilingBytes?: number;
63
86
  scan: ScanResource;
64
87
  }): Record<string, any>;
65
88
  export declare function explainFinding(report: PulseResult, findingId: string): {