weavatrix 0.2.19 → 0.3.1

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 (41) hide show
  1. package/README.md +103 -93
  2. package/SECURITY.md +13 -31
  3. package/bin/weavatrix-mcp.mjs +2 -1
  4. package/docs/adr/0001-v0.3-offline-online-split.md +9 -7
  5. package/docs/releases/v0.3.0.md +43 -0
  6. package/docs/releases/v0.3.1.md +30 -0
  7. package/package.json +9 -2
  8. package/skill/SKILL.md +34 -59
  9. package/src/analysis/architecture/contract-storage.js +2 -2
  10. package/src/analysis/audit-extensions.js +60 -0
  11. package/src/analysis/change-classification.js +72 -10
  12. package/src/analysis/duplicate-groups.js +13 -2
  13. package/src/analysis/duplicates.compute.js +7 -2
  14. package/src/analysis/health-capabilities.js +2 -1
  15. package/src/analysis/internal-audit/supply-chain.js +17 -2
  16. package/src/analysis/jvm-artifact-index.js +87 -0
  17. package/src/analysis/jvm-dependency-evidence.js +31 -8
  18. package/src/analysis/transport-contracts.js +78 -11
  19. package/src/analysis/transport-runtime-evidence.js +155 -0
  20. package/src/child-env.js +2 -2
  21. package/src/extension/local-services.mjs +75 -0
  22. package/src/graph/internal-builder.langs.js +2 -0
  23. package/src/graph/repo-registry.js +2 -2
  24. package/src/mcp/catalog.mjs +47 -21
  25. package/src/mcp/evidence/duplicate-member-order.mjs +1 -1
  26. package/src/mcp/evidence-snapshot.health.mjs +2 -2
  27. package/src/mcp/extension-api.mjs +77 -0
  28. package/src/mcp/health/audit-format.mjs +11 -1
  29. package/src/mcp/health/audit.mjs +10 -7
  30. package/src/mcp/health/duplicates.mjs +5 -3
  31. package/src/mcp/sync-payload.mjs +1 -1
  32. package/src/mcp/tools-actions.mjs +1 -8
  33. package/src/mcp/tools-architecture.mjs +2 -2
  34. package/src/mcp/tools-company.mjs +4 -2
  35. package/src/mcp-runtime.mjs +2 -0
  36. package/src/mcp-server.mjs +28 -17
  37. package/src/security/advisory-store.js +133 -181
  38. package/src/security/rust-advisory-report.js +60 -0
  39. package/src/mcp/actions/advisories.mjs +0 -17
  40. package/src/mcp/actions/graph-sync.mjs +0 -195
  41. package/src/mcp/actions/hosted-architecture.mjs +0 -57
@@ -8,21 +8,18 @@ import {fileURLToPath} from 'node:url'
8
8
  import {resolveNode, isSymbol, stalenessLine, resetStalenessCache} from './graph-context.mjs'
9
9
  import {createRgResolver} from '../mcp-rg.mjs'
10
10
  import {readSource, searchCode} from '../mcp-source-tools.mjs'
11
+ import {extensionRuntimeSummary, normalizeWeavatrixExtensions} from './extension-api.mjs'
11
12
 
12
13
  const SELF_DIR = dirname(fileURLToPath(import.meta.url))
13
14
  const resolveRg = createRgResolver(SELF_DIR)
14
- // The default profile is fully offline and includes explicit local repository retargeting. Operators
15
- // who want a hard single-repository boundary can select the `pinned` profile instead. HTTP tools are
16
- // split into explicit `advisories` and `hosted` capabilities; the old `online` name remains an alias
17
- // for both so existing registrations keep working.
15
+ // The core artifact has only local profiles. Online packages compose their own profiles through the
16
+ // public extension API; the MIT package never contains a capability alias that can enable HTTP.
18
17
  export const DEFAULT_CAPS = Object.freeze(['graph', 'search', 'source', 'health', 'build', 'retarget', 'crossrepo'])
