mdcontext 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (251) hide show
  1. package/.changeset/config.json +9 -9
  2. package/.claude/settings.local.json +25 -0
  3. package/.github/workflows/claude-code-review.yml +44 -0
  4. package/.github/workflows/claude.yml +85 -0
  5. package/CONTRIBUTING.md +186 -0
  6. package/NOTES/NOTES +44 -0
  7. package/README.md +206 -3
  8. package/biome.json +1 -1
  9. package/dist/chunk-23UPXDNL.js +3044 -0
  10. package/dist/chunk-2W7MO2DL.js +1366 -0
  11. package/dist/chunk-3NUAZGMA.js +1689 -0
  12. package/dist/chunk-7TOWB2XB.js +366 -0
  13. package/dist/chunk-7XOTOADQ.js +3065 -0
  14. package/dist/chunk-AH2PDM2K.js +3042 -0
  15. package/dist/chunk-BNXWSZ63.js +3742 -0
  16. package/dist/chunk-BTL5DJVU.js +3222 -0
  17. package/dist/chunk-HDHYG7E4.js +104 -0
  18. package/dist/chunk-HLR4KZBP.js +3234 -0
  19. package/dist/chunk-IP3FRFEB.js +1045 -0
  20. package/dist/chunk-KHU56VDO.js +3042 -0
  21. package/dist/chunk-KRYIFLQR.js +85 -89
  22. package/dist/chunk-LBSDNLEM.js +287 -0
  23. package/dist/chunk-MNTQ7HCP.js +2643 -0
  24. package/dist/chunk-MUJELQQ6.js +1387 -0
  25. package/dist/chunk-MXJGMSLV.js +2199 -0
  26. package/dist/chunk-N6QJGC3Z.js +2636 -0
  27. package/dist/chunk-OBELGBPM.js +1713 -0
  28. package/dist/chunk-OT7R5XTA.js +3192 -0
  29. package/dist/chunk-P7X4RA2T.js +106 -0
  30. package/dist/chunk-PIDUQNC2.js +3185 -0
  31. package/dist/chunk-POGCDIH4.js +3187 -0
  32. package/dist/chunk-PSIEOQGZ.js +3043 -0
  33. package/dist/chunk-PVRT3IHA.js +3238 -0
  34. package/dist/chunk-QNN4TT23.js +1430 -0
  35. package/dist/chunk-RE3R45RJ.js +3042 -0
  36. package/dist/chunk-S7E6TFX6.js +718 -657
  37. package/dist/chunk-SG6GLU4U.js +1378 -0
  38. package/dist/chunk-SJCDV2ST.js +274 -0
  39. package/dist/chunk-SYE5XLF3.js +104 -0
  40. package/dist/chunk-T5VLYBZD.js +103 -0
  41. package/dist/chunk-TOQB7VWU.js +3238 -0
  42. package/dist/chunk-VFNMZ4ZQ.js +3228 -0
  43. package/dist/chunk-VVTGZNBT.js +1533 -1423
  44. package/dist/chunk-W7Q4RFEV.js +104 -0
  45. package/dist/chunk-XTYYVRLO.js +3190 -0
  46. package/dist/chunk-Y6MDYVJD.js +3063 -0
  47. package/dist/cli/main.js +4072 -629
  48. package/dist/index.d.ts +420 -33
  49. package/dist/index.js +8 -15
  50. package/dist/mcp/server.js +103 -7
  51. package/dist/schema-BAWSG7KY.js +22 -0
  52. package/dist/schema-E3QUPL26.js +20 -0
  53. package/dist/schema-EHL7WUT6.js +20 -0
  54. package/docs/019-USAGE.md +44 -5
  55. package/docs/020-current-implementation.md +8 -8
  56. package/docs/021-DOGFOODING-FINDINGS.md +1 -1
  57. package/docs/CONFIG.md +1123 -0
  58. package/docs/ERRORS.md +383 -0
  59. package/docs/summarization.md +320 -0
  60. package/justfile +40 -0
  61. package/package.json +39 -33
  62. package/research/INDEX.md +315 -0
  63. package/research/code-review/README.md +90 -0
  64. package/research/code-review/cli-error-handling-review.md +979 -0
  65. package/research/code-review/code-review-validation-report.md +464 -0
  66. package/research/code-review/main-ts-review.md +1128 -0
  67. package/research/config-docs/SUMMARY.md +357 -0
  68. package/research/config-docs/TEST-RESULTS.md +776 -0
  69. package/research/config-docs/TODO.md +542 -0
  70. package/research/config-docs/analysis.md +744 -0
  71. package/research/config-docs/fix-validation.md +502 -0
  72. package/research/config-docs/help-audit.md +264 -0
  73. package/research/config-docs/help-system-analysis.md +890 -0
  74. package/research/frontmatter/COMMENTS-ARE-SKIPPED.md +149 -0
  75. package/research/frontmatter/LLM-CODE-NAVIGATION.md +276 -0
  76. package/research/issue-review.md +603 -0
  77. package/research/llm-summarization/agent-cli-tools-2026.md +1082 -0
  78. package/research/llm-summarization/alternative-providers-2026.md +1428 -0
  79. package/research/llm-summarization/anthropic-2026.md +367 -0
  80. package/research/llm-summarization/claude-cli-integration.md +1706 -0
  81. package/research/llm-summarization/cli-integration-patterns.md +3155 -0
  82. package/research/llm-summarization/openai-2026.md +473 -0
  83. package/research/llm-summarization/openai-compatible-providers-2026.md +1022 -0
  84. package/research/llm-summarization/opencode-cli-integration.md +1552 -0
  85. package/research/llm-summarization/prompt-engineering-2026.md +1426 -0
  86. package/research/llm-summarization/prototype-results.md +56 -0
  87. package/research/llm-summarization/provider-switching-patterns-2026.md +2153 -0
  88. package/research/llm-summarization/typescript-llm-libraries-2026.md +2436 -0
  89. package/research/mdcontext-pudding/00-EXECUTIVE-SUMMARY.md +282 -0
  90. package/research/mdcontext-pudding/01-index-embed.md +956 -0
  91. package/research/mdcontext-pudding/02-search-COMMANDS.md +142 -0
  92. package/research/mdcontext-pudding/02-search-SUMMARY.md +146 -0
  93. package/research/mdcontext-pudding/02-search.md +970 -0
  94. package/research/mdcontext-pudding/03-context.md +779 -0
  95. package/research/mdcontext-pudding/04-navigation-and-analytics.md +803 -0
  96. package/research/mdcontext-pudding/04-tree.md +704 -0
  97. package/research/mdcontext-pudding/05-config.md +1038 -0
  98. package/research/mdcontext-pudding/06-links-summary.txt +87 -0
  99. package/research/mdcontext-pudding/06-links.md +679 -0
  100. package/research/mdcontext-pudding/07-stats.md +693 -0
  101. package/research/mdcontext-pudding/BUG-FIX-PLAN.md +388 -0
  102. package/research/mdcontext-pudding/P0-BUG-VALIDATION.md +167 -0
  103. package/research/mdcontext-pudding/README.md +168 -0
  104. package/research/mdcontext-pudding/TESTING-SUMMARY.md +128 -0
  105. package/research/research-quality-review.md +834 -0
  106. package/research/semantic-search/embedding-text-analysis.md +156 -0
  107. package/research/semantic-search/multi-word-failure-reproduction.md +171 -0
  108. package/research/semantic-search/query-processing-analysis.md +207 -0
  109. package/research/semantic-search/root-cause-and-solution.md +114 -0
  110. package/research/semantic-search/threshold-validation-report.md +69 -0
  111. package/research/semantic-search/vector-search-analysis.md +63 -0
  112. package/research/test-path-issues.md +276 -0
  113. package/review/ALP-76/1-error-type-design.md +962 -0
  114. package/review/ALP-76/2-error-handling-patterns.md +906 -0
  115. package/review/ALP-76/3-error-presentation.md +624 -0
  116. package/review/ALP-76/4-test-coverage.md +625 -0
  117. package/review/ALP-76/5-migration-completeness.md +440 -0
  118. package/review/ALP-76/6-effect-best-practices.md +755 -0
  119. package/scripts/apply-branch-protection.sh +47 -0
  120. package/scripts/branch-protection-templates.json +79 -0
  121. package/scripts/prototype-summarization.ts +346 -0
  122. package/scripts/rebuild-hnswlib.js +32 -37
  123. package/scripts/setup-branch-protection.sh +64 -0
  124. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/active-provider.json +7 -0
  125. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/bm25.json +541 -0
  126. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/bm25.meta.json +5 -0
  127. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/config.json +8 -0
  128. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/embeddings/openai_text-embedding-3-small_512/vectors.bin +0 -0
  129. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/embeddings/openai_text-embedding-3-small_512/vectors.meta.bin +0 -0
  130. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/indexes/documents.json +60 -0
  131. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/indexes/links.json +13 -0
  132. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/indexes/sections.json +1197 -0
  133. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/configuration-management.md +99 -0
  134. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/distributed-systems.md +92 -0
  135. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/error-handling.md +78 -0
  136. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/failure-automation.md +55 -0
  137. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/job-context.md +69 -0
  138. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/process-orchestration.md +99 -0
  139. package/src/cli/argv-preprocessor.test.ts +2 -2
  140. package/src/cli/cli.test.ts +230 -33
  141. package/src/cli/commands/config-cmd.ts +642 -0
  142. package/src/cli/commands/context.ts +97 -9
  143. package/src/cli/commands/duplicates.ts +122 -0
  144. package/src/cli/commands/embeddings.ts +529 -0
  145. package/src/cli/commands/index-cmd.ts +210 -30
  146. package/src/cli/commands/index.ts +3 -0
  147. package/src/cli/commands/search.ts +894 -64
  148. package/src/cli/commands/stats.ts +3 -0
  149. package/src/cli/commands/tree.ts +26 -5
  150. package/src/cli/config-layer.ts +176 -0
  151. package/src/cli/error-handler.test.ts +235 -0
  152. package/src/cli/error-handler.ts +655 -0
  153. package/src/cli/flag-schemas.ts +66 -0
  154. package/src/cli/help.ts +209 -7
  155. package/src/cli/main.ts +348 -58
  156. package/src/cli/options.ts +10 -0
  157. package/src/cli/shared-error-handling.ts +199 -0
  158. package/src/cli/utils.ts +150 -17
  159. package/src/config/file-provider.test.ts +320 -0
  160. package/src/config/file-provider.ts +273 -0
  161. package/src/config/index.ts +72 -0
  162. package/src/config/integration.test.ts +667 -0
  163. package/src/config/precedence.test.ts +277 -0
  164. package/src/config/precedence.ts +451 -0
  165. package/src/config/schema.test.ts +414 -0
  166. package/src/config/schema.ts +603 -0
  167. package/src/config/service.test.ts +320 -0
  168. package/src/config/service.ts +243 -0
  169. package/src/config/testing.test.ts +264 -0
  170. package/src/config/testing.ts +110 -0
  171. package/src/core/types.ts +6 -33
  172. package/src/duplicates/detector.test.ts +183 -0
  173. package/src/duplicates/detector.ts +414 -0
  174. package/src/duplicates/index.ts +18 -0
  175. package/src/embeddings/embedding-namespace.test.ts +300 -0
  176. package/src/embeddings/embedding-namespace.ts +947 -0
  177. package/src/embeddings/heading-boost.test.ts +222 -0
  178. package/src/embeddings/hnsw-build-options.test.ts +198 -0
  179. package/src/embeddings/hyde.test.ts +272 -0
  180. package/src/embeddings/hyde.ts +264 -0
  181. package/src/embeddings/index.ts +2 -0
  182. package/src/embeddings/openai-provider.ts +332 -83
  183. package/src/embeddings/pricing.json +22 -0
  184. package/src/embeddings/provider-constants.ts +204 -0
  185. package/src/embeddings/provider-errors.test.ts +967 -0
  186. package/src/embeddings/provider-errors.ts +565 -0
  187. package/src/embeddings/provider-factory.test.ts +240 -0
  188. package/src/embeddings/provider-factory.ts +225 -0
  189. package/src/embeddings/provider-integration.test.ts +788 -0
  190. package/src/embeddings/query-preprocessing.test.ts +187 -0
  191. package/src/embeddings/semantic-search-threshold.test.ts +508 -0
  192. package/src/embeddings/semantic-search.ts +780 -93
  193. package/src/embeddings/types.ts +293 -16
  194. package/src/embeddings/vector-store.ts +486 -77
  195. package/src/embeddings/voyage-provider.ts +313 -0
  196. package/src/errors/errors.test.ts +845 -0
  197. package/src/errors/index.ts +533 -0
  198. package/src/index/ignore-patterns.test.ts +354 -0
  199. package/src/index/ignore-patterns.ts +305 -0
  200. package/src/index/indexer.ts +286 -48
  201. package/src/index/storage.ts +94 -30
  202. package/src/index/types.ts +40 -2
  203. package/src/index/watcher.ts +67 -9
  204. package/src/index.ts +22 -0
  205. package/src/integration/search-keyword.test.ts +678 -0
  206. package/src/mcp/server.ts +135 -6
  207. package/src/parser/parser.ts +18 -19
  208. package/src/parser/section-filter.test.ts +277 -0
  209. package/src/parser/section-filter.ts +125 -3
  210. package/src/search/__tests__/hybrid-search.test.ts +650 -0
  211. package/src/search/bm25-store.ts +366 -0
  212. package/src/search/cross-encoder.test.ts +253 -0
  213. package/src/search/cross-encoder.ts +406 -0
  214. package/src/search/fuzzy-search.test.ts +419 -0
  215. package/src/search/fuzzy-search.ts +273 -0
  216. package/src/search/hybrid-search.ts +448 -0
  217. package/src/search/path-matcher.test.ts +276 -0
  218. package/src/search/path-matcher.ts +33 -0
  219. package/src/search/searcher.test.ts +99 -1
  220. package/src/search/searcher.ts +189 -67
  221. package/src/search/wink-bm25.d.ts +30 -0
  222. package/src/summarization/cli-providers/claude.ts +202 -0
  223. package/src/summarization/cli-providers/detection.test.ts +273 -0
  224. package/src/summarization/cli-providers/detection.ts +118 -0
  225. package/src/summarization/cli-providers/index.ts +8 -0
  226. package/src/summarization/cost.test.ts +139 -0
  227. package/src/summarization/cost.ts +102 -0
  228. package/src/summarization/error-handler.test.ts +127 -0
  229. package/src/summarization/error-handler.ts +111 -0
  230. package/src/summarization/index.ts +102 -0
  231. package/src/summarization/pipeline.test.ts +498 -0
  232. package/src/summarization/pipeline.ts +231 -0
  233. package/src/summarization/prompts.test.ts +269 -0
  234. package/src/summarization/prompts.ts +133 -0
  235. package/src/summarization/provider-factory.test.ts +396 -0
  236. package/src/summarization/provider-factory.ts +178 -0
  237. package/src/summarization/types.ts +184 -0
  238. package/src/summarize/summarizer.ts +104 -35
  239. package/src/types/huggingface-transformers.d.ts +66 -0
  240. package/tests/fixtures/cli/.mdcontext/active-provider.json +7 -0
  241. package/tests/fixtures/cli/.mdcontext/embeddings/openai_text-embedding-3-small_512/vectors.bin +0 -0
  242. package/tests/fixtures/cli/.mdcontext/embeddings/openai_text-embedding-3-small_512/vectors.meta.bin +0 -0
  243. package/tests/fixtures/cli/.mdcontext/indexes/documents.json +4 -4
  244. package/tests/fixtures/cli/.mdcontext/indexes/sections.json +14 -0
  245. package/tests/integration/embed-index.test.ts +712 -0
  246. package/tests/integration/search-context.test.ts +469 -0
  247. package/tests/integration/search-semantic.test.ts +522 -0
  248. package/vitest.config.ts +1 -6
  249. package/AGENTS.md +0 -46
  250. package/tests/fixtures/cli/.mdcontext/vectors.bin +0 -0
  251. package/tests/fixtures/cli/.mdcontext/vectors.meta.json +0 -1264
