securenow 8.16.0 → 8.18.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/esm-detect.js ADDED
@@ -0,0 +1,57 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Is this an ESM app, so the preload must register the ESM loader hook?
5
+ *
6
+ * Node decides a file's module type from the nearest package.json above THE FILE, not from
7
+ * the working directory. Process managers often start apps from another directory (pm2 `cwd`,
8
+ * systemd WorkingDirectory, a Docker WORKDIR, a supervisor that runs each environment from its
9
+ * own folder). Checking only process.cwd() then missed the hook, and every library the app
10
+ * imports (Express, pg, ...) went uninstrumented: no http.route on any request span.
11
+ *
12
+ * ESM when any of these says so: the entry script is .mjs; the nearest package.json above the
13
+ * entry script (process.argv[1], or pm2's pm_exec_path) has "type": "module"; or the working
14
+ * directory's package.json does (the original check, kept for launchers whose argv[1] is a wrapper).
15
+ */
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+
19
+ function readType(pkgPath) {
20
+ try {
21
+ return JSON.parse(fs.readFileSync(pkgPath, 'utf8').replace(/^/, '')).type || 'commonjs';
22
+ } catch (_) {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ function nearestPackageJson(startDir) {
28
+ let dir = startDir;
29
+ for (let i = 0; i < 64; i++) {
30
+ const candidate = path.join(dir, 'package.json');
31
+ if (fs.existsSync(candidate)) return candidate;
32
+ const parent = path.dirname(dir);
33
+ if (parent === dir) return null;
34
+ dir = parent;
35
+ }
36
+ return null;
37
+ }
38
+
39
+ /** @returns {{ esm: boolean, reason: string|null }} */
40
+ function detectEsmApp({ argv = process.argv, cwd = process.cwd(), env = process.env } = {}) {
41
+ const entries = [argv && argv[1], env && env.pm_exec_path]
42
+ .filter((p) => typeof p === 'string' && p && !p.startsWith('-'))
43
+ .map((p) => path.resolve(cwd, p));
44
+
45
+ for (const entry of entries) {
46
+ if (/\.mjs$/i.test(entry)) return { esm: true, reason: `${path.basename(entry)} is an .mjs file` };
47
+ if (/\.cjs$/i.test(entry)) continue;
48
+ const pkgPath = nearestPackageJson(path.dirname(entry));
49
+ if (pkgPath && readType(pkgPath) === 'module') return { esm: true, reason: `${pkgPath} has "type": "module"` };
50
+ }
51
+
52
+ const cwdPkg = path.resolve(cwd, 'package.json');
53
+ if (fs.existsSync(cwdPkg) && readType(cwdPkg) === 'module') return { esm: true, reason: `${cwdPkg} has "type": "module"` };
54
+ return { esm: false, reason: null };
55
+ }
56
+
57
+ module.exports = { detectEsmApp, nearestPackageJson };
package/mcp/catalog.js CHANGED
@@ -353,7 +353,7 @@ const TOOLS = [
353
353
  {
354
354
  name: 'securenow_traces_show',
355
355
  title: 'Show Trace',
356
- description: 'Show spans for a trace id scoped to one or more app keys.',
356
+ description: 'Show spans for a trace id scoped to one or more app keys. Fields identical on every span (service, environment and other resource attributes) come once under shared; empty fields are left out.',
357
357
  scope: 'traces:read',
358
358
  readOnly: true,
359
359
  method: 'GET',
@@ -385,6 +385,25 @@ const TOOLS = [
385
385
  limit: number('Maximum logs to return.', { minimum: 1, maximum: 500 }),
386
386
  }, ['appKeys']),
387
387
  },
388
+ {
389
+ name: 'securenow_logs_errors',
390
+ title: 'Summarize Error Logs',
391
+ description: 'What is failing: error (and fatal) log lines grouped by message, with ids, uuids, IPs and numbers normalized so repeats are one group. Each group: a sample line, how many times, first and last seen, environments, exception type and sample trace ids (pass one to securenow_logs_for_trace or securenow_traces_show). Far cheaper than listing raw logs. includeWarn adds warnings.',
392
+ scope: 'logs:read',
393
+ readOnly: true,
394
+ method: 'GET',
395
+ endpoint: '/logs/errors',
396
+ queryFields: ['appKeys', 'environment', 'since', 'from', 'to', 'includeWarn', 'limit'],
397
+ normalize: { appKeys: normalizeAppKeys },
398
+ inputSchema: objectSchema({
399
+ ...appKeysInput,
400
+ ...environmentInput,
401
+ since: string('Window: 30m, 1h (default), 6h, 2d; or pass from/to.'),
402
+ ...timeRangeInput,
403
+ includeWarn: boolean('Also group warnings.'),
404
+ limit: number('Maximum groups (default 20).', { minimum: 1, maximum: 100 }),
405
+ }, ['appKeys']),
406
+ },
388
407
  {
389
408
  name: 'securenow_logs_for_trace',
390
409
  title: 'Logs For Trace',
@@ -1381,13 +1400,13 @@ const TOOLS = [
1381
1400
  {
1382
1401
  name: 'securenow_alert_rule_draft',
1383
1402
  title: 'Draft an Alert Rule From English',
1384
- description: 'Turn a detection idea in plain English into an alert rule draft for one app, checked against that app\'s data. It reads the app\'s real event types, attribute keys and routes, then picks the kind: instant (conditions checked on every incoming request, alerts within seconds; one request is enough evidence) or periodic (SQL run on a schedule: counts, thresholds, distinct counts, ratios, sequences, baselines, security events, app log lines). Periodic SQL is run once against the data; instant conditions are replayed over the app\'s last 2000 requests, so check.matches shows what would alert today (false positives). Nothing is saved: create it with securenow_alert_rule_create, passing the draft fields. Waits up to 120 seconds.',
1403
+ description: 'Turn a detection idea in plain English into an alert rule draft for one app, checked against that app\'s data. It reads the app\'s real event types, attribute keys and routes, then picks the kind: instant (conditions checked on every incoming request, alerts within seconds; one request is enough evidence) or periodic (SQL run on a schedule: counts, thresholds, distinct counts, ratios, sequences, baselines, security events, app log lines). Periodic SQL is run once against the data; instant conditions are replayed over the app\'s last 2000 requests, so check.matches shows what would alert today (false positives). Nothing is saved: create it with securenow_alert_rule_create, passing the draft fields. To CHANGE an existing rule, pass ruleId and describe the change in text: the draft is the whole rule rewritten (same kind, the rest kept), with `current` showing the rule as it is; apply it with securenow_alert_rule_update (sqlQuery + schedule, or instant {matchMode, conditions, environments}). Waits up to 120 seconds.',
1385
1404
  scope: 'alerts:write',
1386
1405
  readOnly: false,
1387
1406
  confirm: true,
1388
1407
  method: 'POST',
1389
1408
  endpoint: '/alert-rules/draft',
1390
- bodyFields: ['text', 'applicationKey', 'environment', 'kind'],
1409
+ bodyFields: ['text', 'applicationKey', 'environment', 'kind', 'ruleId'],
1391
1410
  poll: {
1392
1411
  tool: 'securenow_alert_rule_draft_status',
1393
1412
  idField: 'draftId',
@@ -1400,8 +1419,9 @@ const TOOLS = [
1400
1419
  applicationKey: string('App key the rule watches; the draft is checked against its data.'),
1401
1420
  environment: string('Only watch this environment (e.g. production). Omit to watch every environment.'),
1402
1421
  kind: string('Optional: instant or periodic. Omit to let the drafter choose.'),
1422
+ ruleId: string('Optional: an existing custom rule to change; text then describes the change. applicationKey defaults to one the rule watches.'),
1403
1423
  ...confirmSchema,
1404
- }, ['text', 'applicationKey', 'confirm', 'reason']),
1424
+ }, ['text', 'confirm', 'reason']),
1405
1425
  },
1406
1426
  {
1407
1427
  name: 'securenow_alert_rule_draft_status',
@@ -1419,13 +1439,13 @@ const TOOLS = [
1419
1439
  {
1420
1440
  name: 'securenow_alert_rule_create',
1421
1441
  title: 'Create Alert Rule (Test Mode)',
1422
- description: 'Create an alert rule. It always starts in test mode: it alerts, but nothing blocks, rate-limits or acts on its alerts until a person promotes it. Instant rule: executionMode "instant" plus instant {matchMode: any|all, conditions: [{field: path|method|user_agent|status_code|request_body|request_headers|client_ip|host, operator, value}]}. Periodic rule: sqlQuery plus schedule {cronExpression}; the SQL must scope every table with the __USER_APP_KEYS__ placeholder, bound time on every table read, and return a column named ip (rows without an ip never alert). Easiest path: securenow_alert_rule_draft, then pass its draft fields here. Write action; requires confirmation.',
1442
+ description: 'Create an alert rule. It always starts in test mode: it alerts, but nothing blocks, rate-limits or acts on its alerts until a person promotes it. Instant rule: executionMode "instant" plus instant {matchMode: any|all, conditions: [{field: path|method|user_agent|status_code|request_body|request_headers|client_ip|host, operator, value}]}. Periodic rule: sqlQuery plus schedule {cronExpression}; the SQL must scope every table with the __USER_APP_KEYS__ placeholder, bound time on every table read, and return a column named ip (the API refuses SQL with no ip column, since it would never alert, unless allowNoIp). A periodic rule runs once right away (firstRunQueued); see securenow_alert_rule_runs. Easiest path: securenow_alert_rule_draft, then pass its draft fields here. Write action; requires confirmation.',
1423
1443
  scope: 'alerts:write',
1424
1444
  readOnly: false,
1425
1445
  confirm: true,
1426
1446
  method: 'POST',
1427
1447
  endpoint: '/alert-rules',
1428
- bodyFields: ['name', 'description', 'nlpQuery', 'category', 'severity', 'applications', 'applicationsAll', 'executionMode', 'sqlQuery', 'schedule', 'instant', 'throttle'],
1448
+ bodyFields: ['name', 'description', 'nlpQuery', 'category', 'severity', 'applications', 'applicationsAll', 'executionMode', 'sqlQuery', 'schedule', 'instant', 'throttle', 'allowNoIp'],
1429
1449
  inputSchema: objectSchema({
1430
1450
  name: string('Rule name.'),
1431
1451
  description: string('What it catches, in a sentence.'),
@@ -1438,7 +1458,8 @@ const TOOLS = [
1438
1458
  sqlQuery: string('Periodic rules: the detection SELECT.'),
1439
1459
  schedule: { type: 'object', additionalProperties: true, description: 'Periodic rules: {cronExpression: "* * * * *", description: "Every minute"}. Default every 15 minutes.' },
1440
1460
  instant: { type: 'object', additionalProperties: true, description: 'Instant rules: {matchMode: "any" | "all", conditions: [{field, operator, value}], environments: ["staging"] (optional; omit to watch every environment)}.' },
1441
- throttle: { type: 'object', additionalProperties: true, description: 'Optional {enabled: true, minutes: 15}: one alert per rule per window.' },
1461
+ throttle: { type: 'object', additionalProperties: true, description: 'Optional {enabled: true, minutes: 15}: one alert per address every N minutes (other addresses still alert).' },
1462
+ allowNoIp: boolean('Periodic rules: save SQL that returns no ip column anyway (it will never notify). Default false.'),
1442
1463
  ...confirmSchema,
1443
1464
  }, ['name', 'confirm', 'reason']),
1444
1465
  },
@@ -1520,25 +1541,28 @@ const TOOLS = [
1520
1541
  {
1521
1542
  name: 'securenow_alert_rule_update',
1522
1543
  title: 'Update Alert Rule',
1523
- description: 'Update alert rule scope/status/schedule/throttle or record a review action. Write action; requires confirmation.',
1544
+ description: 'Update alert rule scope/status/severity/schedule/throttle, a custom rule\'s SQL (sqlQuery, checked like create: scoped, time-bounded, with an ip column) or instant conditions (instant: {matchMode, conditions, environments}; omitted fields such as the instant block stay as they are), or record a review action. reviewNote is kept as the version\'s reason. Write action; requires confirmation.',
1524
1545
  scope: 'alerts:write',
1525
1546
  readOnly: false,
1526
1547
  confirm: true,
1527
1548
  method: 'PUT',
1528
1549
  endpoint: '/alert-rules/:id',
1529
1550
  pathParams: ['id'],
1530
- bodyFields: ['name', 'description', 'status', 'applicationsAll', 'applications', 'schedule', 'throttle', 'alertChannelIds', 'reviewAction', 'reviewNote', 'reviewNotificationId'],
1551
+ bodyFields: ['name', 'description', 'severity', 'status', 'applicationsAll', 'applications', 'schedule', 'throttle', 'sqlQuery', 'instant', 'alertChannelIds', 'reviewAction', 'reviewNote', 'reviewNotificationId'],
1531
1552
  inputSchema: objectSchema({
1532
1553
  id: string('Alert rule id.'),
1533
1554
  name: string('Rule name for custom rules.'),
1534
1555
  description: string('Rule description for custom rules.'),
1556
+ severity: string('critical, high, medium or low (custom rules).'),
1535
1557
  status: string('Active, Disabled, or Paused.'),
1536
1558
  applicationsAll: boolean('Scope rule to all applications.'),
1537
1559
  applications: arrayOfStrings('Application keys when applicationsAll is false.'),
1538
1560
  schedule: { type: 'object', additionalProperties: true, description: 'Schedule patch.' },
1539
- throttle: { type: 'object', additionalProperties: true, description: 'Throttle patch.' },
1561
+ throttle: { type: 'object', additionalProperties: true, description: 'Throttle patch {enabled, minutes}: one alert per address every N minutes.' },
1562
+ sqlQuery: string('Custom periodic rules: the replacement detection SELECT (e.g. from a securenow_alert_rule_draft with ruleId).'),
1563
+ instant: { type: 'object', additionalProperties: true, description: 'Custom instant rules: {matchMode, conditions: [{field, operator, value}], environments}. Fields left out keep their current values.' },
1540
1564
  alertChannelIds: arrayOfStrings('Alert channel ids.'),
1541
- reviewAction: string('Rule review action, e.g. keep_active or saved_new_version.'),
1565
+ reviewAction: string('Rule review action: keep_active (keep it running and stop auto-pausing it) or tuned_version (the rule was tuned; resume it).'),
1542
1566
  reviewNote: string('Review note.'),
1543
1567
  reviewNotificationId: string('Notification id tied to this review.'),
1544
1568
  ...confirmSchema,
@@ -1832,7 +1856,7 @@ const TOOLS = [
1832
1856
  {
1833
1857
  name: 'securenow_alert_rule_test',
1834
1858
  title: 'Test Alert Rule',
1835
- description: 'Start an alert rule test. Pass mode "dry_run" for a read-only query run; "live" (the server default when mode is omitted) inserts synthetic spans into the app\'s telemetry.',
1859
+ description: 'Start an alert rule test. By default (mode "dry_run") it runs the rule\'s query once on the app\'s real data and inserts nothing. Mode "live" first inserts 6 synthetic attack requests into the app\'s telemetry; only ask for it explicitly.',
1836
1860
  scope: 'alerts:write',
1837
1861
  readOnly: false,
1838
1862
  confirm: true,
@@ -1843,7 +1867,7 @@ const TOOLS = [
1843
1867
  inputSchema: objectSchema({
1844
1868
  id: string('Alert rule id.'),
1845
1869
  applicationKey: string('Application key to test.'),
1846
- mode: string('dry_run (read-only, recommended) or live (inserts synthetic spans).'),
1870
+ mode: string('dry_run (default, read-only) or live (inserts 6 synthetic attack requests).'),
1847
1871
  ...confirmSchema,
1848
1872
  }, ['id', 'confirm', 'reason']),
1849
1873
  },
@@ -1880,6 +1904,84 @@ const TOOLS = [
1880
1904
  testId: string('Alert rule test id.'),
1881
1905
  }, ['id', 'testId']),
1882
1906
  },
1907
+ {
1908
+ name: 'securenow_alert_rule_runs',
1909
+ title: 'List Alert Rule Runs',
1910
+ description: 'A rule\'s recent runs, newest first: when it ran (schedule, manual, create or instant trigger), what it returned (rows, addresses), and the outcome: notified, no_rows, throttled (address alerted on within the throttle window), suppressed_blocked (already blocked), suppressed_trusted, no_ip (rows without an address), storm, auto_resolved (dismissed by an exclusion, trusted IP or verified bot), skipped or error. suppressedIps says which addresses were left out and why. Also nextRunAt for periodic rules. Quiet runs are kept 2 days, the rest 14.',
1911
+ scope: 'alerts:read',
1912
+ readOnly: true,
1913
+ method: 'GET',
1914
+ endpoint: '/alert-rules/:id/runs',
1915
+ pathParams: ['id'],
1916
+ queryFields: ['limit', 'since'],
1917
+ inputSchema: objectSchema({
1918
+ id: string('Alert rule id.'),
1919
+ limit: number('How many runs (default 50, max 200).', { minimum: 1, maximum: 200 }),
1920
+ since: string('Only runs since then: 30m, 1h, 2d or an ISO date.'),
1921
+ }, ['id']),
1922
+ },
1923
+ {
1924
+ name: 'securenow_alert_rule_run_now',
1925
+ title: 'Run Alert Rule Now',
1926
+ description: 'Run a periodic (scheduled) rule once now, outside its schedule. It can notify like a scheduled run (test-mode rules alert only). The run appears in securenow_alert_rule_runs within seconds with trigger "manual". Instant rules have nothing to run (they check every request); use securenow_alert_rule_explain instead. Write action; requires confirmation.',
1927
+ scope: 'alerts:write',
1928
+ readOnly: false,
1929
+ confirm: true,
1930
+ method: 'POST',
1931
+ endpoint: '/alert-rules/:id/run',
1932
+ pathParams: ['id'],
1933
+ inputSchema: objectSchema({
1934
+ id: string('Alert rule id (an active periodic rule).'),
1935
+ ...confirmSchema,
1936
+ }, ['id', 'confirm', 'reason']),
1937
+ },
1938
+ {
1939
+ name: 'securenow_alert_rule_explain',
1940
+ title: 'Explain Why a Rule Did or Didn\'t Alert',
1941
+ description: 'Why did (or didn\'t) this rule alert on this address? Checks, in a time window: the rule\'s status, mode, apps and environments; its runs and what each did with the address (notified, throttled, already blocked, trusted, dismissed); the per-address throttle; blocks on the address and the rule that made them; trusted and allowlist entries; notifications; and the address\'s requests, replayed through an instant rule\'s conditions. Returns ordered findings and a one-line verdict, e.g. "Detected at 15:06:31 but not notified: 203.0.113.50 was already blocked by instant rule X".',
1942
+ scope: 'alerts:read',
1943
+ readOnly: true,
1944
+ method: 'GET',
1945
+ endpoint: '/alert-rules/:id/explain',
1946
+ pathParams: ['id'],
1947
+ queryFields: ['ip', 'since'],
1948
+ inputSchema: objectSchema({
1949
+ id: string('Alert rule id.'),
1950
+ ip: string('The address to explain (IPv4 or IPv6).'),
1951
+ since: string('Window start: 30m, 1h (default), 2d or an ISO date; at most 7 days.'),
1952
+ }, ['id', 'ip']),
1953
+ },
1954
+ {
1955
+ name: 'securenow_alert_rule_request_promotion',
1956
+ title: 'Ask a Person to Promote an Alert Rule',
1957
+ description: 'Ask a person to promote a test-mode rule to prod, with why it is ready and the evidence (runs, notifications it caught, false-positive checks). Agents create rules in test mode and cannot approve this themselves: a person approves on the rules page or with `securenow human approve-promotion`, which moves the rule to prod (its instant block, matching automations and the AI pipeline can then act on its alerts). One pending request per rule. Write action; requires confirmation.',
1958
+ scope: 'alerts:write',
1959
+ readOnly: false,
1960
+ confirm: true,
1961
+ method: 'POST',
1962
+ endpoint: '/alert-rules/:id/promotion-requests',
1963
+ pathParams: ['id'],
1964
+ bodyFields: ['reason', 'evidence'],
1965
+ inputSchema: objectSchema({
1966
+ id: string('Alert rule id (a custom rule in test mode).'),
1967
+ evidence: string('What shows it is ready: e.g. runs and their outcomes, notifications it raised on real attacks, how many false positives in how long.'),
1968
+ ...confirmSchema,
1969
+ }, ['id', 'confirm', 'reason']),
1970
+ },
1971
+ {
1972
+ name: 'securenow_alert_rule_promotion_requests',
1973
+ title: 'List Alert Rule Promotion Requests',
1974
+ description: 'Requests to promote test-mode rules to prod and their status (pending, approved, denied), who asked and why, and who decided.',
1975
+ scope: 'alerts:read',
1976
+ readOnly: true,
1977
+ method: 'GET',
1978
+ endpoint: '/alert-rules/promotion-requests',
1979
+ queryFields: ['status', 'limit'],
1980
+ inputSchema: objectSchema({
1981
+ status: string('pending (default), approved, denied or all.'),
1982
+ limit: number('How many (default 50, max 100).', { minimum: 1, maximum: 100 }),
1983
+ }),
1984
+ },
1883
1985
  {
1884
1986
  name: 'securenow_alert_rule_exclusions_list',
1885
1987
  title: 'List Alert Rule Exclusions',
@@ -2656,8 +2758,40 @@ function mcpToolDefinition(tool) {
2656
2758
  };
2657
2759
  }
2658
2760
 
2659
- function listTools() {
2660
- return TOOLS.map(mcpToolDefinition);
2761
+ // Tool profiles: a smaller tool list for an agent that does one job (fewer tools, less context).
2762
+ // Every profile keeps the auth and app tools. `securenow mcp --profile <name>` (or
2763
+ // SECURENOW_MCP_PROFILE) picks one; the default is every tool.
2764
+ const PROFILE_BASE_TOOLS = new Set(['securenow_auth_status', 'securenow_apps_list', 'securenow_apps_info']);
2765
+ const TOOL_PROFILES = {
2766
+ investigator: {
2767
+ description: 'Read-only investigation: notifications, traces, logs, IP intelligence, forensics questions and chat, rule run history and "why didn\'t this alert".',
2768
+ includes: (tool) => tool.readOnly || /^securenow_forensics_(query|chat|chat_confirm)$/.test(tool.name),
2769
+ },
2770
+ detection: {
2771
+ description: 'Write and tune detections: draft, create, test, run and explain alert rules, with the traces, logs, notifications and forensics to check them against.',
2772
+ includes: (tool) => /^securenow_alert_rules?_/.test(tool.name)
2773
+ || /^securenow_(traces|logs|forensics|ip)_/.test(tool.name)
2774
+ || /^securenow_notifications_(list|get|batch_get)$/.test(tool.name),
2775
+ },
2776
+ responder: {
2777
+ description: 'Respond to alerts: notifications, the human action queue and alert reviews, blocks, allowlist, trusted IPs, rate limits, challenges, automations, firewall and IP intelligence.',
2778
+ includes: (tool) => /^securenow_(notifications|human|alert_review|blocklist|allowlist|trusted|rate_limit|challenge|automation|ip|firewall)_/.test(tool.name)
2779
+ || ['securenow_alert_rule_get', 'securenow_alert_rule_runs', 'securenow_alert_rule_explain', 'securenow_traces_list', 'securenow_traces_show', 'securenow_logs_for_trace'].includes(tool.name),
2780
+ },
2781
+ };
2782
+
2783
+ function toolsForProfile(profile) {
2784
+ const name = String(profile || 'all').trim().toLowerCase();
2785
+ if (name === 'all') return TOOLS;
2786
+ const selected = TOOL_PROFILES[name];
2787
+ if (!selected) {
2788
+ throw new Error(`Unknown MCP profile "${profile}". Use one of: all, ${Object.keys(TOOL_PROFILES).join(', ')}`);
2789
+ }
2790
+ return TOOLS.filter((tool) => PROFILE_BASE_TOOLS.has(tool.name) || selected.includes(tool));
2791
+ }
2792
+
2793
+ function listTools(profile) {
2794
+ return toolsForProfile(profile).map(mcpToolDefinition);
2661
2795
  }
2662
2796
 
2663
2797
  function listResources() {
@@ -2897,7 +3031,29 @@ function summarizeRateLimit(r) {
2897
3031
  return { ...r, metadata: undefined, effectiveStatus: r.status === 'active' && expired ? 'expired' : r.status };
2898
3032
  }
2899
3033
 
3034
+ // One trace's spans repeat the same resource fields (service, environment, SDK...) on every span:
3035
+ // keep those once under `shared` and drop empty fields, so a trace costs far less context.
3036
+ function compactSpans(res) {
3037
+ if (!Array.isArray(res?.spans) || res.spans.length < 2) return res;
3038
+ const spans = res.spans;
3039
+ const isEmpty = (v) => v == null || v === '' || (typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0)
3040
+ || (Array.isArray(v) && v.length === 0);
3041
+ const shared = {};
3042
+ for (const key of new Set(spans.flatMap((span) => Object.keys(span || {})))) {
3043
+ const first = JSON.stringify(spans[0]?.[key]);
3044
+ if (spans.every((span) => JSON.stringify(span?.[key]) === first)) shared[key] = spans[0]?.[key];
3045
+ }
3046
+ const keep = (span) => Object.fromEntries(Object.entries(span || {}).filter(([key, value]) => !(key in shared) && !isEmpty(value)));
3047
+ return {
3048
+ ...res,
3049
+ _compact: 'Fields identical on every span are listed once under shared; empty fields are left out.',
3050
+ shared: Object.fromEntries(Object.entries(shared).filter(([, value]) => !isEmpty(value))),
3051
+ spans: spans.map(keep),
3052
+ };
3053
+ }
3054
+
2900
3055
  const SUMMARIZERS = {
3056
+ securenow_traces_show: compactSpans,
2901
3057
  securenow_notifications_list: (res) => (Array.isArray(res?.notifications) ? { ...res, notifications: res.notifications.map(summarizeNotification) } : res),
2902
3058
  securenow_alert_rules_list: (res) => {
2903
3059
  if (!Array.isArray(res?.alertRules)) return res;
@@ -2952,6 +3108,8 @@ module.exports = {
2952
3108
  getTool,
2953
3109
  mcpToolDefinition,
2954
3110
  listTools,
3111
+ toolsForProfile,
3112
+ TOOL_PROFILES,
2955
3113
  listResources,
2956
3114
  listPrompts,
2957
3115
  promptMessages,
package/mcp/server.js CHANGED
@@ -9,6 +9,7 @@ const {
9
9
  RESOURCES,
10
10
  getTool,
11
11
  listTools,
12
+ toolsForProfile,
12
13
  listResources,
13
14
  listPrompts,
14
15
  promptMessages,
@@ -26,6 +27,17 @@ const {
26
27
  const SERVER_NAME = 'securenow';
27
28
  const PROTOCOL_VERSION = '2025-06-18';
28
29
 
30
+ // `securenow mcp --profile investigator|detection|responder` (or SECURENOW_MCP_PROFILE) serves a
31
+ // subset of the tools; the default is every tool.
32
+ const PROFILE = String(process.env.SECURENOW_MCP_PROFILE || 'all').trim().toLowerCase() || 'all';
33
+ let PROFILE_TOOL_NAMES;
34
+ try {
35
+ PROFILE_TOOL_NAMES = new Set(toolsForProfile(PROFILE).map((tool) => tool.name));
36
+ } catch (err) {
37
+ process.stderr.write(`[securenow mcp] ${err.message}\n`);
38
+ process.exit(2);
39
+ }
40
+
29
41
  function write(message) {
30
42
  process.stdout.write(`${JSON.stringify(message)}\n`);
31
43
  }
@@ -201,7 +213,7 @@ function readResource(uri) {
201
213
 
202
214
  const text = resourceContent(resource, {
203
215
  projectConfig: () => jsonText(localAuthStatus()),
204
- toolsCatalog: () => jsonText(listTools().map((tool) => ({
216
+ toolsCatalog: () => jsonText(listTools(PROFILE).map((tool) => ({
205
217
  name: tool.name,
206
218
  description: tool.description,
207
219
  readOnly: tool.annotations?.readOnlyHint === true,
@@ -240,7 +252,7 @@ async function handleRequest(message) {
240
252
  }
241
253
 
242
254
  if (method === 'ping') return ok(id, {});
243
- if (method === 'tools/list') return ok(id, { tools: listTools() });
255
+ if (method === 'tools/list') return ok(id, { tools: listTools(PROFILE) });
244
256
  if (method === 'resources/list') return ok(id, { resources: listResources() });
245
257
  if (method === 'prompts/list') return ok(id, { prompts: listPrompts() });
246
258
 
@@ -262,6 +274,9 @@ async function handleRequest(message) {
262
274
  const args = params.arguments || {};
263
275
  const tool = getTool(name);
264
276
  if (!tool) throw new Error(`Unknown tool: ${name}`);
277
+ if (!PROFILE_TOOL_NAMES.has(name)) {
278
+ throw new Error(`Tool ${name} is not in the "${PROFILE}" MCP profile. Restart the server without --profile (or with another profile) to use it.`);
279
+ }
265
280
 
266
281
  const result = await callApiTool(tool, args);
267
282
  return ok(id, asToolResult({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "securenow",
3
- "version": "8.16.0",
3
+ "version": "8.18.0",
4
4
  "mcpName": "ai.securenow/securenow",
5
5
  "description": "OpenTelemetry instrumentation for Node.js, Next.js, and Nuxt - Send traces and logs to any OTLP-compatible backend",
6
6
  "type": "commonjs",
@@ -11,7 +11,7 @@
11
11
  "securenow-mcp": "mcp/server.js"
12
12
  },
13
13
  "scripts": {
14
- "test": "node test-app-config.js && node test-resolve-ip.js && node test-otel-defaults.js && node test-telemetry.js && node test-legacy-semconv.js && node test-otp-redaction.js && node test-header-redaction.js && node test-firewall-paths.js && node test-rate-limits.js && node test-rate-limit-redis.js && node test-log-format.js && node test-crash-capture.js && node test-sdk-disabled.js && node test-internal-thread.js && node test-package-contents.js"
14
+ "test": "node test-app-config.js && node test-resolve-ip.js && node test-otel-defaults.js && node test-telemetry.js && node test-legacy-semconv.js && node test-otp-redaction.js && node test-header-redaction.js && node test-firewall-paths.js && node test-rate-limits.js && node test-rate-limit-redis.js && node test-log-format.js && node test-crash-capture.js && node test-sdk-disabled.js && node test-internal-thread.js && node test-esm-detect.js && node test-package-contents.js"
15
15
  },
16
16
  "keywords": [
17
17
  "opentelemetry",
@@ -160,6 +160,7 @@
160
160
  "challenge.js",
161
161
  "otel-defaults.js",
162
162
  "internal-thread.js",
163
+ "esm-detect.js",
163
164
  "legacy-semconv.js",
164
165
  "otp-redaction.js",
165
166
  "header-redaction.js",
package/register.js CHANGED
@@ -14,13 +14,9 @@ if (require('./sdk-disabled').sdkDisabled('register')) return;
14
14
  // 1. Auto-register the ESM loader hook so customers never need --import
15
15
  (() => {
16
16
  try {
17
- const fs = require('fs');
18
- const path = require('path');
19
- const pkgPath = path.resolve(process.cwd(), 'package.json');
20
- if (!fs.existsSync(pkgPath)) return;
21
-
22
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8').replace(/^\uFEFF/, ''));
23
- if (pkg.type !== 'module') return;
17
+ // ESM is decided by the entry script's package.json, not only the working directory's
18
+ // (process managers often start apps from another directory). See esm-detect.js.
19
+ if (!require('./esm-detect').detectEsmApp().esm) return;
24
20
 
25
21
  // Already registered via --import?
26
22
  const execArgv = process.execArgv.join(' ');
package/tracing.js CHANGED
@@ -338,19 +338,13 @@ function collectMultipartMeta(request, contentType, sensitiveFields, maxTextFiel
338
338
  // (e.g. Node 18, or require('securenow/tracing') called directly without register.js).
339
339
  (() => {
340
340
  try {
341
- const fs = require('fs');
342
- const path = require('path');
343
- const pkgPath = path.resolve(process.cwd(), 'package.json');
344
- if (fs.existsSync(pkgPath)) {
345
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
346
- if (pkg.type === 'module') {
347
- const execArgv = process.execArgv.join(' ');
348
- const hasCliHook = execArgv.includes('hook.mjs') || execArgv.includes('import-in-the-middle');
349
- const hasModuleRegister = typeof require('node:module').register === 'function';
350
- if (!hasCliHook && !hasModuleRegister) {
351
- console.warn('[securenow] ⚠️ ESM app detected ("type": "module") but no ESM loader hook available.');
352
- console.warn('[securenow] Upgrade to Node >=20.6 (recommended) or add: --import @opentelemetry/instrumentation/hook.mjs');
353
- }
341
+ if (require('./esm-detect').detectEsmApp().esm) {
342
+ const execArgv = process.execArgv.join(' ');
343
+ const hasCliHook = execArgv.includes('hook.mjs') || execArgv.includes('import-in-the-middle');
344
+ const hasModuleRegister = typeof require('node:module').register === 'function';
345
+ if (!hasCliHook && !hasModuleRegister) {
346
+ console.warn('[securenow] ⚠️ ESM app detected ("type": "module") but no ESM loader hook available.');
347
+ console.warn('[securenow] Upgrade to Node >=20.6 (recommended) or add: --import @opentelemetry/instrumentation/hook.mjs');
354
348
  }
355
349
  }
356
350
  } catch (_) {}