19
18
  const PROFILE_CAPS = Object.freeze({
20
19
  offline: DEFAULT_CAPS,
21
20
  pinned: ['graph', 'search', 'source', 'health', 'build'],
22
- osv: [...DEFAULT_CAPS, 'advisories'],
23
- hosted: [...DEFAULT_CAPS, 'advisories', 'hosted'],
24
- full: [...DEFAULT_CAPS, 'advisories', 'hosted'],
25
21
  })
22
+ const MOVED_PROFILES = new Set(['online', 'osv', 'hosted', 'full'])
26
23
 
27
24
  // The files whose mtime the stdio shell watches for hot reload — keep in sync with the imports in
28
25
  // loadHotApi below (catalog.mjs itself is last: a change here re-derives the whole table).
@@ -36,8 +33,7 @@ const HOT_OWNERS = [
36
33
  'graph/tools-core.mjs', 'graph/tools-query.mjs', 'tools-graph-hubs.mjs',
37
34
  'health/duplicates.mjs', 'health/dead-code.mjs', 'health/audit-format.mjs',
38
35
  'health/audit.mjs', 'health/structure.mjs', 'health/endpoints.mjs',
39
- 'actions/graph-lifecycle.mjs', 'actions/advisories.mjs',
40
- 'actions/hosted-architecture.mjs', 'actions/graph-sync.mjs',
36
+ 'actions/graph-lifecycle.mjs',
41
37
  'architecture-starter.mjs', 'architecture-bootstrap.mjs',
42
38
  'company-contract-verdict.mjs',
43
39
  ]
@@ -76,13 +72,13 @@ function buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps}) {
76
72
  {
77
73
  cap: 'crossrepo',
78
74
  name: 'trace_api_contract',
79
- description: 'Cross-repository HTTP, GraphQL, gRPC and event/topic contract, handler-liveness and blast-radius evidence. Joins static backend contracts to registered client repositories; dynamic URLs/topics, reflection and unresolved runtime configuration remain explicit UNKNOWN evidence. Medium/high-confidence external matches mark a handler/contract NOT_DEAD_EXTERNAL_USE. Repository paths stay local and cannot be supplied through this tool.',
75
+ description: 'Cross-repository HTTP, GraphQL, gRPC and event/topic contract, handler-liveness and blast-radius evidence. Joins static models with optional revision-bound runtime/OTLP evidence; unobserved dynamic URLs/topics/reflection remain explicit UNKNOWN. Medium/high-confidence external matches mark a handler/contract NOT_DEAD_EXTERNAL_USE. Repository paths stay local and runtime report paths are repository-contained.',
80
76
  inputSchema: {
81
77
  type: 'object',
82
78
  properties: {
83
79
  backend: {type: 'string', description: 'Backend repository UUID or exact unambiguous registry label'},
84
80
  clients: {type: 'array', items: {type: 'string'}, minItems: 1, maxItems: 20, description: 'Client repository UUIDs or exact unambiguous registry labels'},
85
- transport: {type: 'string', enum: ['all', 'http', 'graphql', 'grpc', 'event'], default: 'all', description: 'Contract family to trace; all runs every supported static detector'},
81
+ transport: {type: 'string', enum: ['all', 'http', 'graphql', 'grpc', 'event'], default: 'all', description: 'Contract family to trace; all runs static and revision-bound runtime evidence for every supported transport'},
86
82
  method: {type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']},
87
83
  path: {type: 'string', maxLength: 2048, description: 'Optional full route or segment-aligned route fragment; /query matches /edgeAnalytics/query/... and {id}, :id and concrete parameter values are normalized'},
88
84
  changed_files: {type: 'array', items: {type: 'string'}, maxItems: 500, description: 'Optional backend repo-relative changed files; only endpoints declared in those files are traced'},
@@ -107,6 +103,8 @@ function buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps}) {
107
103
  },
108
104
  auto_discover_wrappers: {type: 'boolean', default: true, description: 'Discover only simple unambiguous functions that forward a URL parameter directly to a known object-style HTTP client'},
109
105
  runtime_config: {type: 'object', maxProperties: 50, additionalProperties: {type: 'string', maxLength: 2048}, description: 'Optional non-secret static bindings for runtime URL prefixes, e.g. process.env.API_BASE. Values are used locally for this call and are not returned.'},
106
+ runtime_evidence_files: {type: 'object', maxProperties: 21, additionalProperties: {type: 'string', maxLength: 512}, description: 'Optional repository-label/UUID to repository-relative weavatrix.transport-runtime.v1 JSON path. Defaults to .weavatrix/transport-runtime.json or .weavatrix/reports/transport-runtime.json in each repository.'},
107
+ runtime_evidence_max_age_hours: {type: 'integer', minimum: 1, maximum: 8760, default: 168, description: 'Maximum accepted age for a revision-matched runtime evidence report'},
110
108
  include_tests: {type: 'boolean', default: false},
111
109
  max_impact_depth: {type: 'integer', minimum: 0, maximum: 5, default: 2},
112
110
  max_endpoints: {type: 'integer', minimum: 1, maximum: 500, default: 250},
@@ -123,7 +121,7 @@ function buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps}) {
123
121
  {cap: 'source', name: 'read_source', description: "Read the actual source of a node (by label/ID) or a repo-relative file path — the symbol's lines with context. The graph stores only locations, not source text. For a path read, pass start_line to anchor the window anywhere in the file (otherwise it shows the head).", inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'node label or ID'}, path: {type: 'string', description: 'or a repo-relative file path'}, start_line: {type: 'integer', description: 'anchor line: window = start_line-before .. start_line+after'}, before: {type: 'integer', default: 3}, after: {type: 'integer', default: 40}}}, run: (g, a, ctx) => readSource({repoRoot: ctx.repoRoot, resolveNode, isSymbol}, g, a)},