package/src/mcp/server.ts CHANGED
@@ -6,7 +6,14 @@
6
6
  * Exposes markdown analysis tools for Claude integration
7
7
  */
8
8
 
9
+ import { createRequire } from 'node:module'
9
10
  import * as path from 'node:path'
11
+
12
+ // Read version from package.json using createRequire for ESM compatibility
13
+ const require = createRequire(import.meta.url)
14
+ const packageJson = require('../../package.json') as { version: string }
15
+ const MCP_VERSION: string = packageJson.version
16
+
10
17
  import { Server } from '@modelcontextprotocol/sdk/server/index.js'
11
18
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
12
19
  import type { CallToolResult, Tool } from '@modelcontextprotocol/sdk/types.js'
@@ -17,7 +24,11 @@ import {
17
24
  import { Effect } from 'effect'
18
25
  import type { MdSection } from '../core/types.js'
19
26
  import { semanticSearch } from '../embeddings/semantic-search.js'
20
- import { buildIndex } from '../index/indexer.js'
27
+ import {
28
+ buildIndex,
29
+ getIncomingLinks,
30
+ getOutgoingLinks,
31
+ } from '../index/indexer.js'
21
32
  import { parseFile } from '../parser/parser.js'
22
33
  import { search } from '../search/searcher.js'
23
34
  import { formatSummary, summarizeFile } from '../summarize/summarizer.js'
@@ -52,8 +63,8 @@ const tools: Tool[] = [
52
63
  },
53
64
  threshold: {
54
65
  type: 'number',
55
- description: 'Minimum similarity threshold 0-1 (default: 0.5)',
56
- default: 0.5,
66
+ description: 'Minimum similarity threshold 0-1 (default: 0.35)',
67
+ default: 0.35,
57
68
  },
58
69
  },
