@4da/mcp-server 5.0.3 → 6.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.
Files changed (172) hide show
  1. package/CHANGELOG.md +667 -0
  2. package/README.md +66 -35
  3. package/dist/build-staleness.d.ts +35 -0
  4. package/dist/build-staleness.js +95 -0
  5. package/dist/db.d.ts +53 -2
  6. package/dist/db.js +242 -36
  7. package/dist/doctor.js +27 -7
  8. package/dist/embeddings.js +7 -1
  9. package/dist/freshness-markers.d.ts +34 -0
  10. package/dist/freshness-markers.js +74 -0
  11. package/dist/gitignore.d.ts +37 -0
  12. package/dist/gitignore.js +130 -0
  13. package/dist/index.js +127 -44
  14. package/dist/live/bun-lockfile.d.ts +26 -0
  15. package/dist/live/bun-lockfile.js +123 -0
  16. package/dist/live/cargo-platform.d.ts +38 -0
  17. package/dist/live/cargo-platform.js +178 -0
  18. package/dist/live/changelog-classify.d.ts +41 -0
  19. package/dist/live/changelog-classify.js +207 -0
  20. package/dist/live/changelog.d.ts +64 -0
  21. package/dist/live/changelog.js +318 -0
  22. package/dist/live/crates-registry.js +3 -1
  23. package/dist/live/crates-versions.d.ts +47 -0
  24. package/dist/live/crates-versions.js +201 -0
  25. package/dist/live/dependency-set.d.ts +13 -0
  26. package/dist/live/dependency-set.js +95 -0
  27. package/dist/live/dev-scope.d.ts +32 -0
  28. package/dist/live/dev-scope.js +102 -0
  29. package/dist/live/file-signature.d.ts +18 -0
  30. package/dist/live/file-signature.js +43 -0
  31. package/dist/live/go-registry.js +3 -1
  32. package/dist/live/hn-fetcher.js +9 -3
  33. package/dist/live/index.d.ts +105 -2
  34. package/dist/live/index.js +266 -139
  35. package/dist/live/install-state.d.ts +43 -0
  36. package/dist/live/install-state.js +150 -0
  37. package/dist/live/js-lockfile-readers.d.ts +46 -0
  38. package/dist/live/js-lockfile-readers.js +181 -0
  39. package/dist/live/lockfile-parsers-pygo.d.ts +60 -0
  40. package/dist/live/lockfile-parsers-pygo.js +274 -0
  41. package/dist/live/lockfile-parsers.d.ts +69 -0
  42. package/dist/live/lockfile-parsers.js +311 -0
  43. package/dist/live/lockfile-types.d.ts +34 -0
  44. package/dist/live/lockfile-types.js +42 -0
  45. package/dist/live/maintenance.d.ts +33 -0
  46. package/dist/live/maintenance.js +37 -0
  47. package/dist/live/npm-packument.d.ts +47 -0
  48. package/dist/live/npm-packument.js +150 -0
  49. package/dist/live/npm-registry.js +67 -53
  50. package/dist/live/osv-scanner.d.ts +31 -1
  51. package/dist/live/osv-scanner.js +119 -10
  52. package/dist/live/package-archive.d.ts +49 -0
  53. package/dist/live/package-archive.js +186 -0
  54. package/dist/live/pypi-registry.js +3 -1
  55. package/dist/live/registry-health.d.ts +16 -0
  56. package/dist/live/registry-health.js +74 -0
  57. package/dist/live/release-metadata.d.ts +67 -0
  58. package/dist/live/release-metadata.js +16 -0
  59. package/dist/live/resolution.d.ts +43 -0
  60. package/dist/live/resolution.js +62 -0
  61. package/dist/live/semver-precedence.d.ts +30 -0
  62. package/dist/live/semver-precedence.js +83 -0
  63. package/dist/live/severity-scope.d.ts +57 -0
  64. package/dist/live/severity-scope.js +89 -0
  65. package/dist/live/types.d.ts +83 -0
  66. package/dist/live/upgrade-sources.d.ts +71 -0
  67. package/dist/live/upgrade-sources.js +198 -0
  68. package/dist/live/version-compare.d.ts +39 -0
  69. package/dist/live/version-compare.js +130 -0
  70. package/dist/live/version-resolver.d.ts +14 -1
  71. package/dist/live/version-resolver.js +93 -303
  72. package/dist/native-bindings.d.ts +19 -0
  73. package/dist/native-bindings.js +39 -0
  74. package/dist/project-manifests.d.ts +40 -0
  75. package/dist/project-manifests.js +193 -0
  76. package/dist/project-scanner.js +38 -48
  77. package/dist/project-tree.d.ts +41 -0
  78. package/dist/project-tree.js +110 -0
  79. package/dist/prompts.d.ts +28 -0
  80. package/dist/prompts.js +54 -0
  81. package/dist/schema-registry.d.ts +41 -29
  82. package/dist/schema-registry.js +98 -105
  83. package/dist/server-instructions.d.ts +11 -0
  84. package/dist/server-instructions.js +36 -0
  85. package/dist/tool-args.d.ts +28 -0
  86. package/dist/tool-args.js +93 -0
  87. package/dist/tool-dispatch.d.ts +1 -1
  88. package/dist/tool-dispatch.js +42 -8
  89. package/dist/tools/app-plan.d.ts +92 -0
  90. package/dist/tools/app-plan.js +155 -0
  91. package/dist/tools/briefing-stored-advisories.d.ts +20 -0
  92. package/dist/tools/briefing-stored-advisories.js +58 -0
  93. package/dist/tools/briefing-task-scope.d.ts +73 -0
  94. package/dist/tools/briefing-task-scope.js +197 -0
  95. package/dist/tools/briefing-wisdom.d.ts +31 -0
  96. package/dist/tools/briefing-wisdom.js +148 -0
  97. package/dist/tools/decision-enforcement.d.ts +0 -5
  98. package/dist/tools/decision-enforcement.js +0 -4
  99. package/dist/tools/decision-memory.js +18 -66
  100. package/dist/tools/dependency-check-signals.d.ts +74 -0
  101. package/dist/tools/dependency-check-signals.js +264 -0
  102. package/dist/tools/dependency-check.d.ts +98 -0
  103. package/dist/tools/dependency-check.js +295 -0
  104. package/dist/tools/dependency-health.js +74 -21
  105. package/dist/tools/developer-dna.d.ts +3 -0
  106. package/dist/tools/developer-dna.js +16 -15
  107. package/dist/tools/ecosystem-pulse.d.ts +6 -2
  108. package/dist/tools/ecosystem-pulse.js +53 -14
  109. package/dist/tools/get-actionable-signals.d.ts +26 -4
  110. package/dist/tools/get-actionable-signals.js +214 -251
  111. package/dist/tools/get-context.d.ts +2 -0
  112. package/dist/tools/get-context.js +9 -1
  113. package/dist/tools/get-relevant-content.d.ts +3 -1
  114. package/dist/tools/get-relevant-content.js +34 -3
  115. package/dist/tools/index.d.ts +4 -2
  116. package/dist/tools/index.js +4 -2
  117. package/dist/tools/install-drift-notes.d.ts +51 -0
  118. package/dist/tools/install-drift-notes.js +172 -0
  119. package/dist/tools/knowledge-gap-citations.d.ts +55 -0
  120. package/dist/tools/knowledge-gap-citations.js +132 -0
  121. package/dist/tools/knowledge-gap-exposure.d.ts +72 -0
  122. package/dist/tools/knowledge-gap-exposure.js +160 -0
  123. package/dist/tools/knowledge-gap-grading.d.ts +91 -0
  124. package/dist/tools/knowledge-gap-grading.js +157 -0
  125. package/dist/tools/knowledge-gap-installs.d.ts +65 -0
  126. package/dist/tools/knowledge-gap-installs.js +134 -0
  127. package/dist/tools/knowledge-gap-ranges.d.ts +51 -0
  128. package/dist/tools/knowledge-gap-ranges.js +146 -0
  129. package/dist/tools/knowledge-gap-scope.d.ts +63 -0
  130. package/dist/tools/knowledge-gap-scope.js +118 -0
  131. package/dist/tools/knowledge-gaps.d.ts +12 -3
  132. package/dist/tools/knowledge-gaps.js +172 -121
  133. package/dist/tools/package-presence.d.ts +24 -0
  134. package/dist/tools/package-presence.js +35 -0
  135. package/dist/tools/record-feedback.d.ts +0 -1
  136. package/dist/tools/record-feedback.js +7 -8
  137. package/dist/tools/signal-classifier.d.ts +25 -0
  138. package/dist/tools/signal-classifier.js +40 -0
  139. package/dist/tools/upgrade-impact-callsites.d.ts +52 -0
  140. package/dist/tools/upgrade-impact-callsites.js +226 -0
  141. package/dist/tools/upgrade-impact-literals.d.ts +45 -0
  142. package/dist/tools/upgrade-impact-literals.js +118 -0
  143. package/dist/tools/upgrade-impact-report.d.ts +80 -0
  144. package/dist/tools/upgrade-impact-report.js +178 -0
  145. package/dist/tools/upgrade-impact.d.ts +84 -0
  146. package/dist/tools/upgrade-impact.js +305 -0
  147. package/dist/tools/upgrade-planner.d.ts +41 -8
  148. package/dist/tools/upgrade-planner.js +201 -53
  149. package/dist/tools/vulnerability-scan-concise.d.ts +72 -0
  150. package/dist/tools/vulnerability-scan-concise.js +97 -0
  151. package/dist/tools/vulnerability-scan-format.d.ts +137 -0
  152. package/dist/tools/vulnerability-scan-format.js +276 -0
  153. package/dist/tools/vulnerability-scan.d.ts +338 -18
  154. package/dist/tools/vulnerability-scan.js +84 -125
  155. package/dist/tools/what-should-i-know.d.ts +86 -19
  156. package/dist/tools/what-should-i-know.js +277 -283
  157. package/dist/types.d.ts +4 -1
  158. package/package.json +27 -30
  159. package/dist/schemas/agent-memory.json +0 -49
  160. package/dist/schemas/decision-enforcement.json +0 -22
  161. package/dist/schemas/decision-memory.json +0 -54
  162. package/dist/schemas/dependency-health.json +0 -27
  163. package/dist/schemas/developer-dna.json +0 -219
  164. package/dist/schemas/ecosystem-pulse.json +0 -17
  165. package/dist/schemas/get-actionable-signals.json +0 -29
  166. package/dist/schemas/get-context.json +0 -17
  167. package/dist/schemas/get-relevant-content.json +0 -26
  168. package/dist/schemas/knowledge-gaps.json +0 -14
  169. package/dist/schemas/record-feedback.json +0 -24
  170. package/dist/schemas/upgrade-planner.json +0 -22
  171. package/dist/schemas/vulnerability-scan.json +0 -26
  172. package/dist/schemas/what-should-i-know.json +0 -19