124
122
  {cap: 'source', refreshGraph: true, name: 'inspect_symbol', description: 'Inspect one exact symbol with an on-demand TypeScript/JavaScript LSP reference query, grouped occurrence containers, graph blast radius, complexity facts and bounded local source context. Ambiguous labels fail closed; point queries never replace the broad precision overlay.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Exact node ID or unambiguous symbol label'}, precision: {type: 'string', enum: ['auto', 'graph', 'lsp'], default: 'auto'}, max_references: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, max_containers: {type: 'integer', minimum: 1, maximum: 50, default: 15}, context_lines: {type: 'integer', minimum: 0, maximum: 40, default: 8}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 60000, default: 30000}}, required: ['label']}, run: (g, a, ctx) => ts.tInspectSymbol(g, a, ctx)},
125
123
  {cap: 'source', refreshGraph: true, name: 'context_bundle', description: 'Return one compact, bounded source bundle for an exact symbol: definition, production-first inbound/outbound containers, exact re-export sites, on-demand TS/JS reference evidence and diverse excerpts around call sites. Use before an edit when query_graph would be too broad.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Exact node ID or unambiguous symbol label'}, precision: {type: 'string', enum: ['auto', 'graph', 'lsp'], default: 'auto'}, max_references: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, max_related: {type: 'integer', minimum: 1, maximum: 30, default: 10}, max_reexports: {type: 'integer', minimum: 1, maximum: 100, default: 20}, max_source_files: {type: 'integer', minimum: 1, maximum: 8, default: 4}, context_lines: {type: 'integer', minimum: 0, maximum: 12, default: 4}, include_classified: {type: 'boolean', default: false, description: 'Include test/e2e/generated/mock/story/docs/benchmark/temp callers after production callers'}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 60000, default: 30000}}, required: ['label']}, run: (g, a, ctx) => tb.tContextBundle(g, a, ctx, ts.tInspectSymbol)},
