weavatrix 0.2.12 → 0.2.14

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 (132) hide show
  1. package/README.md +90 -21
  2. package/SECURITY.md +2 -2
  3. package/docs/releases/v0.2.14.md +93 -0
  4. package/package.json +2 -2
  5. package/skill/SKILL.md +108 -43
  6. package/src/analysis/allowed-test-runner.js +20 -9
  7. package/src/analysis/architecture/contract-graph.js +119 -0
  8. package/src/analysis/architecture/contract-schema.js +110 -0
  9. package/src/analysis/architecture/contract-storage.js +35 -0
  10. package/src/analysis/architecture/contract-verification.js +168 -0
  11. package/src/analysis/architecture-contract.js +16 -343
  12. package/src/analysis/change-classification/diff-parser.js +103 -0
  13. package/src/analysis/change-classification/options.js +40 -0
  14. package/src/analysis/change-classification/symbol-classifier.js +177 -0
  15. package/src/analysis/change-classification.js +120 -519
  16. package/src/analysis/dead-code-review/policy.js +23 -0
  17. package/src/analysis/dead-code-review.js +9 -19
  18. package/src/analysis/dep-check.js +10 -57
  19. package/src/analysis/dep-rules.js +10 -303
  20. package/src/analysis/dependency/scoped-dependencies.js +40 -0
  21. package/src/analysis/endpoints/common.js +62 -0
  22. package/src/analysis/endpoints/extract.js +107 -0
  23. package/src/analysis/endpoints/inventory.js +84 -0
  24. package/src/analysis/endpoints/mounts.js +129 -0
  25. package/src/analysis/endpoints-java.js +80 -3
  26. package/src/analysis/endpoints.js +3 -448
  27. package/src/analysis/git-history/analytics.js +177 -0
  28. package/src/analysis/git-history/collector.js +109 -0
  29. package/src/analysis/git-history/options.js +68 -0
  30. package/src/analysis/git-history.js +128 -577
  31. package/src/analysis/health-capabilities.js +82 -0
  32. package/src/analysis/http-contracts/analysis.js +169 -0
  33. package/src/analysis/http-contracts/client-call-detection.js +81 -0
  34. package/src/analysis/http-contracts/client-call-parser.js +255 -0
  35. package/src/analysis/http-contracts/graph-context.js +143 -0
  36. package/src/analysis/http-contracts/matching.js +61 -0
  37. package/src/analysis/http-contracts/shared.js +86 -0
  38. package/src/analysis/http-contracts.js +6 -822
  39. package/src/analysis/internal-audit/dependency-health.js +111 -0
  40. package/src/analysis/internal-audit/python-manifests.js +65 -0
  41. package/src/analysis/internal-audit/repo-files.js +55 -0
  42. package/src/analysis/internal-audit/supply-chain.js +132 -0
  43. package/src/analysis/internal-audit.collect.js +5 -106
  44. package/src/analysis/internal-audit.run.js +113 -200
  45. package/src/analysis/jvm-dependency-evidence.js +69 -0
  46. package/src/analysis/source-correctness/retry-patterns.js +93 -0
  47. package/src/analysis/source-correctness.js +225 -0
  48. package/src/analysis/structure/dependency-graph.js +156 -0
  49. package/src/analysis/structure/findings.js +142 -0
  50. package/src/graph/builder/go-receiver-resolution.js +112 -0
  51. package/src/graph/builder/js/queries.js +48 -0
  52. package/src/graph/builder/lang-go.js +89 -4
  53. package/src/graph/builder/lang-js.js +4 -44
  54. package/src/graph/builder/pass2-resolution.js +68 -0
  55. package/src/graph/freshness-probe.js +2 -1
  56. package/src/graph/incremental-refresh.js +3 -2
  57. package/src/graph/internal-builder.build.js +22 -183
  58. package/src/graph/internal-builder.pass2.js +161 -0
  59. package/src/graph/internal-builder.resolvers.js +2 -105
  60. package/src/graph/resolvers/rust.js +117 -0
  61. package/src/mcp/actions/advisories.mjs +18 -0
  62. package/src/mcp/actions/graph-lifecycle.mjs +148 -0
  63. package/src/mcp/actions/graph-sync.mjs +195 -0
  64. package/src/mcp/actions/hosted-architecture.mjs +57 -0
  65. package/src/mcp/architecture-bootstrap.mjs +168 -0
  66. package/src/mcp/architecture-starter.mjs +234 -0
  67. package/src/mcp/catalog.mjs +22 -6
  68. package/src/mcp/evidence/bun-lock-graph.mjs +209 -0
  69. package/src/mcp/evidence/package-graph-common.mjs +115 -0
  70. package/src/mcp/evidence/package-lock-graph.mjs +92 -0
  71. package/src/mcp/evidence-snapshot.package-graph.mjs +5 -474
  72. package/src/mcp/graph/context-core.mjs +111 -0
  73. package/src/mcp/graph/context-seeds.mjs +208 -0
  74. package/src/mcp/graph/context-state.mjs +86 -0
  75. package/src/mcp/graph/tools-core.mjs +143 -0
  76. package/src/mcp/graph/tools-query.mjs +223 -0
  77. package/src/mcp/graph-context.mjs +4 -496
  78. package/src/mcp/health/audit-format.mjs +172 -0
  79. package/src/mcp/health/audit.mjs +171 -0
  80. package/src/mcp/health/dead-code.mjs +110 -0
  81. package/src/mcp/health/duplicates.mjs +83 -0
  82. package/src/mcp/health/endpoints.mjs +42 -0
  83. package/src/mcp/health/structure.mjs +219 -0
  84. package/src/mcp/runtime-version.mjs +32 -0
  85. package/src/mcp/server/auto-refresh.mjs +66 -0
  86. package/src/mcp/server/runtime-config.mjs +43 -0
  87. package/src/mcp/server/shutdown.mjs +40 -0
  88. package/src/mcp/sync/evidence-architecture.mjs +54 -0
  89. package/src/mcp/sync/evidence-common.mjs +88 -0
  90. package/src/mcp/sync/evidence-duplicates.mjs +100 -0
  91. package/src/mcp/sync/evidence-health.mjs +56 -0
  92. package/src/mcp/sync/evidence-packages.mjs +95 -0
  93. package/src/mcp/sync/payload-common.mjs +97 -0
  94. package/src/mcp/sync/payload-v2.mjs +53 -0
  95. package/src/mcp/sync/payload-v3.mjs +79 -0
  96. package/src/mcp/sync-evidence.mjs +7 -402
  97. package/src/mcp/sync-payload.mjs +10 -244
  98. package/src/mcp/tools-actions.mjs +18 -414
  99. package/src/mcp/tools-architecture.mjs +18 -70
  100. package/src/mcp/tools-context.mjs +56 -15
  101. package/src/mcp/tools-endpoints.mjs +4 -1
  102. package/src/mcp/tools-graph.mjs +17 -330
  103. package/src/mcp/tools-health.mjs +24 -705
  104. package/src/mcp/tools-verified-change.mjs +12 -4
  105. package/src/mcp-server.mjs +49 -146
  106. package/src/precision/lsp-client/constants.js +12 -0
  107. package/src/precision/lsp-client/environment.js +20 -0
  108. package/src/precision/lsp-client/errors.js +15 -0
  109. package/src/precision/lsp-client/lifecycle.js +127 -0
  110. package/src/precision/lsp-client/message-parser.js +81 -0
  111. package/src/precision/lsp-client/protocol.js +212 -0
  112. package/src/precision/lsp-client/registry.js +58 -0
  113. package/src/precision/lsp-client/repo-uri.js +60 -0
  114. package/src/precision/lsp-client/stdio-client.js +151 -0
  115. package/src/precision/lsp-client.js +10 -682
  116. package/src/precision/lsp-overlay/build.js +238 -0
  117. package/src/precision/lsp-overlay/contract.js +61 -0
  118. package/src/precision/lsp-overlay/reference-results.js +108 -0
  119. package/src/precision/lsp-overlay/semantic-inputs.js +62 -0
  120. package/src/precision/lsp-overlay/source-session.js +134 -0
  121. package/src/precision/lsp-overlay/store.js +150 -0
  122. package/src/precision/lsp-overlay/target-index.js +147 -0
  123. package/src/precision/lsp-overlay/target-query.js +55 -0
  124. package/src/precision/lsp-overlay.js +11 -868
  125. package/src/precision/typescript-lsp-provider.js +12 -682
  126. package/src/precision/typescript-provider/client.js +69 -0
  127. package/src/precision/typescript-provider/discovery.js +124 -0
  128. package/src/precision/typescript-provider/project-host.js +249 -0
  129. package/src/precision/typescript-provider/project-safety.js +161 -0
  130. package/src/precision/typescript-provider/reference-usage.js +53 -0
  131. package/src/version.js +7 -0
  132. package/docs/releases/v0.2.12.md +0 -45