59
70
  required: ['query'],
@@ -154,6 +165,36 @@ const tools: Tool[] = [
154
165
  },
155
166
  },
156
167
  },
168
+ {
169
+ name: 'md_links',
170
+ description:
171
+ 'Get outgoing links from a markdown file. Shows what files this document references/links to.',
172
+ inputSchema: {
173
+ type: 'object',
174
+ properties: {
175
+ path: {
176
+ type: 'string',
177
+ description: 'Path to the markdown file',
178
+ },
179
+ },
180
+ required: ['path'],
181
+ },
182
+ },
183
+ {
184
+ name: 'md_backlinks',
185
+ description:
186
+ 'Get incoming links to a markdown file. Shows what files reference/link to this document.',
187
+ inputSchema: {
188
+ type: 'object',
189
+ properties: {
190
+ path: {
191
+ type: 'string',
192
+ description: 'Path to the markdown file',
193
+ },
194
+ },
195
+ required: ['path'],
196
+ },
197
+ },
157
198
  ]
158
199
 
159
200
  // ============================================================================
@@ -167,8 +208,11 @@ const handleMdSearch = async (
167
208
  const query = args.query as string
168
209
  const limit = (args.limit as number) ?? 5
169
210
  const pathFilter = args.path_filter as string | undefined
170
- const threshold = (args.threshold as number) ?? 0.5
211
+ const threshold = (args.threshold as number) ?? 0.35
171
212
 
213
+ // Note: catchAll is intentional at this MCP boundary layer.
214
+ // MCP protocol requires JSON error responses, so we convert typed errors
215
+ // to { error: message } format for protocol compliance.
172
216
  const result = await Effect.runPromise(
173
217
  semanticSearch(rootPath, query, {
174
218
  limit,
@@ -228,6 +272,7 @@ const handleMdContext = async (
228
272
  ? filePath
229
273
  : path.join(rootPath, filePath)
230
274
 
275
+ // Note: catchAll is intentional - MCP boundary converts errors to JSON format
231
276
  const result = await Effect.runPromise(
232
277
  summarizeFile(resolvedPath, { level, maxTokens }).pipe(
233
278
  Effect.catchAll((e) => Effect.succeed({ error: e.message })),
@@ -255,9 +300,9 @@ const handleMdStructure = async (
255
300
  ? filePath
256
301
  : path.join(rootPath, filePath)
257
302
 
303
+ // Note: catchAll is intentional - MCP boundary converts errors to JSON format
258
304
  const result = await Effect.runPromise(
259
305
  parseFile(resolvedPath).pipe(
260
- Effect.mapError((e) => new Error(`${e._tag}: ${e.message}`)),
261
306
  Effect.catchAll((e) => Effect.succeed({ error: e.message })),
262
307
  ),
263
308
  )
@@ -310,6 +355,7 @@ const handleMdKeywordSearch = async (
310
355
  const hasTable = args.has_table as boolean | undefined
311
356
  const limit = (args.limit as number) ?? 20
312
357
 
358
+ // Note: catchAll is intentional - MCP boundary converts errors to JSON format
313
359
  const result = await Effect.runPromise(
314
360
  search(rootPath, {
315
361
  heading,
@@ -381,6 +427,7 @@ const handleMdIndex = async (
381
427
  ? indexPath
382
428
  : path.join(rootPath, indexPath)
383
429
 
430
+ // Note: catchAll is intentional - MCP boundary converts errors to JSON format
384
431
  const result = await Effect.runPromise(
385
432
  buildIndex(resolvedPath, { force }).pipe(
386
433
  Effect.catchAll((e) => Effect.succeed({ error: e.message })),
@@ -404,6 +451,84 @@ const handleMdIndex = async (
404
451
  }
405
452
  }
406
453
 
454
+ const handleMdLinks = async (
455
+ args: Record<string, unknown>,
456
+ rootPath: string,
457
+ ): Promise<CallToolResult> => {
458
+ const filePath = args.path as string
459
+ const resolvedPath = path.isAbsolute(filePath)
460
+ ? filePath
461
+ : path.join(rootPath, filePath)
462
+
463
+ // Note: catchAll is intentional - MCP boundary converts errors to JSON format
464
+ const result = await Effect.runPromise(
465
+ getOutgoingLinks(rootPath, resolvedPath).pipe(
466
+ Effect.catchAll((e) => Effect.succeed({ error: e.message })),
467
+ ),
468
+ )
469
+
470
+ if ('error' in result) {
471
+ return {
472
+ content: [{ type: 'text', text: `Error: ${result.error}` }],
473
+ isError: true,
474
+ }
475
+ }
476
+
477
+ const links = result as readonly string[]
478
+ const relativePath = path.relative(rootPath, resolvedPath)
479
+
480
+ return {
481
+ content: [
482
+ {
483
+ type: 'text',
484
+ text:
485
+ links.length > 0
486
+ ? `Outgoing links from ${relativePath}:\n\n${links.map((l) => ` -> ${l}`).join('\n')}\n\nTotal: ${links.length} links`
487
+ : `No outgoing links from ${relativePath}`,
488
+ },
489
+ ],
490
+ }
491
+ }
492
+
493
+ const handleMdBacklinks = async (
494
+ args: Record<string, unknown>,
495
+ rootPath: string,
496
+ ): Promise<CallToolResult> => {
497
+ const filePath = args.path as string
498
+ const resolvedPath = path.isAbsolute(filePath)
499
+ ? filePath
500
+ : path.join(rootPath, filePath)
501
+
502
+ // Note: catchAll is intentional - MCP boundary converts errors to JSON format
503
+ const result = await Effect.runPromise(
504
+ getIncomingLinks(rootPath, resolvedPath).pipe(
505
+ Effect.catchAll((e) => Effect.succeed({ error: e.message })),
506
+ ),
507
+ )
508
+
509
+ if ('error' in result) {
510
+ return {
511
+ content: [{ type: 'text', text: `Error: ${result.error}` }],
512
+ isError: true,
513
+ }
514
+ }
515
+
516
+ const links = result as readonly string[]
517
+ const relativePath = path.relative(rootPath, resolvedPath)
518
+
519
+ return {
520
+ content: [
521
+ {
522
+ type: 'text',
523
+ text:
524
+ links.length > 0
525
+ ? `Incoming links to ${relativePath}:\n\n${links.map((l) => ` <- ${l}`).join('\n')}\n\nTotal: ${links.length} backlinks`
526
+ : `No incoming links to ${relativePath}`,
527
+ },
528
+ ],
529
+ }
530
+ }
531
+
407
532
  // ============================================================================
408
533
  // MCP Server Setup
409
534
  // ============================================================================
@@ -412,7 +537,7 @@ const createServer = (rootPath: string) => {
412
537
  const server = new Server(
413
538
  {
414
539
  name: 'mdcontext-mcp',
415
- version: '0.1.0',
540
+ version: MCP_VERSION,
416
541
  },
417
542
  {
418
543
  capabilities: {
@@ -441,6 +566,10 @@ const createServer = (rootPath: string) => {
441
566
  return handleMdKeywordSearch(args ?? {}, rootPath)
442
567
  case 'md_index':
443
568
  return handleMdIndex(args ?? {}, rootPath)
569
+ case 'md_links':
570
+ return handleMdLinks(args ?? {}, rootPath)
571
+ case 'md_backlinks':
572
+ return handleMdBacklinks(args ?? {}, rootPath)
444
573
  default:
445
574
  return {
446
575
  content: [{ type: 'text', text: `Unknown tool: ${name}` }],
@@ -21,6 +21,7 @@ import type {
21
21
  MdSection,
22
22
  ParseError,
23
23
  } from '../core/types.js'
24
+ import { FileReadError } from '../errors/index.js'
24
25
  import { countTokensApprox, countWords } from '../utils/tokens.js'
25
26
 
26
27
  // ============================================================================
@@ -362,31 +363,29 @@ export const parse = (
362
363
 
363
364
  /**
364
365
  * Parse a markdown file from the filesystem
366
+ *
367
+ * @throws ParseError - File content cannot be parsed
368
+ * @throws FileReadError - File cannot be read from filesystem
365
369
  */
366
370
  export const parseFile = (
367
371
  filePath: string,
368
- ): Effect.Effect<
369
- MdDocument,
370
- ParseError | { _tag: 'IoError'; message: string; path: string }
371
- > =>
372
+ ): Effect.Effect<MdDocument, ParseError | FileReadError> =>
372
373
  Effect.gen(function* () {
373
374
  const fs = yield* Effect.promise(() => import('node:fs/promises'))
374
375
 
375
- let content: string
376
- let stats: Awaited<ReturnType<typeof fs.stat>>
377
-
378
- try {
379
- ;[content, stats] = yield* Effect.all([
380
- Effect.promise(() => fs.readFile(filePath, 'utf-8')),
381
- Effect.promise(() => fs.stat(filePath)),
382
- ])
383
- } catch (error) {
384
- return yield* Effect.fail({
385
- _tag: 'IoError' as const,
386
- message: error instanceof Error ? error.message : 'Unknown error',
387
- path: filePath,
388
- })
389
- }
376
+ const [content, stats] = yield* Effect.tryPromise({
377
+ try: () =>
378
+ Promise.all([
379
+ fs.readFile(filePath, 'utf-8'),
380
+ fs.stat(filePath),
381
+ ] as const),
382
+ catch: (error) =>
383
+ new FileReadError({
384
+ path: filePath,
385
+ message: error instanceof Error ? error.message : 'Unknown error',
386
+ cause: error,
387
+ }),
388
+ })
390
389
 
391
390
  return yield* parse(content, {
392
391
  path: filePath,
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Tests for section filtering utilities
3
+ */
4
+
5
+ import { describe, expect, it } from 'vitest'
6
+ import type { HeadingLevel, MdDocument, MdSection } from '../core/types.js'
7
+ import {
8
+ buildSectionList,
9
+ extractSectionContent,
10
+ filterDocumentSections,
11
+ filterExcludedSections,
12
+ } from './section-filter.js'
13
+
14
+ // Helper to create minimal section for testing
15
+ const createSection = (
16
+ heading: string,
17
+ level: HeadingLevel,
18
+ children: MdSection[] = [],
19
+ tokenCount: number = 100,
20
+ ): MdSection => ({
21
+ id: `section-${heading.toLowerCase().replace(/\s+/g, '-')}`,
22
+ heading,
23
+ level,
24
+ content: `# ${heading}\n\nContent for ${heading}`,
25
+ plainText: `Content for ${heading}`,
26
+ startLine: 1,
27
+ endLine: 10,
28
+ children,
29
+ metadata: {
30
+ wordCount: 10,
31
+ tokenCount,
32
+ hasCode: false,
33
+ hasList: false,
34
+ hasTable: false,
35
+ },
36
+ })
37
+
38
+ // Helper to create minimal document for testing
39
+ const createDocument = (sections: MdSection[]): MdDocument => ({
40
+ id: 'test-doc',
41
+ path: '/test/doc.md',
42
+ title: 'Test Document',
43
+ sections,
44
+ links: [],
45
+ codeBlocks: [],
46
+ metadata: {
47
+ tokenCount: sections.reduce((acc, s) => acc + s.metadata.tokenCount, 0),
48
+ headingCount: sections.length,
49
+ linkCount: 0,
50
+ codeBlockCount: 0,
51
+ wordCount: 100,
52
+ lastModified: new Date(),
53
+ indexedAt: new Date(),
54
+ },
55
+ frontmatter: {},
56
+ })
57
+
58
+ describe('section-filter', () => {
59
+ describe('filterExcludedSections', () => {
60
+ const sectionList = [
61
+ { number: '1', heading: 'Introduction', level: 1, tokenCount: 100 },
62
+ { number: '1.1', heading: 'Overview', level: 2, tokenCount: 50 },
63
+ { number: '2', heading: 'Installation', level: 1, tokenCount: 200 },
64
+ { number: '2.1', heading: 'Requirements', level: 2, tokenCount: 75 },
65
+ { number: '2.2', heading: 'Setup Steps', level: 2, tokenCount: 80 },
66
+ { number: '3', heading: 'API Reference', level: 1, tokenCount: 500 },
67
+ { number: '3.1', heading: 'Methods', level: 2, tokenCount: 300 },
68
+ { number: '4', heading: 'License', level: 1, tokenCount: 50 },
69
+ ]
70
+
71
+ it('returns all sections when no exclusion patterns provided', () => {
72
+ const result = filterExcludedSections(sectionList, [])
73
+ expect(result).toEqual(sectionList)
74
+ })
75
+
76
+ it('excludes sections by exact heading match', () => {
77
+ const result = filterExcludedSections(sectionList, ['License'])
78
+ expect(result).toHaveLength(7)
79
+ expect(result.find((s) => s.heading === 'License')).toBeUndefined()
80
+ })
81
+
82
+ it('excludes sections by partial heading match', () => {
83
+ const result = filterExcludedSections(sectionList, ['Setup'])
84
+ expect(result).toHaveLength(7)
85
+ expect(result.find((s) => s.heading === 'Setup Steps')).toBeUndefined()
86
+ })
87
+
88
+ it('excludes sections by glob pattern', () => {
89
+ const result = filterExcludedSections(sectionList, ['*Reference*'])
90
+ expect(result).toHaveLength(7)
91
+ expect(result.find((s) => s.heading === 'API Reference')).toBeUndefined()
92
+ })
93
+
94
+ it('excludes sections by section number', () => {
95
+ const result = filterExcludedSections(sectionList, ['2.1'])
96
+ expect(result).toHaveLength(7)
97
+ expect(result.find((s) => s.number === '2.1')).toBeUndefined()
98
+ })
99
+
100
+ it('excludes multiple sections with multiple patterns', () => {
101
+ const result = filterExcludedSections(sectionList, [
102
+ 'License',
103
+ 'Overview',
104
+ ])
105
+ expect(result).toHaveLength(6)
106
+ expect(result.find((s) => s.heading === 'License')).toBeUndefined()
107
+ expect(result.find((s) => s.heading === 'Overview')).toBeUndefined()
108
+ })
109
+
110
+ it('handles case-insensitive matching', () => {
111
+ const result = filterExcludedSections(sectionList, ['LICENSE'])
112
+ expect(result).toHaveLength(7)
113
+ expect(result.find((s) => s.heading === 'License')).toBeUndefined()
114
+ })
115
+ })
116
+
117
+ describe('extractSectionContent with exclusion', () => {
118
+ const doc = createDocument([
119
+ createSection('Introduction', 1, [
120
+ createSection('Getting Started', 2),
121
+ createSection('Quick Start', 2),
122
+ ]),
123
+ createSection('API', 1, [
124
+ createSection('Methods', 2),
125
+ createSection('Properties', 2),
126
+ ]),
127
+ createSection('License', 1),
128
+ ])
129
+
130
+ it('extracts all matching sections without exclusion', () => {
131
+ const result = extractSectionContent(doc, '*')
132
+ expect(result.matchedNumbers).toHaveLength(7)
133
+ expect(result.excludedNumbers).toHaveLength(0)
134
+ })
135
+
136
+ it('excludes sections matching exclusion pattern', () => {
137
+ const result = extractSectionContent(doc, '*', {
138
+ exclude: ['License'],
139
+ })
140
+ expect(result.matchedNumbers).toHaveLength(6)
141
+ expect(result.excludedNumbers).toEqual(['3'])
142
+ expect(
143
+ result.sections.find((s) => s.heading === 'License'),
144
+ ).toBeUndefined()
145
+ })
146
+
147
+ it('reports excluded sections in excludedNumbers', () => {
148
+ const result = extractSectionContent(doc, '*', {
149
+ exclude: ['Quick Start', 'Properties'],
150
+ })
151
+ expect(result.excludedNumbers).toContain('1.2')
152
+ expect(result.excludedNumbers).toContain('2.2')
153
+ })
154
+
155
+ it('combines shallow and exclude options', () => {
156
+ const result = extractSectionContent(doc, 'Introduction', {
157
+ shallow: true,
158
+ exclude: ['Getting Started'],
159
+ })
160
+ // With shallow, we only get Introduction without children
161
+ // The exclude pattern only affects the matched sections list
162
+ expect(result.sections).toHaveLength(1)
163
+ expect(result.sections[0]?.heading).toBe('Introduction')
164
+ })
165
+ })
166
+
167
+ describe('filterDocumentSections', () => {
168
+ const doc = createDocument([
169
+ createSection('Introduction', 1, [
170
+ createSection('Overview', 2),
171
+ createSection('Goals', 2),
172
+ ]),
173
+ createSection('Installation', 1),
174
+ createSection('License', 1),
175
+ ])
176
+
177
+ it('returns original document when no exclusion patterns', () => {
178
+ const result = filterDocumentSections(doc, [])
179
+ expect(result.document).toBe(doc)
180
+ expect(result.excludedCount).toBe(0)
181
+ })
182
+
183
+ it('filters out matching sections from document', () => {
184
+ const result = filterDocumentSections(doc, ['License'])
185
+ expect(result.excludedCount).toBe(1)
186
+ expect(result.document.sections).toHaveLength(2)
187
+ expect(
188
+ result.document.sections.find((s) => s.heading === 'License'),
189
+ ).toBeUndefined()
190
+ })
191
+
192
+ it('filters out nested sections', () => {
193
+ const result = filterDocumentSections(doc, ['Overview'])
194
+ expect(result.excludedCount).toBe(1)
195
+ // Find Introduction section
196
+ const intro = result.document.sections.find(
197
+ (s) => s.heading === 'Introduction',
198
+ )
199
+ expect(intro).toBeDefined()
200
+ // Overview should be removed from children
201
+ expect(
202
+ intro?.children.find((c) => c.heading === 'Overview'),
203
+ ).toBeUndefined()
204
+ // Goals should still be there
205
+ expect(intro?.children.find((c) => c.heading === 'Goals')).toBeDefined()
206
+ })
207
+
208
+ it('filters multiple sections with glob pattern', () => {
209
+ const result = filterDocumentSections(doc, ['*stallation*', 'License'])
210
+ expect(result.excludedCount).toBe(2)
211
+ expect(result.document.sections).toHaveLength(1)
212
+ expect(result.document.sections[0]?.heading).toBe('Introduction')
213
+ })
214
+
215
+ it('preserves document structure for non-matching sections', () => {
216
+ const result = filterDocumentSections(doc, ['NonExistent'])
217
+ expect(result.document).toBe(doc)
218
+ expect(result.excludedCount).toBe(0)
219
+ })
220
+
221
+ it('counts descendants when parent section is excluded', () => {
222
+ // Introduction has 2 children (Overview, Goals), so excluding Introduction
223
+ // should count 3 total excluded sections
224
+ const result = filterDocumentSections(doc, ['Introduction'])
225
+ expect(result.excludedCount).toBe(3) // Introduction + Overview + Goals
226
+ expect(result.document.sections).toHaveLength(2) // Installation + License
227
+ expect(
228
+ result.document.sections.find((s) => s.heading === 'Introduction'),
229
+ ).toBeUndefined()
230
+ })
231
+
232
+ it('counts deeply nested descendants correctly', () => {
233
+ const deepDoc = createDocument([
234
+ createSection('Root', 1, [
235
+ createSection('Child 1', 2, [
236
+ createSection('Grandchild 1', 3),
237
+ createSection('Grandchild 2', 3),
238
+ ]),
239
+ createSection('Child 2', 2),
240
+ ]),
241
+ createSection('Other', 1),
242
+ ])
243
+ const result = filterDocumentSections(deepDoc, ['Root'])
244
+ // Root + Child 1 + Grandchild 1 + Grandchild 2 + Child 2 = 5
245
+ expect(result.excludedCount).toBe(5)
246
+ expect(result.document.sections).toHaveLength(1)
247
+ expect(result.document.sections[0]?.heading).toBe('Other')
248
+ })
249
+
250
+ it('does not double-count when multiple patterns match same section', () => {
251
+ const result = filterDocumentSections(doc, ['Introduction', 'Intro*'])
252
+ // Both patterns match Introduction, but should only count once
253
+ // Introduction + Overview + Goals = 3
254
+ expect(result.excludedCount).toBe(3)
255
+ })
256
+ })
257
+
258
+ describe('buildSectionList', () => {
259
+ const doc = createDocument([
260
+ createSection('A', 1, [
261
+ createSection('A.1', 2, [createSection('A.1.1', 3)]),
262
+ createSection('A.2', 2),
263
+ ]),
264
+ createSection('B', 1),
265
+ ])
266
+
267
+ it('assigns correct hierarchical numbers', () => {
268
+ const list = buildSectionList(doc)
269
+ expect(list).toHaveLength(5)
270
+ expect(list[0]).toMatchObject({ number: '1', heading: 'A' })
271
+ expect(list[1]).toMatchObject({ number: '1.1', heading: 'A.1' })
272
+ expect(list[2]).toMatchObject({ number: '1.1.1', heading: 'A.1.1' })
273
+ expect(list[3]).toMatchObject({ number: '1.2', heading: 'A.2' })
274
+ expect(list[4]).toMatchObject({ number: '2', heading: 'B' })
275
+ })
276
+ })
277
+ })