126
- {cap: 'health', name: 'find_duplicates', description: "Content-based clone detection over production code (MOSS winnowing over method bodies). Supports high-confidence small clones down to 12 tokens when min_tokens is lowered. Tests, classified non-product paths, and all-router framework boilerplate clone groups are excluded by default; opt them in explicitly.", inputSchema: {type: 'object', properties: {min_similarity: {type: 'integer', description: '50-100, default 80 (ignored in semantic mode)'}, min_tokens: {type: 'integer', minimum: 12, maximum: 400, description: 'min fragment size, 12-400; default 50'}, mode: {type: 'string', enum: ['renamed', 'strict', 'semantic'], default: 'renamed'}, include_tests: {type: 'boolean', default: false}, include_classified: {type: 'boolean', default: false, description: 'Include generated/mock/story/docs/benchmark/temp and paths explicitly classified as excluded; tests still require include_tests'}, include_boilerplate: {type: 'boolean', default: false, description: 'Include clone groups made entirely of conventional *.router.js/ts router symbols'}, include_strings: {type: 'boolean', description: 'Also clone-check large multi-line string literals', default: false}, top_n: {type: 'integer', default: 15}}}, run: (g, a, ctx) => th.tFindDuplicates(g, a, ctx)},
124
+ {cap: 'health', name: 'find_duplicates', description: "Content-based clone detection over production code (MOSS winnowing over method bodies). Supports high-confidence small clones down to 12 tokens when min_tokens is lowered. Tests, classified non-product paths, all-router framework boilerplate and immutable declarative catalogs are excluded by default; opt them in explicitly.", inputSchema: {type: 'object', properties: {min_similarity: {type: 'integer', description: '50-100, default 80 (ignored in semantic mode)'}, min_tokens: {type: 'integer', minimum: 12, maximum: 400, description: 'min fragment size, 12-400; default 50'}, mode: {type: 'string', enum: ['renamed', 'strict', 'semantic'], default: 'renamed'}, include_tests: {type: 'boolean', default: false}, include_classified: {type: 'boolean', default: false, description: 'Include generated/mock/story/docs/benchmark/temp and paths explicitly classified as excluded; tests still require include_tests'}, include_boilerplate: {type: 'boolean', default: false, description: 'Include clone groups made entirely of conventional *.router.js/ts router symbols'}, include_declarative: {type: 'boolean', default: false, description: 'Include repeated immutable array/object catalogs that contain no executable control flow'}, include_strings: {type: 'boolean', description: 'Also clone-check large multi-line string literals', default: false}, top_n: {type: 'integer', default: 15}}}, run: (g, a, ctx) => th.tFindDuplicates(g, a, ctx)},
127
125
  {cap: 'health', name: 'find_dead_code', description: 'Conservative review queue for statically unreferenced files, functions, methods and symbols. Returns confidence, reason, bounded evidence and explicit framework/dynamic/reflection/public-API caveats; never an auto-delete verdict. Tests, generated code, mocks, stories, docs, benchmarks and temporary roots are excluded by default.', inputSchema: {type: 'object', properties: {path: {type: 'string', maxLength: 1024, description: 'Optional repo-relative path prefix'}, kinds: {type: 'array', items: {type: 'string', enum: ['file', 'function', 'method', 'symbol']}, maxItems: 4, uniqueItems: true, description: 'Optional candidate kinds; defaults to all'}, min_confidence: {type: 'string', enum: ['high', 'medium', 'low'], default: 'medium', description: 'Minimum confidence to include. low explicitly includes public/framework/dynamic review candidates'}, include_tests: {type: 'boolean', default: false}, include_classified: {type: 'boolean', default: false, description: 'Include generated/mock/story/docs/benchmark/temp and paths explicitly classified as excluded; tests still require include_tests'}, top_n: {type: 'integer', minimum: 1, maximum: 100, default: 30}}}, run: (g, a, ctx) => th.tFindDeadCode(g, a, ctx)},