@@ -0,0 +1,130 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Enough of .gitignore to decide whether a directory or lockfile belongs to
4
+ * the project, for the standalone project-tree walk.
5
+ *
6
+ * A directory the repository ignores is not part of the project: scratch
7
+ * clones, generated output, a sibling tool checked out inside the tree.
8
+ * Measured 2026-10-02: walking 4DA's root without this scanned the ignored
9
+ * `victauri-gauntlet/` and `cli/` and reported their advisories (rustls, h2,
10
+ * scc) as the project's, where osv-scanner, which honours .gitignore, did not.
11
+ *
12
+ * Read from the files, never by running git: `git check-ignore` in a cloned
13
+ * repository honours that repository's config, and a config can name programs
14
+ * for git to run (core.fsmonitor). Reading a scanned folder must not execute
15
+ * anything it names.
16
+ *
17
+ * Supported: comments, blank lines, `dir/` (directory only), leading `/` and
18
+ * inner `/` anchoring, `*`, `?`, `**`, nested .gitignore files and
19
+ * .git/info/exclude. A negation (`!pattern`) is treated conservatively: a
20
+ * path that any negation could re-include is never skipped.
21
+ */
22
+ import * as fs from "node:fs";
23
+ import * as path from "node:path";
24
+ const caseless = process.platform === "win32" || process.platform === "darwin";
25
+ function globToRegex(glob, anchored) {
26
+ let re = "";
27
+ for (let i = 0; i < glob.length; i++) {
28
+ const ch = glob[i];
29
+ if (ch === "*") {
30
+ if (glob[i + 1] === "*") {
31
+ re += ".*";
32
+ i++;
33
+ if (glob[i + 1] === "/")
34
+ i++;
35
+ }
36
+ else
37
+ re += "[^/]*";
38
+ }
39
+ else if (ch === "?")
40
+ re += "[^/]";
41
+ else
42
+ re += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
43
+ }
44
+ // Unanchored patterns match at any depth below their file's directory.
45
+ return new RegExp(`^${anchored ? "" : "(?:.*/)?"}${re}(?:/.*)?$`, caseless ? "i" : "");
46
+ }
47
+ function parseRules(content, base) {
48
+ const rules = [];
49
+ for (const raw of content.split(/\r?\n/)) {
50
+ let line = raw.replace(/\s+$/, "");
51
+ if (!line || line.startsWith("#"))
52
+ continue;
53
+ const negated = line.startsWith("!");
54
+ if (negated)
55
+ line = line.slice(1);
56
+ const dirOnly = line.endsWith("/");
57
+ if (dirOnly)
58
+ line = line.slice(0, -1);
59
+ const anchored = line.startsWith("/") || line.includes("/");
60
+ line = line.replace(/^\//, "");
61
+ if (!line)
62
+ continue;
63
+ rules.push({ base, regex: globToRegex(line, anchored), dirOnly, negated });
64
+ }
65
+ return rules;
66
+ }
67
+ /** Ignore rules gathered while walking down from a root. */
68
+ export class IgnoreRules {
69
+ root;
70
+ rules = [];
71
+ loaded = new Set();
72
+ constructor(root) {
73
+ this.root = root;
74
+ this.load("");
75
+ try {
76
+ this.rules.push(...parseRules(fs.readFileSync(path.join(root, ".git", "info", "exclude"), "utf-8"), ""));
77
+ }
78
+ catch {
79
+ // Not a git checkout, or no exclude file.
80
+ }
81
+ }
82
+ /** Read the .gitignore of a directory (relative to the root) once. */
83
+ load(relDir) {
84
+ if (this.loaded.has(relDir))
85
+ return;
86
+ this.loaded.add(relDir);
87
+ try {
88
+ this.rules.push(...parseRules(fs.readFileSync(path.join(this.root, relDir, ".gitignore"), "utf-8"), relDir));
89
+ }
90
+ catch {
91
+ // No .gitignore here.
92
+ }
93
+ }
94
+ /**
95
+ * Whether an absolute directory below the root lies in, or is, an ignored
96
+ * directory. Every ancestor is checked, loading nested .gitignore files on
97
+ * the way down. A directory outside the root is never "ignored" here.
98
+ */
99
+ ignoresDirectory(absDir) {
100
+ const rel = path.relative(this.root, absDir).replace(/\\/g, "/");
101
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel))
102
+ return false;
103
+ const segments = rel.split("/");
104
+ for (let i = 1; i <= segments.length; i++) {
105
+ this.load(segments.slice(0, i - 1).join("/"));
106
+ if (this.ignores(segments.slice(0, i).join("/"), true))
107
+ return true;
108
+ }
109
+ return false;
110
+ }
111
+ /** Whether a path (relative to the root, `/`-separated) is ignored. */
112
+ ignores(relPath, isDir) {
113
+ let ignored = false;
114
+ let reincludable = false;
115
+ for (const rule of this.rules) {
116
+ if (rule.base && !relPath.startsWith(`${rule.base}/`))
117
+ continue;
118
+ const local = rule.base ? relPath.slice(rule.base.length + 1) : relPath;
119
+ if (rule.dirOnly && !isDir && !local.includes("/"))
120
+ continue;
121
+ if (!rule.regex.test(local))
122
+ continue;
123
+ if (rule.negated)
124
+ reincludable = true;
125
+ else
126
+ ignored = true;
127
+ }
128
+ return ignored && !reincludable;
129
+ }
130
+ }
package/dist/index.js CHANGED
@@ -15,11 +15,13 @@
15
15
  * secret (MCP_AUTH_SECRET) and then verifies an HMAC-SHA256-signed Bearer