@@ -126,10 +126,18 @@ export async function tVerifiedChange(g, args = {}, ctx = {}, tools = {}, permis
126
126
  })
127
127
  : {model: 'bounded call-argument-to-parameter evidence (not CFG or taint analysis)', status: 'UNAVAILABLE', reason: 'source capability is not enabled', edges: [], unsupportedEdges: 0, capped: false}
128
128
  const suggestedTests = targetTests(impact)
129
- const checkedTests = validateTestRequests(ctx.repoRoot, args.tests || [])
130
- const testProof = phase === 'verify'
131
- ? await runAllowedTests(ctx.repoRoot, args.tests || [], {enabled: args.run_tests === true, timeoutMs: args.test_timeout_ms})
132
- : {state: checkedTests.ok ? 'PLANNED' : 'BLOCKED', reason: checkedTests.reason, plan: checkedTests.tests || [], results: []}
129
+ const requestedTests = Array.isArray(args.tests) ? args.tests : []
130
+ let testProof
131
+ if (!requestedTests.length) {
132
+ testProof = {state: 'NOT_REQUESTED', reason: 'no package scripts were requested', plan: [], results: []}
133
+ } else if (args.run_tests !== true) {
134
+ testProof = await runAllowedTests(ctx.repoRoot, requestedTests, {enabled: false, timeoutMs: args.test_timeout_ms})
135
+ } else if (phase === 'verify') {
136
+ testProof = await runAllowedTests(ctx.repoRoot, requestedTests, {enabled: true, timeoutMs: args.test_timeout_ms})
137
+ } else {
138
+ const checkedTests = validateTestRequests(ctx.repoRoot, requestedTests)
139
+ testProof = {state: checkedTests.ok ? 'PLANNED' : 'BLOCKED', reason: checkedTests.reason, plan: checkedTests.tests || [], results: []}
140
+ }
133
141
  const testCoverageState = testCoverage(testProof, args.tests || [], suggestedTests)