128
126
  {cap: 'health', name: 'run_audit', description: 'Core production-first repository Health review with an explicit capability/completeness matrix for structure, dependencies, bounded runtime-correctness/concurrency patterns, advisories, malware and coverage. Unsupported Maven/Gradle import verification is NOT_SUPPORTED/PARTIAL, never a clean zero. Findings whose evidence is entirely test/e2e/generated/mock/story/docs/benchmark/temp or explicitly excluded are suppressed by default; opt them in with include_classified. category=dependencies is a dedicated dependency-health projection across missing, unused and duplicate declarations while preserving each finding\'s native category. With base_ref, builds and audits an immutable Git checkout and compares stable deterministic finding IDs; debt defaults to genuinely new findings. Supply-chain checks remain explicitly uncomparable across the source-only baseline. changed_files without base_ref is only changed-scope, never a new-debt claim.', inputSchema: {type: 'object', properties: {category: {type: 'string', enum: ['dependencies', 'unused', 'structure', 'vulnerability', 'malware'], description: 'Only findings of this category; dependencies selects dependency manifest/import findings across native categories'}, min_severity: {type: 'string', enum: ['critical', 'high', 'medium', 'low', 'info'], description: 'Minimum severity to include'}, max_findings: {type: 'integer', description: 'Max findings to list, default 30'}, include_classified: {type: 'boolean', default: false, description: 'Include findings whose evidence is entirely tests/e2e/generated/mocks/stories/docs/benchmarks/temp or explicitly excluded'}, include_malware_scan: {type: 'boolean', description: 'Also grep installed packages for malware heuristics (slow)', default: false}, base_ref: {type: 'string', maxLength: 200, description: 'Optional immutable Git baseline (for example HEAD~1 or origin/main). Enables honest new/existing/fixed debt comparison'}, changed_files: {type: 'array', items: {type: 'string'}, minItems: 1, maxItems: 500, description: 'Optional explicit repo-relative scope. Without base_ref this is changed-scope only; when omitted with base_ref, files are derived from the Git diff'}, debt: {type: 'string', enum: ['new', 'existing', 'all'], default: 'new', description: 'Baseline comparison view. Defaults to genuinely new deterministic findings when base_ref is present'}}}, run: (g, a, ctx) => th.tRunAudit(g, a, ctx)},
129
127
  {cap: 'health', name: 'coverage_map', description: 'Map a real existing coverage report onto the graph. If no report exists, return clearly labelled static test reachability (a test imports/reaches a source file) with actualCoverage=NOT_AVAILABLE; reachability is never presented as measured coverage.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', description: 'Max risk hotspots to list, default 15'}, path: {type: 'string', description: 'Optional repo-relative path prefix filter, e.g. src/query'}}}, run: (g, a, ctx) => th.tCoverageMap(g, a, ctx)},
