weavatrix 0.2.18 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -93
- package/SECURITY.md +13 -31
- package/bin/weavatrix-mcp.mjs +2 -1
- package/docs/adr/0001-v0.3-offline-online-split.md +9 -7
- package/docs/releases/v0.2.19.md +33 -0
- package/docs/releases/v0.3.0.md +43 -0
- package/package.json +9 -3
- package/skill/SKILL.md +34 -59
- package/src/analysis/architecture/contract-storage.js +2 -2
- package/src/analysis/audit-extensions.js +60 -0
- package/src/analysis/duplicate-groups.js +13 -2
- package/src/analysis/duplicates.compute.js +7 -2
- package/src/analysis/health-capabilities.js +2 -1
- package/src/analysis/internal-audit/supply-chain.js +17 -2
- package/src/analysis/jvm-artifact-index.js +87 -0
- package/src/analysis/jvm-dependency-evidence.js +31 -8
- package/src/analysis/transport-contracts.js +78 -11
- package/src/analysis/transport-runtime-evidence.js +155 -0
- package/src/child-env.js +2 -2
- package/src/extension/local-services.mjs +75 -0
- package/src/graph/internal-builder.langs.js +2 -0
- package/src/graph/repo-registry.js +2 -2
- package/src/mcp/catalog.mjs +47 -21
- package/src/mcp/evidence/duplicate-member-order.mjs +1 -1
- package/src/mcp/evidence-snapshot.health.mjs +2 -2
- package/src/mcp/extension-api.mjs +77 -0
- package/src/mcp/health/audit-format.mjs +11 -1
- package/src/mcp/health/audit.mjs +10 -7
- package/src/mcp/health/duplicates.mjs +5 -3
- package/src/mcp/sync-payload.mjs +1 -1
- package/src/mcp/tools-actions.mjs +1 -8
- package/src/mcp/tools-architecture.mjs +2 -2
- package/src/mcp/tools-company.mjs +4 -2
- package/src/mcp-runtime.mjs +2 -0
- package/src/mcp-server.mjs +28 -17
- package/src/security/advisory-store.js +133 -181
- package/src/security/malware-scoring.js +4 -0
- package/src/security/registry-sig.rules.js +4 -3
- package/src/security/rust-advisory-report.js +60 -0
- package/docs/releases/v0.2.18.md +0 -41
- package/src/mcp/actions/advisories.mjs +0 -17
- package/src/mcp/actions/graph-sync.mjs +0 -195
- package/src/mcp/actions/hosted-architecture.mjs +0 -57
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const EXTENSION_NAME = /^[a-z0-9][a-z0-9._-]{0,127}$/
|
|
2
|
+
const TOOL_NAME = /^[a-z][a-z0-9_]{0,127}$/
|
|
3
|
+
|
|
4
|
+
const asArray = (value, field) => {
|
|
5
|
+
if (value == null) return []
|
|
6
|
+
if (!Array.isArray(value)) throw new TypeError(`${field} must be an array`)
|
|
7
|
+
return value
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const validateProfiles = (profiles = {}) => {
|
|
11
|
+
if (!profiles || typeof profiles !== 'object' || Array.isArray(profiles)) throw new TypeError('extension profiles must be an object')
|
|
12
|
+
const normalized = {}
|
|
13
|
+
for (const [name, capabilities] of Object.entries(profiles)) {
|
|
14
|
+
if (!EXTENSION_NAME.test(name)) throw new TypeError(`invalid extension profile name: ${name}`)
|
|
15
|
+
normalized[name] = asArray(capabilities, `profile ${name}`).map(String)
|
|
16
|
+
}
|
|
17
|
+
return Object.freeze(normalized)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const validateTools = (tools = []) => Object.freeze(asArray(tools, 'extension tools').map((tool) => {
|
|
21
|
+
if (!tool || typeof tool !== 'object') throw new TypeError('each extension tool must be an object')
|
|
22
|
+
if (!TOOL_NAME.test(String(tool.name || ''))) throw new TypeError(`invalid extension tool name: ${tool.name || '(missing)'}`)
|
|
23
|
+
if (!EXTENSION_NAME.test(String(tool.cap || ''))) throw new TypeError(`invalid extension tool capability: ${tool.cap || '(missing)'}`)
|
|
24
|
+
if (typeof tool.run !== 'function') throw new TypeError(`extension tool ${tool.name} must provide run()`)
|
|
25
|
+
return Object.freeze({...tool})
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
const validateAuditProviders = (providers = []) => Object.freeze(asArray(providers, 'auditProviders').map((provider) => {
|
|
29
|
+
if (!provider || typeof provider !== 'object') throw new TypeError('each audit provider must be an object')
|
|
30
|
+
if (!EXTENSION_NAME.test(String(provider.id || ''))) throw new TypeError(`invalid audit provider id: ${provider.id || '(missing)'}`)
|
|
31
|
+
if (typeof provider.run !== 'function') throw new TypeError(`audit provider ${provider.id} must provide run()`)
|
|
32
|
+
if (provider.network !== undefined && provider.network !== 'none') throw new TypeError(`audit provider ${provider.id} must be local (network: "none")`)
|
|
33
|
+
return Object.freeze({...provider, network: 'none'})
|
|
34
|
+
}))
|
|
35
|
+
|
|
36
|
+
const validateSkills = (skills = []) => Object.freeze(asArray(skills, 'skills').map((skill) => {
|
|
37
|
+
if (!skill || typeof skill !== 'object') throw new TypeError('each extension skill must be an object')
|
|
38
|
+
if (!EXTENSION_NAME.test(String(skill.name || ''))) throw new TypeError(`invalid extension skill name: ${skill.name || '(missing)'}`)
|
|
39
|
+
if (!String(skill.path || '').trim()) throw new TypeError(`extension skill ${skill.name} must provide a package-relative path`)
|
|
40
|
+
return Object.freeze({...skill, name: String(skill.name), path: String(skill.path)})
|
|
41
|
+
}))
|
|
42
|
+
|
|
43
|
+
// Public composition contract for packages layered on top of the MIT core. Extensions may add
|
|
44
|
+
// tools, local audit providers and packaged skills, but cannot replace core tools or profiles.
|
|
45
|
+
export function defineWeavatrixExtension(spec) {
|
|
46
|
+
if (!spec || typeof spec !== 'object') throw new TypeError('extension definition must be an object')
|
|
47
|
+
const name = String(spec.name || '')
|
|
48
|
+
const version = String(spec.version || '')
|
|
49
|
+
if (!EXTENSION_NAME.test(name)) throw new TypeError(`invalid extension name: ${name || '(missing)'}`)
|
|
50
|
+
if (!version.trim()) throw new TypeError(`extension ${name} must provide a version`)
|
|
51
|
+
return Object.freeze({
|
|
52
|
+
name,
|
|
53
|
+
version,
|
|
54
|
+
tools: validateTools(spec.tools),
|
|
55
|
+
profiles: validateProfiles(spec.profiles),
|
|
56
|
+
auditProviders: validateAuditProviders(spec.auditProviders),
|
|
57
|
+
skills: validateSkills(spec.skills),
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function normalizeWeavatrixExtensions(extensions = []) {
|
|
62
|
+
const normalized = asArray(extensions, 'extensions').map((extension) => defineWeavatrixExtension(extension))
|
|
63
|
+
const names = new Set()
|
|
64
|
+
for (const extension of normalized) {
|
|
65
|
+
if (names.has(extension.name)) throw new TypeError(`duplicate extension name: ${extension.name}`)
|
|
66
|
+
names.add(extension.name)
|
|
67
|
+
}
|
|
68
|
+
return Object.freeze(normalized)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const extensionRuntimeSummary = (extensions = []) => extensions.map((extension) => ({
|
|
72
|
+
name: extension.name,
|
|
73
|
+
version: extension.version,
|
|
74
|
+
tools: extension.tools.length,
|
|
75
|
+
auditProviders: extension.auditProviders.length,
|
|
76
|
+
skills: extension.skills.map((skill) => skill.name),
|
|
77
|
+
}))
|
|
@@ -52,7 +52,7 @@ export const auditFilter = (audit, args, findings = audit.findings, repoRoot = n
|
|
|
52
52
|
|
|
53
53
|
const auditChecksLine = (audit) => {
|
|
54
54
|
const check = (name, state) => `${name} ${state?.status || 'ERROR'}${state?.detail ? ` — ${state.detail}` : ''}`
|
|
55
|
-
return `Checks: ${check('OSV', audit.checks?.osv)}; ${check('malware', audit.checks?.malware)}. A NOT_CHECKED/PARTIAL/ERROR check is incomplete or unknown, never a clean zero.`
|
|
55
|
+
return `Checks: ${check('OSV', audit.checks?.osv)}; ${check('RustSec report', audit.checks?.rustsec)}; ${check('malware', audit.checks?.malware)}. A NOT_CHECKED/PARTIAL/ERROR check is incomplete or unknown, never a clean zero.`
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
const auditCapabilityLines = (audit) => {
|
|
@@ -74,6 +74,15 @@ const auditCapabilityLines = (audit) => {
|
|
|
74
74
|
return lines
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
const auditExtensionLines = (audit) => {
|
|
78
|
+
const items = audit.extensionCapabilities || []
|
|
79
|
+
if (!items.length) return []
|
|
80
|
+
return [
|
|
81
|
+
'Extension analyzer matrix (status/completeness):',
|
|
82
|
+
...items.map((item) => ` ${item.extension ? `${item.extension}/` : ''}${item.id}: ${item.status}/${item.completeness} — ${item.detail} (${item.findingCount} finding(s)).`),
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
|
|
77
86
|
const auditDependencyCoverageLines = (deps) => {
|
|
78
87
|
const ecosystems = Object.values(deps.ecosystems || {}).filter((item) => item?.present)
|
|
79
88
|
const verification = Object.entries(deps.verificationCoverage || {})
|
|
@@ -138,6 +147,7 @@ export const formatOrdinaryAudit = (audit, args, findings = audit.findings, head
|
|
|
138
147
|
auditDependencySummaryLine(audit, deps),
|
|
139
148
|
...auditDependencyCoverageLines(deps),
|
|
140
149
|
...auditCapabilityLines(audit),
|
|
150
|
+
...auditExtensionLines(audit),
|
|
141
151
|
`Scoped severity: critical ${sev.critical}, high ${sev.high}, medium ${sev.medium}, low ${sev.low}, info ${sev.info}. Scoped categories: unused ${bycat.unused}, structure ${bycat.structure}, vulnerability ${bycat.vulnerability}, malware ${bycat.malware}.`,
|
|
142
152
|
pathScope.suppressed ? `Path policy: production-first; suppressed ${pathScope.suppressed} finding(s) whose evidence is entirely test/e2e/generated/mock/story/docs/benchmark/temp or explicitly excluded. Pass include_classified:true to include them.` : 'Path policy: production-first; no classified-only findings were suppressed.',
|
|
143
153
|
`Repository-level ${auditChecksLine(audit)}`,
|
package/src/mcp/health/audit.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {rawGraph, effectiveRawGraph} from '../graph-context.mjs'
|
|
2
2
|
import {runInternalAudit} from '../../analysis/internal-audit.js'
|
|
3
|
+
import {applyAuditExtensions} from '../../analysis/audit-extensions.js'
|
|
3
4
|
import {classifyChangeImpact} from '../../analysis/change-classification.js'
|
|
4
5
|
import {
|
|
5
6
|
compareAuditDebt,
|
|
@@ -19,6 +20,11 @@ const {
|
|
|
19
20
|
pathsFromClassification,
|
|
20
21
|
} = await import(new URL(`./audit-format.mjs${auditFormatVersion}`, import.meta.url).href)
|
|
21
22
|
|
|
23
|
+
const runAudit = async (repoRoot, graph, args, ctx, options = {}) => applyAuditExtensions(
|
|
24
|
+
await runInternalAudit(repoRoot, {graph, ...options}),
|
|
25
|
+
{providers: ctx.extensions?.auditProviders || [], repoRoot, graph, args},
|
|
26
|
+
)
|
|
27
|
+
|
|
22
28
|
async function runAuditWithBaseline(args, ctx, currentGraph) {
|
|
23
29
|
const resolved = resolveGitCommit(ctx.repoRoot, args.base_ref)
|
|
24
30
|
if (!resolved.ok) return toolResult(`Audit baseline unavailable: ${resolved.error}.`, {
|
|
@@ -27,10 +33,7 @@ async function runAuditWithBaseline(args, ctx, currentGraph) {
|
|
|
27
33
|
|
|
28
34
|
let currentAudit
|
|
29
35
|
try {
|
|
30
|
-
currentAudit = await
|
|
31
|
-
graph: currentGraph,
|
|
32
|
-
skipMalwareScan: !args.include_malware_scan,
|
|
33
|
-
})
|
|
36
|
+
currentAudit = await runAudit(ctx.repoRoot, currentGraph, args, ctx, {skipMalwareScan: !args.include_malware_scan})
|
|
34
37
|
} catch (error) {
|
|
35
38
|
const reason = error instanceof Error ? error.message : String(error)
|
|
36
39
|
return toolResult(`Audit failed while building the current graph: ${reason}`, {
|
|
@@ -86,7 +89,7 @@ async function runAuditWithBaseline(args, ctx, currentGraph) {
|
|
|
86
89
|
if (currentMode !== 'full') graph = filterGraphForMode(graph, currentMode, {repoRoot: checkout})
|
|
87
90
|
graph.graphBuildMode = currentMode
|
|
88
91
|
graph.graphBuildScope = ''
|
|
89
|
-
return
|
|
92
|
+
return runAudit(checkout, graph, args, ctx, {skipMalwareScan: true})
|
|
90
93
|
})
|
|
91
94
|
if (!baseline.ok || !baseline.value?.ok) {
|
|
92
95
|
const reason = baseline.error || baseline.value?.error || 'baseline audit failed'
|
|
@@ -146,8 +149,8 @@ async function runAuditWithBaseline(args, ctx, currentGraph) {
|
|
|
146
149
|
export async function tRunAudit(g, args, ctx) {
|
|
147
150
|
if (!ctx.repoRoot) return 'Audit needs the repo root (not provided to this server).'
|
|
148
151
|
if (args.base_ref) return runAuditWithBaseline(args, ctx, rawGraph(ctx))
|
|
149
|
-
const
|
|
150
|
-
|
|
152
|
+
const graph = effectiveRawGraph(ctx)
|
|
153
|
+
const audit = await runAudit(ctx.repoRoot, graph, args, ctx, {
|
|
151
154
|
skipMalwareScan: !args.include_malware_scan, // greps installed packages — slow, so opt-in
|
|
152
155
|
})
|
|
153
156
|
if (!audit.ok) return `Audit failed: ${audit.error}`
|
|
@@ -66,7 +66,10 @@ export function tFindDuplicates(g, args, ctx) {
|
|
|
66
66
|
const boilerplateNote = analysis.boilerplateSuppressed
|
|
67
67
|
? ` ${analysis.boilerplateSuppressed} all-router framework boilerplate group(s) were suppressed; pass include_boilerplate:true to inspect them.`
|
|
68
68
|
: ''
|
|
69
|
-
|
|
69
|
+
const declarativeNote = analysis.declarativeSuppressed
|
|
70
|
+
? ` ${analysis.declarativeSuppressed} immutable declarative catalog group(s) were suppressed; pass include_declarative:true to inspect repeated data shapes.`
|
|
71
|
+
: ''
|
|
72
|
+
if (!groups.length) return `No clones at ≥${simMin}% similarity / ≥${tokMin} tokens (${mode} mode). Try lowering the thresholds.${smallPolicy}${suppressionNote}${boilerplateNote}${declarativeNote}`
|
|
70
73
|
const top = groups.slice(0, Math.min(30, Math.max(1, Number(args.top_n) || 15)))
|
|
71
74
|
const lines = top.map((grp, k) => {
|
|
72
75
|
const isStr = grp.members.some((f) => f.kind === 'string')
|
|
@@ -74,10 +77,9 @@ export function tFindDuplicates(g, args, ctx) {
|
|
|
74
77
|
const sites = grp.members.slice(0, 8).map((f) => ` ${f.file}:${f.start}-${f.end}`)
|
|
75
78
|
return [head, ...sites].join('\n')
|
|
76
79
|
})
|
|
77
|
-
return `Found ${groups.length} clone group(s) (${mode} mode, ≥${simMin}%, ≥${tokMin} tok${includeStrings ? ', incl. large string literals' : ''}). Top ${top.length}:\n\n${lines.join('\n\n')}\n\nUse read_source on any two sites to compare, then extract shared logic.${smallPolicy}${suppressionNote}${boilerplateNote}`
|
|
80
|
+
return `Found ${groups.length} clone group(s) (${mode} mode, ≥${simMin}%, ≥${tokMin} tok${includeStrings ? ', incl. large string literals' : ''}). Top ${top.length}:\n\n${lines.join('\n\n')}\n\nUse read_source on any two sites to compare, then extract shared logic.${smallPolicy}${suppressionNote}${boilerplateNote}${declarativeNote}`
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
// Focused dead-code review queue. Unlike the broad run_audit surface, this includes functions and
|
|
81
84
|
// methods with bounded source-free evidence, and explicitly demotes framework/dynamic/public API
|
|
82
85
|
// candidates. It never returns an automatic-delete verdict.
|
|
83
|
-
|
package/src/mcp/sync-payload.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Versioned, explicit
|
|
1
|
+
// Versioned, explicit source-free extension schema. Never forward graph.json wholesale.
|
|
2
2
|
import {sanitizeEvidenceSnapshot} from './sync-evidence.mjs';
|
|
3
3
|
import {
|
|
4
4
|
MAX_SYNC_EXTERNAL_IMPORTS, MAX_SYNC_LINKS, MAX_SYNC_NODES, assertArrayLimit,
|
|
@@ -2,17 +2,10 @@
|
|
|
2
2
|
// behaviorally identical to the former single-file implementation.
|
|
3
3
|
const version = new URL(import.meta.url).search
|
|
4
4
|
const load = (path) => import(new URL(`${path}${version}`, import.meta.url).href)
|
|
5
|
-
const [lifecycle
|
|
5
|
+
const [lifecycle] = await Promise.all([
|
|
6
6
|
load('./actions/graph-lifecycle.mjs'),
|
|
7
|
-
load('./actions/advisories.mjs'),
|
|
8
|
-
load('./actions/hosted-architecture.mjs'),
|
|
9
|
-
load('./actions/graph-sync.mjs'),
|
|
10
7
|
])
|
|
11
8
|
|
|
12
9
|
export const tOpenRepo = lifecycle.tOpenRepo
|
|
13
10
|
export const tListKnownRepos = lifecycle.tListKnownRepos
|
|
14
11
|
export const tRebuildGraph = lifecycle.tRebuildGraph
|
|
15
|
-
export const tRefreshAdvisories = advisories.tRefreshAdvisories
|
|
16
|
-
export const tPullArchitectureContract = architecture.tPullArchitectureContract
|
|
17
|
-
export const tPreviewSyncGraph = sync.tPreviewSyncGraph
|
|
18
|
-
export const tSyncGraph = sync.tSyncGraph
|
|
@@ -17,7 +17,7 @@ const activeContract = bootstrap.activeArchitectureContract
|
|
|
17
17
|
|
|
18
18
|
const remediation = () => ({
|
|
19
19
|
offlinePath: '.weavatrix/architecture.json',
|
|
20
|
-
|
|
20
|
+
extensionAction: 'A composing extension may provide owner-approved remote contract storage',
|
|
21
21
|
nextTool: 'verify_architecture',
|
|
22
22
|
})
|
|
23
23
|
|
|
@@ -38,7 +38,7 @@ function notConfiguredResult(g, action, {includeStarter = false, repoRoot = null
|
|
|
38
38
|
: ''
|
|
39
39
|
return toolResult([
|
|
40
40
|
`Architecture ${action} is NOT_CONFIGURED — no target contract is active.${starterText}`,
|
|
41
|
-
'Next: save .weavatrix/architecture.json
|
|
41
|
+
'Next: save .weavatrix/architecture.json, then call verify_architecture.',
|
|
42
42
|
].join('\n'), {
|
|
43
43
|
state: 'NOT_CONFIGURED',
|
|
44
44
|
remediation: remediation(),
|
|
@@ -193,7 +193,7 @@ export async function tTraceApiContract(_g, args = {}, ctx = {}) {
|
|
|
193
193
|
totals: {...analysis.totals, endpoints: 0, clientCalls: 0, matches: 0, methodMismatches: 0, uncertainCalls: 0, notDeadExternalUse: 0, notDeadExternalHandlers: 0, possibleExternalUse: 0, unknownLiveness: 0},
|
|
194
194
|
}
|
|
195
195
|
const transportAnalysis = selectedTransport === 'http'
|
|
196
|
-
? {transportContractsV:
|
|
196
|
+
? {transportContractsV: 2, transport: 'http', status: 'COMPLETE', completeness: {complete: true, reasons: []}, totals: {contracts: 0, matches: 0, uncertain: 0, filesScanned: 0, runtimeObservations: 0, runtimeResolved: 0, runtimeReportsComplete: 0}, contracts: [], uncertain: [], runtimeEvidence: {status: 'NOT_APPLICABLE', reports: [], resolvedUnknowns: 0}}
|
|
197
197
|
: analyzeTransportContracts({
|
|
198
198
|
backend: {id: backendAlias, repoRoot: backend.repoPath, graph: backendGraph.graph},
|
|
199
199
|
clients: clientGraphs.map((item) => ({id: item.alias, repoRoot: item.record.repoPath, graph: item.graph})),
|
|
@@ -201,6 +201,8 @@ export async function tTraceApiContract(_g, args = {}, ctx = {}) {
|
|
|
201
201
|
includeTests: args.include_tests === true,
|
|
202
202
|
maxImpactDepth: args.max_impact_depth,
|
|
203
203
|
maxAffectedFiles: args.max_affected_files,
|
|
204
|
+
runtimeEvidenceFiles: args.runtime_evidence_files,
|
|
205
|
+
runtimeEvidenceMaxAgeHours: args.runtime_evidence_max_age_hours,
|
|
204
206
|
})
|
|
205
207
|
const verdict = contractVerdict(analysis, transportAnalysis)
|
|
206
208
|
const reasons = [...new Set([...reconciliationReasons, ...(analysis.completeness?.reasons || []), ...(transportAnalysis.completeness?.reasons || [])])]
|
|
@@ -232,7 +234,7 @@ export async function tTraceApiContract(_g, args = {}, ctx = {}) {
|
|
|
232
234
|
...transportAnalysis.contracts.filter((contract) => contract.callsites.length).slice(0, topN).map((contract) =>
|
|
233
235
|
` ${contract.transport.toUpperCase()} ${contract.service ? `${contract.service}.` : contract.operation ? `${contract.operation} ` : ''}${contract.name} (${contract.file}:${contract.line}) [${contract.liveness}] → ${contract.callsites.length} callsite(s), ${contract.affected.files.length} affected file(s)`),
|
|
234
236
|
completeness.complete
|
|
235
|
-
? 'Completeness: complete within the declared repository graphs
|
|
237
|
+
? 'Completeness: complete within the declared repository graphs, supported static models and fresh revision-bound runtime capture.'
|
|
236
238
|
: `Completeness: partial — ${completeness.reasons.join('; ')}.`,
|
|
237
239
|
]
|
|
238
240
|
const result = {
|
package/src/mcp-server.mjs
CHANGED
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
//
|
|
2
|
-
// graph-context.mjs — graph load + indexes, node resolution, staleness, raw-graph cache, diffs
|
|
3
|
-
// tools-graph.mjs — graph query tools tools-impact.mjs — dependents / diff / change impact
|
|
4
|
-
// tools-health.mjs — audit / clones / coverage / endpoints
|
|
5
|
-
// tools-actions.mjs — rebuild / open_repo / list_known_repos + the 'online' group
|
|
6
|
-
// catalog.mjs — tool catalog, capability filter, hot-reload loader
|
|
1
|
+
// Weavatrix MCP stdio entry point; implementations and extension composition live in src/mcp/*.
|
|
7
2
|
// Spawned by Claude Code / Codex as a plain Node child (node mcp-server.mjs <graph.json> <repoRoot>).
|
|
8
3
|
// Speaks newline-delimited JSON-RPC 2.0 over stdio (the MCP stdio transport).
|
|
9
4
|
//
|
|
@@ -17,6 +12,8 @@
|
|
|
17
12
|
// weavatrix-mcp <repoRoot> [caps] — graph path derived from the standard layout
|
|
18
13
|
// weavatrix-mcp <graph.json> <repoRoot> [caps] — explicit graph file (classic form)
|
|
19
14
|
import process from 'node:process'
|
|
15
|
+
import {resolve} from 'node:path'
|
|
16
|
+
import {pathToFileURL} from 'node:url'
|
|
20
17
|
import {loadGraph} from './mcp/graph-context.mjs'
|
|
21
18
|
import {createStalenessNoticeGate} from './mcp/staleness-notice.mjs'
|
|
22
19
|
import {normalizeToolResult} from './mcp/tool-result.mjs'
|
|
@@ -32,23 +29,33 @@ import {
|
|
|
32
29
|
const DEFAULT_PROTOCOL = '2024-11-05'
|
|
33
30
|
const log = (...a) => process.stderr.write(`[weavatrix] ${a.join(' ')}\n`)
|
|
34
31
|
|
|
35
|
-
const target = resolveServerTarget(process.argv, log)
|
|
36
|
-
const GRAPH_PATH = target.graphPath, REPO_ROOT = target.repoRoot, CAPS_ARG = target.capabilities
|
|
37
|
-
|
|
38
32
|
// ---- hot reload of tool implementations -----------------------------------------------------------
|
|
39
33
|
// Node caches modules at spawn, so edits to the tool code would otherwise be invisible until the MCP
|
|
40
34
|
// client reconnects. Before each tools/list|call we stat the hot-reloadable files (HOT_FILES); when
|
|
41
35
|
// any changed on disk we re-import them through catalog.loadHotApi with a cache-busting version and
|
|
42
36
|
// swap the tool table, then notify the client. The stdio shell, graph-context (its caches), and the
|
|
43
37
|
// analysis engines are NOT swapped — changing those still needs a reconnect.
|
|
44
|
-
async function
|
|
38
|
+
export async function startMcpServer({
|
|
39
|
+
argv = process.argv,
|
|
40
|
+
defaultCapabilities,
|
|
41
|
+
loadExtensions = async () => [],
|
|
42
|
+
packageJsonPath = PACKAGE_JSON_PATH,
|
|
43
|
+
packageVersion = PKG_VERSION,
|
|
44
|
+
serverInfo = SERVER_INFO,
|
|
45
|
+
} = {}) {
|
|
46
|
+
const target = resolveServerTarget(argv, log)
|
|
47
|
+
const GRAPH_PATH = target.graphPath
|
|
48
|
+
const REPO_ROOT = target.repoRoot
|
|
49
|
+
const CAPS_ARG = target.capabilities ?? defaultCapabilities
|
|
45
50
|
let catalog = await loadCatalog()
|
|
46
|
-
let
|
|
51
|
+
let extensions = await loadExtensions({version: 0})
|
|
52
|
+
let api = await catalog.loadHotApi(0, CAPS_ARG, {extensions})
|
|
47
53
|
const runtimeInfo = () => ({
|
|
48
|
-
...runtimeVersionStatus({runningVersion:
|
|
54
|
+
...runtimeVersionStatus({runningVersion: packageVersion, packageJsonPath}),
|
|
49
55
|
profile: api.profile || 'custom',
|
|
50
56
|
capabilities: [...api.caps],
|
|
51
57
|
toolCount: api.tools.length,
|
|
58
|
+
...(api.extensions.items.length ? {extensions: api.extensions.items} : {}),
|
|
52
59
|
})
|
|
53
60
|
const runtimeInstructions = () => {
|
|
54
61
|
const runtime = runtimeInfo()
|
|
@@ -61,7 +68,7 @@ async function main() {
|
|
|
61
68
|
let graphError = null
|
|
62
69
|
// ctx owns the CURRENT target: rebuild_graph reloads it, open_repo retargets graphPath/repoRoot
|
|
63
70
|
// at runtime. loadInto always reads ctx.graphPath so both paths share one loader.
|
|
64
|
-
const ctx = {graphPath: GRAPH_PATH, repoRoot: REPO_ROOT, reload: null, runtime: runtimeInfo()}
|
|
71
|
+
const ctx = {graphPath: GRAPH_PATH, repoRoot: REPO_ROOT, reload: null, runtime: runtimeInfo(), extensions: api.extensions}
|
|
65
72
|
const loadInto = () => { graph = loadGraph(ctx.graphPath, {repoRoot: ctx.repoRoot}); graphError = null; return graph }
|
|
66
73
|
ctx.reload = () => { api.resetStalenessCache(); try { return loadInto() } catch (e) { graphError = e.message; return null } }
|
|
67
74
|
try {
|
|
@@ -114,10 +121,13 @@ async function main() {
|
|
|
114
121
|
if (v <= loadedVersion || v === lastFailedVersion) return
|
|
115
122
|
try {
|
|
116
123
|
const nextCatalog = await loadCatalog(v)
|
|
117
|
-
const
|
|
124
|
+
const nextExtensions = await loadExtensions({version: v})
|
|
125
|
+
const nextApi = await nextCatalog.loadHotApi(v, CAPS_ARG, {extensions: nextExtensions})
|
|
118
126
|
catalog = nextCatalog
|
|
119
127
|
api = nextApi
|
|
128
|
+
extensions = nextExtensions
|
|
120
129
|
ctx.runtime = runtimeInfo()
|
|
130
|
+
ctx.extensions = api.extensions
|
|
121
131
|
loadedVersion = v
|
|
122
132
|
log(`hot-reloaded tool implementations from changed source (${api.tools.length} tools)`)
|
|
123
133
|
send({jsonrpc: '2.0', method: 'notifications/tools/list_changed'})
|
|
@@ -145,7 +155,7 @@ async function main() {
|
|
|
145
155
|
if (method === 'initialize') {
|
|
146
156
|
if (params?.protocolVersion) protocolVersion = String(params.protocolVersion)
|
|
147
157
|
return reply(id, {
|
|
148
|
-
protocolVersion, capabilities: {tools: {listChanged: true}}, serverInfo
|
|
158
|
+
protocolVersion, capabilities: {tools: {listChanged: true}}, serverInfo,
|
|
149
159
|
instructions: runtimeInstructions(), _meta: {'weavatrix/runtime': runtimeInfo()},
|
|
150
160
|
})
|
|
151
161
|
}
|
|
@@ -281,7 +291,8 @@ async function main() {
|
|
|
281
291
|
|
|
282
292
|
// Guard: hot-reload re-imports of tool modules never touch this entry, but keep the start guard so a
|
|
283
293
|
// stray re-import of the entry itself can never spawn a second stdio loop.
|
|
284
|
-
|
|
294
|
+
const directEntry = process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
|
295
|
+
if (directEntry && !globalThis.__weavatrixMcpStarted) {
|
|
285
296
|
globalThis.__weavatrixMcpStarted = true
|
|
286
|
-
|
|
297
|
+
startMcpServer()
|
|
287
298
|
}
|