134
142
 
135
143
  const architectureValue = phase === 'verify'
@@ -16,54 +16,24 @@
16
16
  // go to stderr. Two argv forms:
17
17
  // weavatrix-mcp <repoRoot> [caps] — graph path derived from the standard layout
18
18
  // weavatrix-mcp <graph.json> <repoRoot> [caps] — explicit graph file (classic form)
19
- import {existsSync, statSync, realpathSync} from 'node:fs'
20
- import {join, dirname} from 'node:path'
21
- import {fileURLToPath} from 'node:url'
22
19
  import process from 'node:process'
23
20
  import {loadGraph} from './mcp/graph-context.mjs'
24
- import {graphOutDirForRepo} from './graph/layout.js'
25
- import {createRequire} from 'node:module'
26
21
  import {createStalenessNoticeGate} from './mcp/staleness-notice.mjs'
27
22
  import {normalizeToolResult} from './mcp/tool-result.mjs'
28
- import {buildGraphForRepo, defaultPrecisionMode} from './build-graph.js'
29
- import {persistedFreshnessMatches, repositoryFreshnessProbe} from './graph/freshness-probe.js'
30
- import {activeLspClientCount, beginLspClientShutdown, shutdownActiveLspClients} from './precision/lsp-client.js'
31
- import {PRECISION_OVERLAY_V, precisionSemanticInputsMatch, readPrecisionOverlay} from './precision/lsp-overlay.js'
23
+ import {runtimeVersionStatus, staleRuntimeMessage} from './mcp/runtime-version.mjs'
24
+ import {createAutoRefresh} from './mcp/server/auto-refresh.mjs'
25
+ import {createShutdownController} from './mcp/server/shutdown.mjs'
26
+ import {
27
+ hotCatalogVersion as hotVersion, loadServerCatalog as loadCatalog, PACKAGE_JSON_PATH,
28
+ PACKAGE_VERSION as PKG_VERSION, resolveServerTarget, SERVER_INFO,
29
+ } from './mcp/server/runtime-config.mjs'
32
30
 
33
31
  // version comes from package.json so serverInfo can never drift from the published package again
34
- const PKG_VERSION = (() => { try { return createRequire(import.meta.url)('../package.json').version } catch { return '0.0.0' } })()
35
- const SERVER_INFO = {name: 'weavatrix', version: PKG_VERSION}
36
32
  const DEFAULT_PROTOCOL = '2024-11-05'
37
33
  const log = (...a) => process.stderr.write(`[weavatrix] ${a.join(' ')}\n`)
38
34
 
39
- async function settleWithin(promise, timeoutMs) {
40
- let timer
41
- const settled = await Promise.race([
42
- Promise.resolve(promise).then(() => true, () => true),
43
- new Promise((resolveSettled) => { timer = setTimeout(() => resolveSettled(false), timeoutMs) }),
44
- ])
45
- if (timer) clearTimeout(timer)
46
- return settled
47
- }
48
-
49
- // argv[2] is a repo DIRECTORY in the npx form — derive the graph location from the standard layout;
50
- // otherwise it is the graph.json path and the repo root follows it.
51
- let GRAPH_PATH = process.argv[2]
52
- let repoArg = process.argv[3]
53
- // caps ABSENT (undefined) = offline defaults (explicit local retargeting, no network).
54
- // PRESENT (even the empty string) = explicit set — see catalog.loadHotApi.
55
- let CAPS_ARG = process.argv[4]
56
- try {
57
- if (GRAPH_PATH && statSync(GRAPH_PATH).isDirectory()) {
58
- repoArg = realpathSync.native(GRAPH_PATH)
59
- CAPS_ARG = process.argv[3]
60
- GRAPH_PATH = join(graphOutDirForRepo(repoArg), 'graph.json')
61
- if (!existsSync(GRAPH_PATH)) log(`no graph built yet for ${repoArg} — ask the agent to call rebuild_graph; it builds into the standard weavatrix-graphs layout`)
62
- }
63
- } catch { /* argv[2] is not a directory → classic <graph.json> <repoRoot> form */ }
64
- // repo source root for search_code / read_source; null → those tools degrade.
65
- let REPO_ROOT = null
66
- try { if (repoArg && statSync(repoArg).isDirectory()) REPO_ROOT = realpathSync.native(repoArg) } catch { /* invalid repo root */ }
35
+ const target = resolveServerTarget(process.argv, log)
36
+ const GRAPH_PATH = target.graphPath, REPO_ROOT = target.repoRoot, CAPS_ARG = target.capabilities
67
37
 
68
38
  // ---- hot reload of tool implementations -----------------------------------------------------------
69
39
  // Node caches modules at spawn, so edits to the tool code would otherwise be invisible until the MCP