@@ -141,10 +139,6 @@ function buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps}) {
141
139
  {cap: 'health', name: 'propose_architecture_exception', description: 'Prepare, but never apply, a bounded exception proposal for human review.', inputSchema: {type: 'object', properties: {fingerprint: {type: 'string'}, reason: {type: 'string'}, expires: {type: 'string', description: 'Optional YYYY-MM-DD'}}, required: ['fingerprint', 'reason']}, run: (g, a, ctx) => tar.tProposeArchitectureException(g, a, ctx)},
142
140
  {cap: 'retarget', name: 'open_repo', description: 'OFFLINE RETARGET: switch this server to another local Git repository, building its graph when missing. This explicit tool call changes the active repository boundary; pass build:false to probe without building. Omitted mode/precision preserve an existing graph; a new graph uses full and the startup precision setting (lsp unless WEAVATRIX_PRECISION=off). Omit the retarget capability at registration to pin one repository.', inputSchema: {type: 'object', properties: {path: {type: 'string', description: 'Absolute path to a Git working tree'}, build: {type: 'boolean', description: 'Build the graph when missing (default true)', default: true}, mode: {type: 'string', enum: ['full', 'no-tests', 'tests-only'], description: 'Optional build mode override; omit to preserve an existing graph'}, precision: {type: 'string', enum: ['lsp', 'off'], description: 'Optional semantic precision override; omit to preserve an existing graph or use the startup setting for a new graph'}} , required: ['path']}, run: (g, a, ctx) => ta.tOpenRepo(g, a, ctx)},
143
141
  {cap: 'retarget', name: 'list_known_repos', description: 'OFFLINE RETARGET: list every registered local repository graph from the global per-user registry, regardless of parent folder.', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => ta.tListKnownRepos(g, a, ctx)},
144
- {cap: 'advisories', name: 'refresh_advisories', description: "NETWORK / explicit advisories profile: refresh the local OSV advisory store for this repo's concrete npm/PyPI/Go/Maven/Gradle/Cargo package versions. Sends package names + versions to OSV.dev — never automatic, never source code. Afterwards run_audit reads the refreshed store fully offline (~/.weavatrix/advisories.json).", inputSchema: {type: 'object', properties: {timeout_ms: {type: 'integer', description: 'Per-request timeout, default 20000'}}}, run: (g, a, ctx) => ta.tRefreshAdvisories(g, a, ctx)},
145
- {cap: 'hosted', name: 'pull_architecture_contract', description: 'NETWORK / explicit hosted profile: fetch the owner-approved target architecture for the active stable repository UUID, validate it and cache it locally. Sends only the opaque UUID with bearer auth; never source or paths. HTTP failures are returned as actionable AUTH_REQUIRED/FORBIDDEN/NOT_FOUND/REPOSITORY_NOT_READY states.', inputSchema: {type: 'object', properties: {timeout_ms: {type: 'integer', minimum: 1000, maximum: 120000, default: 30000}}}, run: (g, a, ctx) => ta.tPullArchitectureContract(g, a, ctx)},
146
- {cap: 'hosted', name: 'preview_sync', description: 'LOCAL SAFETY PREVIEW / no network: serialize the exact bounded hosted payload, validate the HTTPS destination, and show hostname/path, repository UUID, fields, sections, node/edge counts, bytes and body hash. Returns a five-minute confirmation token for sync_graph. Source bodies, snippets, absolute paths, environment values, credentials, Git remotes and unknown fields are excluded.', inputSchema: {type: 'object', properties: {payload_version: {type: 'integer', enum: [2, 3], default: 3, description: '3 includes bounded architecture/health/stack/package evidence; 2 is explicit graph-only compatibility'}}}, run: (g, a, ctx) => ta.tPreviewSyncGraph(g, a, ctx)},
147
- {cap: 'hosted', name: 'sync_graph', description: 'NETWORK / explicit hosted profile. Uploads only the exact payload created by preview_sync and requires dry_run:false plus its short-lived confirm_token after user approval. Calling without dry_run:false remains a no-network preview compatibility alias. Source bodies, snippets, absolute paths and unknown fields are never uploaded.', inputSchema: {type: 'object', properties: {payload_version: {type: 'integer', enum: [2, 3], default: 3, description: 'Used only when producing a compatibility preview; the approved token pins the exact serialized payload'}, dry_run: {type: 'boolean', default: true, description: 'Compatibility safety switch. Must be explicitly false to send.'}, confirm_token: {type: 'string', maxLength: 64, description: 'Short-lived token returned by preview_sync for the exact destination and payload'}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 120000, default: 30000, description: 'Network timeout in milliseconds'}}}, run: (g, a, ctx) => ta.tSyncGraph(g, a, ctx)},
148
142
  ]