16
16
  * token on every request, enforcing the token's role at tool dispatch.
17
17
  *
18
- * 14 tools across 5 categories. Live vulnerability scanning (OSV.dev),
18
+ * 16 tools across 5 categories, plus the `deps` prompt. Upgrade impact,
19
+ * live vulnerability scanning (OSV.dev), pre-install dependency checks,
19
20
  * ecosystem news, persistent memory, and tech stack awareness for any MCP host.
20
21
  *
21
22
  * Categories (canonical — matches schema-registry.ts `ToolCategory`):
22
- * Security (3) — vulnerability scanning, dependency health, upgrade planning
23
+ * Security (5) — vulnerability scanning, dependency health, upgrade planning,
24
+ * upgrade impact, dependency check
23
25
  * Intelligence (7) — briefing, ecosystem pulse, context, content feed,
24
26
  * actionable signals, knowledge gaps, feedback
25
27
  * Decisions (2) — decision memory, alignment checking
@@ -40,14 +42,18 @@ import { homedir } from "node:os";
40
42
  import { startHttpServer } from "./http-transport.js";
41
43
  import { runSetup } from "./setup.js";
42
44
  import { runDoctor } from "./doctor.js";
43
- import { scanCurrentProject } from "./project-scanner.js";
45
+ import { scanProjectTree, treeResolutionGroups } from "./project-tree.js";
46
+ import { IgnoreRules } from "./gitignore.js";
44
47
  import { LiveIntelligence } from "./live/index.js";