@@ -71,25 +41,27 @@ try { if (repoArg && statSync(repoArg).isDirectory()) REPO_ROOT = realpathSync.n
71
41
  // any changed on disk we re-import them through catalog.loadHotApi with a cache-busting version and
72
42
  // swap the tool table, then notify the client. The stdio shell, graph-context (its caches), and the
73
43
  // analysis engines are NOT swapped — changing those still needs a reconnect.
74
- const MCP_DIR = join(dirname(fileURLToPath(import.meta.url)), 'mcp')
75
- const CATALOG_URL = new URL('./mcp/catalog.mjs', import.meta.url)
76
- const loadCatalog = (version = 0) => import(version ? `${CATALOG_URL.href}?v=${version}` : CATALOG_URL.href)
77
- function hotVersion(hotFiles) {
78
- let v = 0
79
- for (const f of hotFiles) {
80
- try { const t = statSync(join(MCP_DIR, f)).mtimeMs; if (t > v) v = t } catch { /* missing file just doesn't bump the version */ }
81
- }
82
- return v
83
- }
84
-
85
44
  async function main() {
86
45
  let catalog = await loadCatalog()
87
46
  let api = await catalog.loadHotApi(0, CAPS_ARG)
47
+ const runtimeInfo = () => ({
48
+ ...runtimeVersionStatus({runningVersion: PKG_VERSION, packageJsonPath: PACKAGE_JSON_PATH}),
49
+ profile: api.profile || 'custom',
50
+ capabilities: [...api.caps],
51
+ toolCount: api.tools.length,
52
+ })
53
+ const runtimeInstructions = () => {
54
+ const runtime = runtimeInfo()
55
+ const stale = runtime.staleRuntime
56
+ ? ` ${staleRuntimeMessage(runtime)}${runtime.staleRuntimeAllowed ? ' Development override is active.' : ''}`
57
+ : ''
58
+ return `Weavatrix ${runtime.version}; diskVersion=${runtime.diskVersion || 'unavailable'}; profile=${runtime.profile}; tools=${runtime.toolCount}; capabilities=${runtime.capabilities.join(',') || '(none)'}.${stale} If this differs from the client-visible tool list, reconnect the MCP client to discard its cached schema.`
59
+ }
88
60
  let graph = null
89
61
  let graphError = null
90
62
  // ctx owns the CURRENT target: rebuild_graph reloads it, open_repo retargets graphPath/repoRoot
91
63
  // at runtime. loadInto always reads ctx.graphPath so both paths share one loader.
92
- const ctx = {graphPath: GRAPH_PATH, repoRoot: REPO_ROOT, reload: null}
64
+ const ctx = {graphPath: GRAPH_PATH, repoRoot: REPO_ROOT, reload: null, runtime: runtimeInfo()}
93
65
  const loadInto = () => { graph = loadGraph(ctx.graphPath, {repoRoot: ctx.repoRoot}); graphError = null; return graph }
94
66
  ctx.reload = () => { api.resetStalenessCache(); try { return loadInto() } catch (e) { graphError = e.message; return null } }
95
67
  try {
@@ -109,73 +81,12 @@ async function main() {
109
81
  // ordinary tools use a per-call graph/context snapshot, so an explicitly retargetable registration
110
82
  // cannot mix one repo's in-memory graph with another repo's source root under concurrent MCP calls.
111
83
  let targetMutation = Promise.resolve()
112
- let shuttingDown = false
113
- let shutdownPromise = null
114
- const refreshProbeCache = new Map()
115
- const configuredDebounce = process.env.WEAVATRIX_AUTO_REFRESH_DEBOUNCE_MS == null
116
- ? 2_000
117
- : Number(process.env.WEAVATRIX_AUTO_REFRESH_DEBOUNCE_MS)
118
- const refreshDebounceMs = Math.max(0, Math.min(5_000, Number.isFinite(configuredDebounce) ? configuredDebounce : 2_000))
119
- const autoRefresh = async (callCtx, currentGraph) => {
120
- if (!callCtx?.repoRoot || !callCtx?.graphPath) return {graph: null, refresh: null}
121
- const activePrecision = currentGraph?.graphPrecisionMode || defaultPrecisionMode()
122
- const probeKey = `${callCtx.graphPath}\0${currentGraph?.graphBuildMode || 'full'}\0${activePrecision}`
123
- // Semantic inputs include ignored configs and configured project files that Git status does
124
- // not see. Check their bounded fingerprint before the ordinary source freshness debounce;
125
- // exact evidence must have no stale window, even on back-to-back tool calls.
126
- let semanticInputsChanged = false
127
- if (activePrecision === 'lsp' && currentGraph) {
128
- try {
129
- const overlay = readPrecisionOverlay(callCtx.graphPath, currentGraph)
130
- semanticInputsChanged = typeof overlay?.semanticInputFingerprint === 'string'
131
- && !precisionSemanticInputsMatch(overlay, callCtx.repoRoot, currentGraph)
132
- } catch {
133
- semanticInputsChanged = true
134
- }
135
- }
136
- const cachedProbe = refreshProbeCache.get(probeKey)
137
- if (!semanticInputsChanged && currentGraph && cachedProbe && Date.now() - cachedProbe.checkedAt < refreshDebounceMs) {
138
- return {graph: currentGraph, refresh: {kind: 'none', revision: currentGraph.graphRevision || null, changedFiles: 0}}
139
- }
140
- const beforeProbe = repositoryFreshnessProbe(callCtx.repoRoot)
141
- const precisionMissing = activePrecision === 'lsp' && (
142
- Number(currentGraph?.precisionOverlayV) !== PRECISION_OVERLAY_V
143
- || semanticInputsChanged
144
- )
145
- if (!precisionMissing && beforeProbe && currentGraph && (
146
- cachedProbe?.probe === beforeProbe
147
- || persistedFreshnessMatches(currentGraph, beforeProbe, currentGraph.graphBuildMode || 'full')
148
- )) {
149
- refreshProbeCache.set(probeKey, {probe: beforeProbe, checkedAt: Date.now()})
150
- return {graph: currentGraph, refresh: {kind: 'none', revision: currentGraph.graphRevision || null, changedFiles: 0}}
151
- }
152
- const result = await buildGraphForRepo(callCtx.repoRoot, {
153
- mode: currentGraph?.graphBuildMode || 'full',
154
- precision: activePrecision,
155
- scope: '',
156
- outDir: dirname(callCtx.graphPath),
157
- })
158
- if (!result.ok) throw new Error(result.error || 'automatic graph refresh failed')
159
- api.resetStalenessCache()
160
- const fresh = loadGraph(callCtx.graphPath, {repoRoot: callCtx.repoRoot})
161
- const afterProbe = repositoryFreshnessProbe(callCtx.repoRoot)
162
- if (afterProbe && afterProbe === beforeProbe) refreshProbeCache.set(probeKey, {probe: afterProbe, checkedAt: Date.now()})
163
- else refreshProbeCache.delete(probeKey)
164
- const update = result.refresh || {kind: 'full', changedFiles: [], reason: 'automatic-refresh'}
165
- return {
166
- graph: fresh,
167
- refresh: {
168
- kind: update.kind,
169
- revision: update.revision || fresh.graphRevision || null,
170
- changedFiles: Array.isArray(update.changedFiles) ? update.changedFiles.length : 0,
171
- notice: update.kind === 'none' ? undefined : `Graph ${update.kind === 'incremental' ? 'incrementally refreshed' : 'rebuilt'} before this answer (${update.reason || 'repository changed'}).`,
172
- },
173
- }
174
- }
84
+ const shutdown = createShutdownController({log, targetMutation: () => targetMutation})
85
+ const autoRefresh = createAutoRefresh(() => api)
175
86
  const send = (msg) => {
176
87
  // A request that was already running when the client disconnected may complete during the
177
88
  // bounded drain. Do not write a late reply into a closed protocol pipe.
178
- if (shuttingDown || process.stdout.destroyed || !process.stdout.writable) return false
89
+ if (shutdown.isShuttingDown() || process.stdout.destroyed || !process.stdout.writable) return false
179
90
  try {
180
91
  return process.stdout.write(JSON.stringify(msg) + '\n')
181
92
  } catch (error) {
@@ -184,32 +95,9 @@ async function main() {
184
95
  }
185
96
  }
186
97
  const reply = (id, result) => send({jsonrpc: '2.0', id, result})
187
- const fail = (id, code, message) => send({jsonrpc: '2.0', id, error: {code, message}})
98
+ const fail = (id, code, message, data) => send({jsonrpc: '2.0', id, error: {code, message, ...(data ? {data} : {})}})
188
99
 
189
- const requestShutdown = (reason, exitCode = 0) => {
190
- if (shutdownPromise) return shutdownPromise
191
- shuttingDown = true
192
- // This is synchronous and deliberately precedes every await: a graph build that reaches its
193
- // precision phase during the drain must not create a fresh TLS/tsserver process tree.
194
- beginLspClientShutdown()
195
- process.stdin.pause()
196
- const activeAtStart = activeLspClientCount()
197
- log(`shutdown requested (${reason}); draining graph work and ${activeAtStart} semantic provider(s)`)
198
- shutdownPromise = (async () => {
199
- // Give an ordinary auto-refresh a chance to commit and close its provider itself. A wedged
200
- // parse/query is bounded; active semantic children are then closed or tree-killed, after
201
- // which the mutation gets a final window to observe that cancellation and release locks.
202
- const initiallyDrained = await settleWithin(targetMutation, 2_500)
203
- const semantic = await shutdownActiveLspClients({timeoutMs: 3_000})
204
- const fullyDrained = initiallyDrained || await settleWithin(targetMutation, 1_500)
205
- log(`shutdown cleanup: graph=${fullyDrained ? 'drained' : 'bounded-timeout'}, semantic=${semantic.requested} requested/${semantic.remaining} remaining${semantic.timedOut ? ' (forced)' : ''}`)
206
- })().catch((error) => {
207
- log(`shutdown cleanup failed: ${error.stack || error.message}`)
208
- }).finally(() => {
209
- process.exit(exitCode)
210
- })
211
- return shutdownPromise
212
- }
100
+ const requestShutdown = shutdown.request
213
101
  process.stdout.on('error', (error) => {
214
102
  if (error?.code === 'EPIPE' || error?.code === 'ERR_STREAM_DESTROYED') {
215
103
  void requestShutdown('stdout disconnected')
@@ -229,6 +117,7 @@ async function main() {
229
117
  const nextApi = await nextCatalog.loadHotApi(v, CAPS_ARG)
230
118
  catalog = nextCatalog
231
119
  api = nextApi
120
+ ctx.runtime = runtimeInfo()
232
121
  loadedVersion = v
233
122
  log(`hot-reloaded tool implementations from changed source (${api.tools.length} tools)`)
234
123
  send({jsonrpc: '2.0', method: 'notifications/tools/list_changed'})
@@ -241,26 +130,40 @@ async function main() {
241
130
  const handle = async (msg) => {
242
131
  const {id, method, params} = msg
243
132
  const isNotification = id === undefined || id === null
244
- if (shuttingDown) {
133
+ if (shutdown.isShuttingDown()) {
245
134
  if (!isNotification) fail(id, -32000, 'MCP server is shutting down')
246
135
  return
247
136
  }
137
+ if (method === 'initialize' || method === 'tools/list' || method === 'tools/call') {
138
+ const runtime = runtimeInfo()
139
+ ctx.runtime = runtime
140
+ if (runtime.staleRuntime && !runtime.staleRuntimeAllowed) {
141
+ if (!isNotification) fail(id, -32001, staleRuntimeMessage(runtime), {'weavatrix/runtime': runtime})
142
+ return
143
+ }
144
+ }
248
145
  if (method === 'initialize') {
249
146
  if (params?.protocolVersion) protocolVersion = String(params.protocolVersion)
250
- return reply(id, {protocolVersion, capabilities: {tools: {listChanged: true}}, serverInfo: SERVER_INFO})
147
+ return reply(id, {
148
+ protocolVersion, capabilities: {tools: {listChanged: true}}, serverInfo: SERVER_INFO,
149
+ instructions: runtimeInstructions(), _meta: {'weavatrix/runtime': runtimeInfo()},
150
+ })
251
151
  }
252
152
  if (method === 'notifications/initialized' || method === 'initialized') return
253
153
  if (method === 'ping') return reply(id, {})
254
154
  if (method === 'tools/list') {
255
155
  await maybeHotReload()
256
- if (shuttingDown) return
257
- return reply(id, {tools: api.tools.map(({name, description, inputSchema, outputSchema}) => ({name, description, inputSchema, outputSchema}))})
156
+ if (shutdown.isShuttingDown()) return
157
+ return reply(id, {
158
+ tools: api.tools.map(({name, description, inputSchema, outputSchema}) => ({name, description, inputSchema, outputSchema})),
159
+ _meta: {'weavatrix/runtime': runtimeInfo()},
160
+ })
258
161
  }
259
162
  if (method === 'tools/call') {
260
163
  await maybeHotReload()
261
164
  // EOF can arrive while the hot-reload import is pending. Do not enqueue a graph mutation
262
165
  // after requestShutdown captured the mutation chain it is responsible for draining.
263
- if (shuttingDown) return
166
+ if (shutdown.isShuttingDown()) return
264
167
  const tool = api.byName.get(params?.name)
265
168
  if (!tool) return reply(id, {content: [{type: 'text', text: `Unknown tool: ${params?.name}`}], isError: true})
266
169
  const refreshesGraph = tool.cap === 'graph' || tool.cap === 'health' || tool.refreshGraph === true
@@ -349,7 +252,7 @@ async function main() {
349
252
  let buf = ''
350
253
  process.stdin.setEncoding('utf8')
351
254
  process.stdin.on('data', (chunk) => {
352
- if (shuttingDown) return
255
+ if (shutdown.isShuttingDown()) return
353
256
  buf += chunk
354
257
  let nl
355
258
  while ((nl = buf.indexOf('\n')) >= 0) {
@@ -0,0 +1,12 @@
1
+ export const DEFAULT_MAX_MESSAGE_BYTES = 8 * 1024 * 1024
2
+ export const DEFAULT_MAX_HEADER_BYTES = 16 * 1024
3
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 10_000
4
+ export const JSON_RPC_VERSION = '2.0'
5
+
6
+ export function positiveInteger(value, fallback, label) {
7
+ const candidate = value == null ? fallback : Number(value)
8
+ if (!Number.isSafeInteger(candidate) || candidate <= 0) {
9
+ throw new TypeError(`${label} must be a positive integer`)
10
+ }
11
+ return candidate
12
+ }
@@ -0,0 +1,20 @@
1
+ import {delimiter, dirname, join} from 'node:path'
2
+ import {childProcessEnv} from '../../child-env.js'
3
+
4
+ const LSP_ENV_ALLOWLIST = new Set([
5
+ 'path', 'pathext', 'systemroot', 'windir', 'comspec',
6
+ 'temp', 'tmp', 'tmpdir', 'home', 'userprofile', 'localappdata', 'appdata',
7
+ 'lang', 'language', 'lc_all', 'lc_ctype',
8
+ ])
9
+
10
+ export function lspChildProcessEnv(overrides = {}) {
11
+ const inherited = childProcessEnv(overrides)
12
+ const clean = Object.fromEntries(
13
+ Object.entries(inherited).filter(([key]) => LSP_ENV_ALLOWLIST.has(key.toLowerCase())),
14
+ )
15
+ const safePath = [dirname(process.execPath)]
16
+ const systemRoot = inherited.SystemRoot || inherited.SYSTEMROOT || inherited.WINDIR
17
+ if (process.platform === 'win32' && systemRoot) safePath.push(join(systemRoot, 'System32'))
18
+ clean.PATH = [...new Set(safePath)].join(delimiter)
19
+ return clean
20
+ }
@@ -0,0 +1,15 @@
1
+ export class LspProtocolError extends Error {
2
+ constructor(message, options) {
3
+ super(message, options)
4
+ this.name = 'LspProtocolError'
5
+ }
6
+ }
7
+
8
+ export class LspTimeoutError extends Error {
9
+ constructor(method, timeoutMs) {
10
+ super(`LSP request timed out after ${timeoutMs}ms: ${method}`)
11
+ this.name = 'LspTimeoutError'
12
+ this.method = method
13
+ this.timeoutMs = timeoutMs
14
+ }
15
+ }
@@ -0,0 +1,127 @@
1
+ import {spawn as spawnChild} from 'node:child_process'
2
+ import {positiveInteger} from './constants.js'
3
+ import {lspChildProcessEnv} from './environment.js'
4
+ import {LspTimeoutError} from './errors.js'
5
+ import {isLspClientActive} from './registry.js'
6
+ import {notifyClient, rejectPendingRequests, requestFromClient} from './protocol.js'
7
+
8
+ export function failClient(client, error) {
9
+ if (client.state === 'closed' || client.state === 'failed') return
10
+ client.state = 'failed'
11
+ rejectPendingRequests(client, error)
12
+ killClient(client, error)
13
+ }
14
+
15
+ export function killClient(client, reason = new Error('LSP client was killed')) {
16
+ if (client.state !== 'closed') client.state = 'closed'
17
+ rejectPendingRequests(client, reason)
18
+ client.openDocuments.clear()
19
+ try { client.child.stdin?.destroy() } catch { /* already closed */ }
20
+ if (process.platform !== 'win32' && client.processGroupPid) {
21
+ try {
22
+ process.kill(-client.processGroupPid, 'SIGKILL')
23
+ return
24
+ } catch { /* fall through to direct child */ }
25
+ }
26
+ if (client.child.exitCode != null || client.child.signalCode != null) return
27
+ if (process.platform === 'win32' && client.child.pid) {
28
+ try {
29
+ const killer = spawnChild('taskkill', ['/pid', String(client.child.pid), '/T', '/F'], {
30
+ shell: false,
31
+ windowsHide: true,
32
+ env: lspChildProcessEnv(),
33
+ stdio: 'ignore',
34
+ })
35
+ const fallback = () => {
36
+ try { client.child.kill('SIGKILL') } catch { /* already exited */ }
37
+ }
38
+ killer.once('error', fallback)
39
+ killer.once('exit', (code) => { if (code !== 0) fallback() })
40
+ } catch {
41
+ try { client.child.kill('SIGKILL') } catch { /* already exited */ }
42
+ }
43
+ } else {
44
+ try { client.child.kill('SIGKILL') } catch { /* already exited */ }
45
+ }
46
+ }
47
+
48
+ export async function killWindowsTreeAndWait(client, timeoutMs = 3_000) {
49
+ if (process.platform !== 'win32' || !client.child.pid || client.child.exitCode != null) return
50
+ await new Promise((resolveKill) => {
51
+ let settled = false
52
+ const done = () => {
53
+ if (settled) return
54
+ settled = true
55
+ clearTimeout(timer)
56
+ resolveKill()
57
+ }
58
+ const timer = setTimeout(() => {
59
+ try { client.child.kill() } catch { /* already exited */ }
60
+ done()
61
+ }, Math.max(250, Math.min(5_000, Number(timeoutMs) || 3_000)))
62
+ try {
63
+ const killer = spawnChild('taskkill', ['/pid', String(client.child.pid), '/T', '/F'], {
64
+ shell: false,
65
+ windowsHide: true,
66
+ env: lspChildProcessEnv(),
67
+ stdio: 'ignore',
68
+ })
69
+ const fallback = () => {
70
+ try { client.child.kill('SIGKILL') } catch { /* already exited */ }
71
+ }
72
+ killer.once('error', () => { fallback(); done() })
73
+ killer.once('exit', (code) => {
74
+ if (code !== 0) fallback()
75
+ done()
76
+ })
77
+ } catch {
78
+ try { client.child.kill('SIGKILL') } catch { /* already exited */ }
79
+ done()
80
+ }
81
+ })
82
+ }
83
+
84
+ export async function waitForClientExit(client, timeoutMs = client.requestTimeoutMs) {
85
+ if (!isLspClientActive(client)) return true
86
+ const boundedTimeout = Math.max(100, Math.min(10_000, Number(timeoutMs) || client.requestTimeoutMs))
87
+ let timer
88
+ const outcome = await Promise.race([
89
+ client.exited.then(() => true),
90
+ new Promise((resolveExit) => { timer = setTimeout(() => resolveExit(false), boundedTimeout) }),
91
+ ])
92
+ if (timer) clearTimeout(timer)
93
+ return outcome
94
+ }
95
+
96
+ export function shutdownClient(client, options = {}) {
97
+ if (!client.shutdownPromise) client.shutdownPromise = shutdownClientOnce(client, options)
98
+ return client.shutdownPromise
99
+ }
100
+
101
+ export async function shutdownClientOnce(client, {timeoutMs = client.requestTimeoutMs} = {}) {
102
+ const boundedTimeout = positiveInteger(timeoutMs, client.requestTimeoutMs, 'timeoutMs')
103
+ try {
104
+ if (client.state === 'starting') await client.spawned
105
+ if (client.state === 'closed') {
106
+ await waitForClientExit(client, boundedTimeout)
107
+ return
108
+ }
109
+ if (client.state === 'initialized') {
110
+ await requestFromClient(client, 'shutdown', null, {timeoutMs: boundedTimeout})
111
+ }
112
+ client.state = 'stopping'
113
+ await notifyClient(client, 'exit', null)
114
+ await killWindowsTreeAndWait(client, Math.min(3_000, boundedTimeout))
115
+ if (process.platform !== 'win32' && client.processGroupPid) {
116
+ await waitForClientExit(client, Math.min(1_000, boundedTimeout))
117
+ try { process.kill(-client.processGroupPid, 'SIGKILL') } catch { /* group already exited */ }
118
+ }
119
+ if (!await waitForClientExit(client, boundedTimeout)) {
120
+ killClient(client, new LspTimeoutError('exit', boundedTimeout))
121
+ }
122
+ } catch (error) {
123
+ killClient(client, error)
124
+ await killWindowsTreeAndWait(client, Math.min(2_000, boundedTimeout))
125
+ await waitForClientExit(client, Math.min(2_000, boundedTimeout))
126
+ }
127
+ }
@@ -0,0 +1,81 @@
1
+ import {TextDecoder} from 'node:util'
2
+ import {DEFAULT_MAX_HEADER_BYTES, DEFAULT_MAX_MESSAGE_BYTES, positiveInteger} from './constants.js'
3
+ import {LspProtocolError} from './errors.js'
4
+
5
+ const HEADER_DELIMITER = Buffer.from('\r\n\r\n', 'ascii')
6
+ const UTF8_DECODER = new TextDecoder('utf-8', {fatal: true})
7
+
8
+ function asBuffer(chunk) {
9
+ if (Buffer.isBuffer(chunk)) return chunk
10
+ if (chunk instanceof Uint8Array) return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)
11
+ return Buffer.from(chunk)
12
+ }
13
+
14
+ function parseHeaders(bytes, maxMessageBytes) {
15
+ const lines = bytes.toString('ascii').split('\r\n')
16
+ let contentLength = null
17
+ for (const line of lines) {
18
+ const match = /^([!#$%&'*+\-.^_`|~0-9A-Za-z]+):[ \t]*(.*)$/.exec(line)
19
+ if (!match) throw new LspProtocolError('Malformed LSP header line')
20
+ if (match[1].toLowerCase() !== 'content-length') continue
21
+ if (contentLength != null) throw new LspProtocolError('Duplicate Content-Length header')
22
+ if (!/^(0|[1-9][0-9]*)$/.test(match[2])) throw new LspProtocolError('Invalid Content-Length header')
23
+ contentLength = Number(match[2])
24
+ if (!Number.isSafeInteger(contentLength) || contentLength <= 0) {
25
+ throw new LspProtocolError('Content-Length must be positive')
26
+ }
27
+ if (contentLength > maxMessageBytes) {
28
+ throw new LspProtocolError(`LSP message exceeds ${maxMessageBytes} byte limit`)
29
+ }
30
+ }
31
+ if (contentLength == null) throw new LspProtocolError('Missing Content-Length header')
32
+ return contentLength
33
+ }
34
+
35
+ /** Incremental parser for the Content-Length framed JSON-RPC transport used by LSP. */
36
+ export class ContentLengthMessageParser {
37
+ constructor({onMessage, maxMessageBytes = DEFAULT_MAX_MESSAGE_BYTES, maxHeaderBytes = DEFAULT_MAX_HEADER_BYTES} = {}) {
38
+ if (typeof onMessage !== 'function') throw new TypeError('onMessage must be a function')
39
+ this.onMessage = onMessage
40
+ this.maxMessageBytes = positiveInteger(maxMessageBytes, DEFAULT_MAX_MESSAGE_BYTES, 'maxMessageBytes')
41
+ this.maxHeaderBytes = positiveInteger(maxHeaderBytes, DEFAULT_MAX_HEADER_BYTES, 'maxHeaderBytes')
42
+ this.buffer = Buffer.alloc(0)
43
+ this.expectedBodyBytes = null
44
+ }
45
+
46
+ push(chunk) {
47
+ const incoming = asBuffer(chunk)
48
+ if (incoming.length === 0) return
49
+ this.buffer = this.buffer.length === 0 ? incoming : Buffer.concat([this.buffer, incoming])
50
+ while (this.buffer.length > 0) {
51
+ if (this.expectedBodyBytes == null) {
52
+ const delimiterIndex = this.buffer.indexOf(HEADER_DELIMITER)
53
+ if (delimiterIndex < 0) {
54
+ if (this.buffer.length > this.maxHeaderBytes) throw new LspProtocolError('LSP header exceeds byte limit')
55
+ return
56
+ }
57
+ if (delimiterIndex === 0) throw new LspProtocolError('Empty LSP header block')
58
+ if (delimiterIndex > this.maxHeaderBytes) throw new LspProtocolError('LSP header exceeds byte limit')
59
+ this.expectedBodyBytes = parseHeaders(
60
+ this.buffer.subarray(0, delimiterIndex),
61
+ this.maxMessageBytes,
62
+ )
63
+ this.buffer = this.buffer.subarray(delimiterIndex + HEADER_DELIMITER.length)
64
+ }
65
+ if (this.buffer.length < this.expectedBodyBytes) return
66
+ const body = this.buffer.subarray(0, this.expectedBodyBytes)
67
+ this.buffer = this.buffer.subarray(this.expectedBodyBytes)
68
+ this.expectedBodyBytes = null
69
+ let value
70
+ try {
71
+ value = JSON.parse(UTF8_DECODER.decode(body))
72
+ } catch (error) {
73
+ throw new LspProtocolError('Invalid UTF-8 JSON in LSP message', {cause: error})
74
+ }
75
+ if (value == null || typeof value !== 'object' || Array.isArray(value)) {
76
+ throw new LspProtocolError('LSP message must be a JSON object')
77
+ }
78
+ this.onMessage(value)
79
+ }
80
+ }
81
+ }