149
143
  // Every tool supports the same machine-output switch. Text mode is TextContent-only so large
150
144
  // analysis payloads are not duplicated into an agent's context; JSON opts into structuredContent
@@ -170,7 +164,7 @@ function buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps}) {
170
164
  // Import the tool modules (cache-busted when version > 0), build the catalog, apply the caps filter.
171
165
  // capsArg semantics: undefined/null = offline defaults; a
172
166
  // present string (even '') is an explicit selection, so "select nothing" really exposes nothing.
173
- export async function loadHotApi(version, capsArg) {
167
+ export async function loadHotApi(version, capsArg, {extensions: extensionDefinitions = []} = {}) {
174
168
  const v = version ? `?v=${version}` : ''
175
169
  const [tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv] = await Promise.all([
176
170
  import(new URL(`./tools-graph.mjs${v}`, import.meta.url).href),
@@ -185,18 +179,50 @@ export async function loadHotApi(version, capsArg) {
185
179
  import(new URL(`./tools-company.mjs${v}`, import.meta.url).href),
186
180
  import(new URL(`./tools-verified-change.mjs${v}`, import.meta.url).href),
187
181
  ])
182
+ const extensions = normalizeWeavatrixExtensions(extensionDefinitions)
183
+ const extensionProfiles = Object.assign({}, ...extensions.map((extension) => extension.profiles))
184
+ for (const name of Object.keys(extensionProfiles)) {
185
+ if (Object.hasOwn(PROFILE_CAPS, name)) throw new TypeError(`extension profile collides with core profile: ${name}`)
186
+ }
187
+ const profiles = {...PROFILE_CAPS, ...extensionProfiles}
188
188
  const raw = capsArg == null ? 'offline' : String(capsArg).trim()
189
- const profile = Object.hasOwn(PROFILE_CAPS, raw) ? raw : 'custom'
190
- const selected = PROFILE_CAPS[raw] || raw.split(',').map((s) => s.trim()).filter(Boolean)
191
- .flatMap((cap) => cap === 'online' ? ['advisories', 'hosted'] : [cap])
189
+ if (MOVED_PROFILES.has(raw) && !Object.hasOwn(extensionProfiles, raw)) {
190
+ throw new Error(`MCP profile "${raw}" moved to weavatrix-online; the MIT core exposes only offline network-free profiles`)
191
+ }
192
+ const profile = Object.hasOwn(profiles, raw) ? raw : 'custom'
193
+ const selected = profiles[raw] || raw.split(',').map((s) => s.trim()).filter(Boolean)
192
194
  const caps = new Set(selected)
193
- const all = buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps})
195
+ const coreTools = buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps})
196
+ const extensionTools = extensions.flatMap((extension) => extension.tools.map((tool) => ({...tool, extension: extension.name})))
197
+ const all = [...coreTools, ...extensionTools.map((tool) => ({
198
+ ...tool,
199
+ inputSchema: {
200
+ ...(tool.inputSchema || {type: 'object'}),
201
+ properties: {
202
+ ...(tool.inputSchema?.properties || {}),
203
+ output_format: {
204
+ type: 'string', enum: ['text', 'json'], default: 'text',
205
+ description: 'text returns concise TextContent; json also returns the stable structuredContent envelope',
206
+ },
207
+ },
208
+ },
209
+ }))]
210
+ const toolNames = new Set()
211
+ for (const tool of all) {
212
+ if (toolNames.has(tool.name)) throw new TypeError(`extension tool collides with an existing tool: ${tool.name}`)
213
+ toolNames.add(tool.name)
214
+ }
194
215
  const tools = all.filter((t) => caps.has(t.cap))