45
- import { deriveTechStackForHeadlines } from "./tools/ecosystem-pulse.js";
46
48
  import { setLiveIntelligence } from "./live-singleton.js";
47
- // Schema registry for slim tool listing + category metadata
48
- import { getSlimToolList, getSchemaResources, hasToolSchema, getSchemaFilename, getCategoryManifest } from "./schema-registry.js";
49
+ import { SERVER_INSTRUCTIONS } from "./server-instructions.js";
50
+ import { validateToolArgs } from "./tool-args.js";
51
+ // Tool registry: listing, schema resources, category metadata
52
+ import { getCategoryManifest, getSchemaResources, getSlimToolList, getToolSchemaDocument, hasToolSchema, TOOL_REGISTRY, } from "./schema-registry.js";
49
53
  // Map-based tool dispatch (replaces per-tool imports + switch statement)
50
54
  import { dispatchTool } from "./tool-dispatch.js";
55
+ import { getPrompt, listPrompts } from "./prompts.js";
56
+ import { checkBuildStaleness } from "./build-staleness.js";
51
57
  const __filename = fileURLToPath(import.meta.url);
52
58
  const __dirname = dirname(__filename);
53
59
  // Single source of truth for the server version — read from package.json so
@@ -99,29 +105,30 @@ function getDatabase() {
99
105
  // the user's code) — the .mcpb bundle wires its directory picker to this.
100
106
  if (db.isStandalone) {
101
107
  const cwd = resolveProjectDir();
102
- const scan = scanCurrentProject(cwd);
108
+ // The root and every independently-locked project below it
109
+ // (project-tree.ts): a repo root's own lockfile is often not the only one.
110
+ const tree = scanProjectTree(cwd);
111
+ const scan = tree[0].scan;
103
112
  db.populateFromScan(scan);
113
+ const groups = treeResolutionGroups(tree);
104
114
  const detected = [
105
115
  ...scan.languages,
106
116
  ...scan.frameworks,
107
117
  ].filter(Boolean);
108
- console.error(`[4DA] Standalone mode: scanned ${scan.projectPath}`);
109
- if (detected.length > 0) {
110
- console.error(`[4DA] Detected: ${detected.join(", ")} | ${scan.dependencies.length} deps, ${scan.devDependencies.length} dev deps`);
118
+ console.error(`[4DA] Standalone mode: scanned ${scan.projectPath} (${tree.length} project${tree.length === 1 ? "" : "s"})`);
119
+ if (groups.length > 0) {
120
+ console.error(`[4DA] Detected: ${detected.join(", ") || "lockfiles"} | ${scan.dependencies.length} deps, ${scan.devDependencies.length} dev deps at the root`);
111
121
  // Initialize live intelligence with per-ecosystem resolved versions
112
- // (depTargets carries platform-gated dep info so advisories can be
113
- // flagged platform-relevant for the host).
114
- liveIntel.initFromMultiEcosystem(cwd, scan.depsByEcosystem, scan.depTargets);
122
+ // (targets carry platform-gated dep info so advisories can be flagged
123
+ // platform-relevant for the host).
124
+ liveIntel.initFromProjectTree(cwd, groups);
115
125
  if (liveIntel.isEnabled()) {
116
- console.error(`[4DA] Live intelligence: enabled (OSV.dev + HN)`);
117
- // Background prefetch — non-blocking, warms cache for first tool call
118
- const techStack = [...scan.languages, ...scan.frameworks];
119
- liveIntel.scanVulnerabilities(cwd).catch((err) => {
120
- console.error(`[4DA] Vulnerability prefetch failed: ${err instanceof Error ? err.message : String(err)}. Will retry on first tool call.`);
121
- });
122
- liveIntel.fetchHeadlines(techStack).catch((err) => {
123
- console.error(`[4DA] Headline prefetch failed: ${err instanceof Error ? err.message : String(err)}.`);
124
- });
126
+ console.error(`[4DA] Live intelligence: enabled (OSV.dev)`);
127
+ // The vulnerability scan goes through the warmup so a briefing that
128
+ // arrives before it finishes can await it instead of reading nothing.
129
+ // Headlines are fetched only when ecosystem_pulse is called.
130
+ liveIntel.startVulnerabilityWarmup(cwd);
131
+ console.error(`[4DA] Vulnerability scan warming in background (OSV.dev).`);
125
132
  }
126
133
  }
127
134
  else {
@@ -144,6 +151,10 @@ function getDatabase() {
144
151
  // into this project's vulnerability scan.
145
152
  const norm = (p) => p.replace(/\\/g, "/").toLowerCase().replace(/\/+$/, "");
146
153
  const rootNorm = norm(resolveProjectDir());
154
+ // The app indexes every project it can see, including directories the
155
+ // repository ignores (4DA's victauri-gauntlet/ and cli/); their
156
+ // advisories are not this project's. Same rule as standalone mode.
157
+ const ignore = new IgnoreRules(resolveProjectDir());
147
158
  const groups = new Map();
148
159
  for (const row of rows) {
149
160
  const projectPath = row.project_path || process.cwd();
@@ -152,6 +163,8 @@ function getDatabase() {
152
163
  continue;
153
164
  if (pp.includes("/.claude/worktrees/") || pp.includes("/.codex/worktrees/"))
154
165
  continue;
166
+ if (rootNorm && ignore.ignoresDirectory(projectPath))
167
+ continue;
155
168
  const language = row.language || "npm";
156
169
  const key = `${projectPath}::${language}`;
157
170
  let group = groups.get(key);
@@ -166,15 +179,19 @@ function getDatabase() {
166
179
  if (groups.size > 0) {
167
180
  liveIntel.initFromDependencyGroups([...groups.values()]);
168
181
  }
169
- // Warm the headline cache for ecosystem_pulse — previously only the
170
- // standalone branch prefetched, so full-DB servers served an empty
171
- // cache forever. Non-blocking; the tool also fetches on demand now.
182
+ // Headlines are fetched only when ecosystem_pulse is called: the
183
+ // search terms come from the user's projects.
172
184
  if (liveIntel.isEnabled()) {
173
- const techStack = deriveTechStackForHeadlines(db);
174
- if (techStack.length > 0) {
175
- liveIntel.fetchHeadlines(techStack).catch((err) => {
176
- console.error(`[4DA] Headline prefetch failed: ${err instanceof Error ? err.message : String(err)}.`);
177
- });
185
+ // Warm the vulnerability scan too. This branch never scanned: the
186
+ // dependency set was initialised, headlines were prefetched, and
187
+ // `lastVulnScan` stayed null until some tool called
188
+ // vulnerability_scan — so the first what_should_i_know of a session
189
+ // read an empty cache and answered "safe_to_delegate" for a task
190
+ // that named a package with an open advisory (verified live
191
+ // 2026-09-07: 0 advisories at t+0, 11 advisories at t+7min).
192
+ if (liveIntel.isInitialized()) {
193
+ liveIntel.startVulnerabilityWarmup(resolveProjectDir());
194
+ console.error(`[4DA] Vulnerability scan warming in background (OSV.dev) for ${liveIntel.getAuditDeps().length} resolved dependencies.`);
178
195
  }
179
196
  }
180
197
  }
@@ -189,6 +206,37 @@ function getDatabase() {
189
206
  }
190
207
  return db;
191
208
  }
209
+ /** Standalone vs desktop-app database, decided once by the cheap probe (no scan, no resolution). */
210
+ let standaloneMode = null;
211
+ function isStandaloneMode() {
212
+ if (db)
213
+ return db.isStandalone;
214
+ if (standaloneMode === null) {
215
+ const probe = FourDADatabase.validateDatabase(process.env.FOURDA_DB_PATH || undefined);
216
+ standaloneMode = probe.standalone === true;
217
+ }
218
+ return standaloneMode;
219
+ }
220
+ let backgroundInitScheduled = false;
221
+ /**
222
+ * Start the full init (database, project scan, lockfile resolution, scan
223
+ * warmup) after the current response has been written. It is synchronous
224
+ * work, so it runs on a later tick; a tool call that arrives first simply
225
+ * performs it itself, exactly as before.
226
+ */
227
+ function scheduleBackgroundInit() {
228
+ if (backgroundInitScheduled || db)
229
+ return;
230
+ backgroundInitScheduled = true;
231
+ setTimeout(() => {
232
+ try {
233
+ getDatabase();
234
+ }
235
+ catch (err) {
236
+ console.error(`[4DA] Background init failed (tools will retry on first call): ${err instanceof Error ? err.message : String(err)}`);
237
+ }
238
+ }, 50).unref?.();
239
+ }
192
240
  // =============================================================================
193
241
  // Server Factory
194
242
  // =============================================================================
@@ -205,17 +253,23 @@ export function buildServer() {
205
253
  name: "4da-server",
206
254
  version: SERVER_VERSION,
207
255
  }, {
256
+ // `listChanged` was advertised and never sent: the tool set is fixed for
257
+ // the life of the process, so the honest capability is none.
208
258
  capabilities: {
209
- tools: { listChanged: true },
259
+ tools: {},
210
260
  resources: {},
261
+ prompts: {},
211
262
  },
263
+ instructions: SERVER_INSTRUCTIONS,
212
264
  });
213
- // List available tools (SLIM): one-liner descriptions only (~500 tokens vs
214
- // ~4500). Full schemas available via MCP Resources: 4da://schema/{tool_name}
265
+ // tools/list answers from a cheap mode probe, never from the full init. It
266
+ // used to open the database AND resolve every lockfile synchronously first
267
+ // (measured 6.6 s on a 2,063-dependency tree), stalling the host's handshake.
268
+ // The full init is started right after the first listing is sent.
215
269
  server.setRequestHandler("tools/list", async () => {
216
- const database = getDatabase();
270
+ scheduleBackgroundInit();
217
271
  return {
218
- tools: getSlimToolList(database.isStandalone ? true : undefined),
272
+ tools: getSlimToolList(isStandaloneMode() ? true : undefined),
219
273
  };
220
274
  });
221
275
  // List schema resources: full tool schemas as MCP Resources for lazy
@@ -278,29 +332,45 @@ export function buildServer() {
278
332
  throw new Error(`Invalid resource URI: ${uri}`);
279
333
  }
280
334
  const toolName = match[1];
281
- if (!hasToolSchema(toolName)) {
335
+ const document = hasToolSchema(toolName) ? getToolSchemaDocument(toolName) : null;
336
+ if (!document) {
282
337
  throw new Error(`Unknown tool: ${toolName}`);
283
338
  }
284
- const schemaFile = getSchemaFilename(toolName);
285
- if (!schemaFile) {
286
- throw new Error(`No schema file for tool: ${toolName}`);
287
- }
288
- // Read schema from file
289
- const schemaPath = join(__dirname, "schemas", schemaFile);
290
- const schemaContent = readFileSync(schemaPath, "utf-8");
291
339
  return {
292
340
  contents: [
293
341
  {
294
342
  uri,
295
343
  mimeType: "application/json",
296
- text: schemaContent,
344
+ text: JSON.stringify(document, null, 2),
297
345
  },
298
346
  ],
299
347
  };
300
348
  });
349
+ // Prompts: user-invoked workflows (surfaced as slash commands by some hosts).
350
+ server.setRequestHandler("prompts/list", async () => ({ prompts: listPrompts() }));
351
+ server.setRequestHandler("prompts/get", async (request) => {
352
+ const prompt = getPrompt(request.params.name, request.params.arguments);
353
+ if (!prompt) {
354
+ throw new Error(`Unknown prompt: ${request.params.name}`);
355
+ }
356
+ return prompt;
357
+ });
301
358
  // Execute a tool
302
359
  server.setRequestHandler("tools/call", async (request) => {
303
360
  const { name, arguments: args } = request.params;
361
+ // Arguments are checked against the published schema before anything
362
+ // runs, so a wrong type or a misspelled parameter comes back as a
363
+ // correctable error rather than a Node exception or a silent no-op.
364
+ const entry = TOOL_REGISTRY[name];
365
+ if (entry) {
366
+ const problem = validateToolArgs(name, entry.definition.inputSchema, (args ?? {}));
367
+ if (problem) {
368
+ return {
369
+ content: [{ type: "text", text: JSON.stringify({ error: problem }) }],
370
+ isError: true,
371
+ };
372
+ }
373
+ }
304
374
  try {
305
375
  const database = getDatabase();
306
376
  return await dispatchTool(name, database, args);
@@ -333,6 +403,19 @@ export function buildServer() {
333
403
  */
334
404
  async function main() {
335
405
  const args = process.argv.slice(2);
406
+ // Say it before anything else: a stale dist answers with code the repo has
407
+ // already replaced (the 2026-08-30 server was two days behind its src and
408
+ // its already-fixed bugs were nearly re-diagnosed as live). stderr only —
409
+ // stdout is the MCP protocol.
410
+ try {
411
+ const staleness = checkBuildStaleness();
412
+ if (staleness?.stale && staleness.note) {
413
+ console.error(`[4DA] WARNING: ${staleness.note}`);
414
+ }
415
+ }
416
+ catch {
417
+ // The self-check must never block startup.
418
+ }
336
419
  // Version
337
420
  if (args.includes("--version") || args.includes("-v")) {
338
421
  console.log(`@4da/mcp-server ${SERVER_VERSION}`);
@@ -0,0 +1,26 @@
1
+ /**
2
+ * bun.lock reader (Bun 1.2+ text lockfile).
3
+ *
4
+ * Measured 2026-10-03 on the pre-publish held-out benchmark: the FastAPI
5
+ * full-stack template's frontend is locked by bun.lock only, and the server
6
+ * read nothing from it — 52 advisories osv-scanner reports (axios 1.18.0,
7
+ * next 16.2.6, @babel/core 7.28.6, ...) were never scanned.
8
+ *
9
+ * Format: JSONC (trailing commas). `workspaces` lists each workspace's
10
+ * declared dependencies; `packages` maps a resolution key to
11
+ * `["name@version", registry, { dependencies, optionalDependencies, ... }, integrity]`.
12
+ * The key is the package name for the hoisted copy and a path for a nested
13
+ * one ("next/postcss" is the postcss next resolves to; scoped names keep their
14
+ * slash: "@tanstack/router-plugin/@babel/core"). Workspace, link, file, git and
15
+ * github entries do not name a registry version and are skipped.
16
+ */
17
+ import type { InstanceSet } from "./lockfile-types.js";
18
+ /** JSONC to JSON: drops trailing commas outside strings (bun.lock has no comments). */
19
+ export declare function stripTrailingCommas(text: string): string;
20
+ /**
21
+ * Reads a bun.lock into `versions` (the hoisted copy of each name) and
22
+ * `instances` (every copy). Dev scope is known for every copy: dev when only
23
+ * the workspaces' devDependencies reach it, runtime when a dependency or
24
+ * optionalDependency does.
25
+ */
26
+ export declare function readBunLock(content: string, versions: Map<string, string>, instances?: InstanceSet): void;
@@ -0,0 +1,123 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * bun.lock reader (Bun 1.2+ text lockfile).
4
+ *
5
+ * Measured 2026-10-03 on the pre-publish held-out benchmark: the FastAPI
6
+ * full-stack template's frontend is locked by bun.lock only, and the server
7
+ * read nothing from it — 52 advisories osv-scanner reports (axios 1.18.0,
8
+ * next 16.2.6, @babel/core 7.28.6, ...) were never scanned.
9
+ *
10
+ * Format: JSONC (trailing commas). `workspaces` lists each workspace's
11
+ * declared dependencies; `packages` maps a resolution key to
12
+ * `["name@version", registry, { dependencies, optionalDependencies, ... }, integrity]`.
13
+ * The key is the package name for the hoisted copy and a path for a nested
14
+ * one ("next/postcss" is the postcss next resolves to; scoped names keep their
15
+ * slash: "@tanstack/router-plugin/@babel/core"). Workspace, link, file, git and
16
+ * github entries do not name a registry version and are skipped.
17
+ */
18
+ /** JSONC to JSON: drops trailing commas outside strings (bun.lock has no comments). */
19
+ export function stripTrailingCommas(text) {
20
+ let out = "";
21
+ let inString = false;
22
+ for (let i = 0; i < text.length; i++) {
23
+ const ch = text[i];
24
+ if (inString) {
25
+ out += ch;
26
+ if (ch === "\\")
27
+ out += text[++i] ?? "";
28
+ else if (ch === '"')
29
+ inString = false;
30
+ continue;
31
+ }
32
+ if (ch === '"')
33
+ inString = true;
34
+ if (ch === ",") {
35
+ let j = i + 1;
36
+ while (j < text.length && /\s/.test(text[j]))
37
+ j++;
38
+ if (text[j] === "}" || text[j] === "]")
39
+ continue;
40
+ }
41
+ out += ch;
42
+ }
43
+ return out;
44
+ }
45
+ /** "next/@babel/core" -> ["next", "@babel/core"]: a scope and its name are one segment. */
46
+ function keySegments(key) {
47
+ const parts = key.split("/");
48
+ const segments = [];
49
+ for (let i = 0; i < parts.length; i++) {
50
+ segments.push(parts[i].startsWith("@") && i + 1 < parts.length ? `${parts[i]}/${parts[++i]}` : parts[i]);
51
+ }
52
+ return segments;
53
+ }
54
+ /**
55
+ * Reads a bun.lock into `versions` (the hoisted copy of each name) and
56
+ * `instances` (every copy). Dev scope is known for every copy: dev when only
57
+ * the workspaces' devDependencies reach it, runtime when a dependency or
58
+ * optionalDependency does.
59
+ */
60
+ export function readBunLock(content, versions, instances) {
61
+ const lock = JSON.parse(stripTrailingCommas(content));
62
+ const packages = lock.packages ?? {};
63
+ const entry = (key) => {
64
+ const value = packages[key];
65
+ if (!Array.isArray(value) || typeof value[0] !== "string")
66
+ return null;
67
+ const at = value[0].lastIndexOf("@");
68
+ if (at <= 0)
69
+ return null;
70
+ const version = value[0].slice(at + 1);
71
+ if (!/^\d/.test(version))
72
+ return null;
73
+ const meta = value[2] && typeof value[2] === "object" ? value[2] : {};
74
+ return { name: value[0].slice(0, at), version, meta };
75
+ };
76
+ // Node-style resolution: the nearest nested copy, walking up the key, else the hoisted one.
77
+ const resolve = (fromKey, dep) => {
78
+ const segments = keySegments(fromKey);
79
+ for (let n = segments.length; n > 0; n--) {
80
+ const candidate = [...segments.slice(0, n), dep].join("/");
81
+ if (candidate in packages)
82
+ return candidate;
83
+ }
84
+ return dep in packages ? dep : null;
85
+ };
86
+ const reach = (roots) => {
87
+ const seen = new Set();
88
+ const stack = [...roots].filter((name) => name in packages);
89
+ while (stack.length > 0) {
90
+ const key = stack.pop();
91
+ if (seen.has(key))
92
+ continue;
93
+ seen.add(key);
94
+ const e = entry(key);
95
+ if (!e)
96
+ continue;
97
+ for (const dep of Object.keys({ ...e.meta.dependencies, ...e.meta.optionalDependencies })) {
98
+ const next = resolve(key, dep);
99
+ if (next && !seen.has(next))
100
+ stack.push(next);
101
+ }
102
+ }
103
+ return seen;
104
+ };
105
+ const runtimeRoots = new Set();
106
+ const devRoots = new Set();
107
+ for (const ws of Object.values(lock.workspaces ?? {})) {
108
+ for (const name of Object.keys({ ...ws.dependencies, ...ws.optionalDependencies }))
109
+ runtimeRoots.add(name);
110
+ for (const name of Object.keys(ws.devDependencies ?? {}))
111
+ devRoots.add(name);
112
+ }
113
+ const runtime = reach(runtimeRoots);
114
+ const dev = reach(devRoots);
115
+ for (const key of Object.keys(packages)) {
116
+ const e = entry(key);
117
+ if (!e)
118
+ continue;
119
+ if (key === e.name && !versions.has(e.name))
120
+ versions.set(e.name, e.version);
121
+ instances?.add(e.name, e.version, runtime.has(key) ? false : dev.has(key) ? true : undefined);
122
+ }
123
+ }
@@ -0,0 +1,38 @@
1
+ /** Rust target triple for the current host, or null for a platform we do not map. */
2
+ export declare function hostTriple(): string | null;
3
+ /** Reset the memo. Tests only. */
4
+ export declare function _resetCargoPlatformCache(): void;
5
+ /**
6
+ * Crate names cargo resolves for this host in the workspace at `dir`.
7
+ *
8
+ * Returns `null` — meaning *unknown*, never "nothing" — when there is no
9
+ * Cargo.toml, no host triple mapping, or cargo is missing/fails. A `null` must
10
+ * never be treated as an empty set: that would mark every crate inactive and
11
+ * silently hide real advisories.
12
+ */
13
+ export declare function activeCratesForHost(dir: string): Set<string> | null;
14
+ /**
15
+ * How `cargo tree` is invoked for the workspace at `dir`.
16
+ *
17
+ * cargo runs from a server-owned scratch directory and reaches the project
18
+ * only through `--manifest-path`. cargo and rustup take their configuration
19
+ * (`.cargo/config.toml`, `rust-toolchain.toml`) from the working directory and
20
+ * its parents, so running inside the scanned project would let files in that
21
+ * project configure the toolchain this server invokes. A scanned project is
22
+ * untrusted input. `CARGO_TARGET_DIR` points at the same scratch directory so
23
+ * cargo's metadata writes never land in the user's tree either.
24
+ *
25
+ * Exported for tests.
26
+ */
27
+ export declare function cargoTreeInvocation(dir: string): {
28
+ args: string[];
29
+ cwd: string;
30
+ targetDir: string;
31
+ };
32
+ /**
33
+ * Crate names from `cargo tree --prefix none` output.
34
+ *
35
+ * Exported for tests: the parser is the part that can silently degrade to an
36
+ * empty set, and an empty set is the one answer that must never be believed.
37
+ */
38
+ export declare function parseTreeNames(stdout: string): Set<string>;