195
216
  return {
196
217
  tools,
197
218
  byName: new Map(tools.map((t) => [t.name, t])),
198
219
  caps,
199
220
  profile,
221
+ extensions: {
222
+ items: extensionRuntimeSummary(extensions),
223
+ auditProviders: extensions.flatMap((extension) => extension.auditProviders.map((provider) => ({...provider, extension: extension.name}))),
224
+ skills: extensions.flatMap((extension) => extension.skills.map((skill) => ({...skill, extension: extension.name}))),
225
+ },
200
226
  stalenessLine,
201
227
  resetStalenessCache,
202
228
  }
@@ -1,6 +1,6 @@
1
1
  const compareText = (left, right) => String(left).localeCompare(String(right), 'en')
2
2
 
3
- // Snapshot construction and hosted-payload validation must use the same stable
3
+ // Snapshot construction and source-free payload validation must use the same stable
4
4
  // member order or equivalent evidence can hash differently across the boundary.
5
5
  export function compareDuplicateMember(left, right) {
6
6
  return compareText(left.file, right.file) || left.startLine - right.startLine ||
@@ -54,9 +54,9 @@ export function buildHealthSection(graph, audit, repoRoot = null) {
54
54
  complexity: {thresholds: COMPLEXITY_THRESHOLDS, analyzed: 0, hotspots: []},
55
55
  }
56
56
  }
57
- // Public/private hosted evidence follows the same production-first path policy as
57
+ // Source-free extension evidence follows the same production-first path policy as
58
58
  // run_audit. Classified-only findings remain available locally through
59
- // include_classified:true, but must not turn a hosted production snapshot red.
59
+ // include_classified:true, but must not turn a production snapshot red.
60
60
  const scopedFindings = auditFindingPathScope(audit.findings, {repoRoot}).findings
61
61
  const scopedSummary = summarizeFindings(scopedFindings)
62
62
  const findings = bounded(scopedFindings.map(sanitizeFinding).filter(Boolean)
@@ -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)}`,
@@ -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 runInternalAudit(ctx.repoRoot, {
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 runInternalAudit(checkout, {graph, skipMalwareScan: true})
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 audit = await runInternalAudit(ctx.repoRoot, {
150
- graph: effectiveRawGraph(ctx),
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
- if (!groups.length) return `No clones at ≥${simMin}% similarity / ≥${tokMin} tokens (${mode} mode). Try lowering the thresholds.${smallPolicy}${suppressionNote}${boilerplateNote}`
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
-
@@ -1,4 +1,4 @@
1
- // Versioned, explicit wire schema for sync_graph. Never forward graph.json wholesale.
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, advisories, architecture, sync] = await Promise.all([
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
- hostedAction: 'Open Architecture -> choose intended style -> Save target & baseline',
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 (offline) or approve a target in Hosted, then call verify_architecture.',
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: 1, transport: 'http', status: 'COMPLETE', completeness: {complete: true, reasons: []}, totals: {contracts: 0, matches: 0, uncertain: 0, filesScanned: 0}, contracts: [], uncertain: []}
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 and supported static HTTP/GraphQL/gRPC/event models.'
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 = {
@@ -0,0 +1,2 @@
1
+ export {startMcpServer} from './mcp-server.mjs'
2
+ export {DEFAULT_CAPS} from './mcp/catalog.mjs'
@@ -1,9 +1,4 @@
1
- // weavatrix MCP server — the stdio entry point. The tool implementations live in src/mcp/*:
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 main() {
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 api = await catalog.loadHotApi(0, CAPS_ARG)
51
+ let extensions = await loadExtensions({version: 0})
52
+ let api = await catalog.loadHotApi(0, CAPS_ARG, {extensions})
47
53
  const runtimeInfo = () => ({
48
- ...runtimeVersionStatus({runningVersion: PKG_VERSION, packageJsonPath: PACKAGE_JSON_PATH}),
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 nextApi = await nextCatalog.loadHotApi(v, CAPS_ARG)
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: SERVER_INFO,
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
- if (!globalThis.__weavatrixMcpStarted) {
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
- main()
297
+ startMcpServer()
287
298
  }