primarysource 0.1.0__py3-none-any.whl

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 (303) hide show
  1. primarysource/__init__.py +5 -0
  2. primarysource/bbf/__init__.py +56 -0
  3. primarysource/bbf/_xml.py +63 -0
  4. primarysource/bbf/catalogue.json +777 -0
  5. primarysource/bbf/catalogue.py +137 -0
  6. primarysource/bbf/cli.py +79 -0
  7. primarysource/bbf/datamodel.py +281 -0
  8. primarysource/bbf/importer.py +151 -0
  9. primarysource/bbf/library.py +380 -0
  10. primarysource/bbf/server.py +72 -0
  11. primarysource/budget.py +63 -0
  12. primarysource/c2sp/__init__.py +53 -0
  13. primarysource/c2sp/_fetch.py +5 -0
  14. primarysource/c2sp/catalogue.json +231 -0
  15. primarysource/c2sp/catalogue.py +95 -0
  16. primarysource/c2sp/cli.py +62 -0
  17. primarysource/c2sp/importer.py +90 -0
  18. primarysource/c2sp/library.py +309 -0
  19. primarysource/c2sp/markdown.py +31 -0
  20. primarysource/c2sp/server.py +68 -0
  21. primarysource/cavp/__init__.py +42 -0
  22. primarysource/cavp/cli.py +84 -0
  23. primarysource/cavp/data/vectors.toml +47 -0
  24. primarysource/cavp/importer.py +156 -0
  25. primarysource/cavp/model.py +279 -0
  26. primarysource/cavp/parse.py +269 -0
  27. primarysource/cavp/paths.py +17 -0
  28. primarysource/cavp/server.py +67 -0
  29. primarysource/cavp/tools.py +169 -0
  30. primarysource/cli.py +107 -0
  31. primarysource/cursor.py +82 -0
  32. primarysource/cve/__init__.py +5 -0
  33. primarysource/cve/__main__.py +6 -0
  34. primarysource/cve/cli.py +144 -0
  35. primarysource/cve/collection.py +20 -0
  36. primarysource/cve/config.py +58 -0
  37. primarysource/cve/errors.py +16 -0
  38. primarysource/cve/kernel.py +232 -0
  39. primarysource/cve/mcp.py +139 -0
  40. primarysource/cve/parse.py +318 -0
  41. primarysource/cve/py.typed +0 -0
  42. primarysource/cve/service.py +760 -0
  43. primarysource/cve/store.py +214 -0
  44. primarysource/cve/sync.py +438 -0
  45. primarysource/cve/views.py +304 -0
  46. primarysource/debpolicy/__init__.py +16 -0
  47. primarysource/debpolicy/catalogue.py +263 -0
  48. primarysource/debpolicy/cli.py +12 -0
  49. primarysource/debpolicy/server.py +11 -0
  50. primarysource/draft/__init__.py +11 -0
  51. primarysource/draft/catalogue.py +117 -0
  52. primarysource/draft/cli.py +149 -0
  53. primarysource/draft/collection.py +21 -0
  54. primarysource/draft/datatracker.py +158 -0
  55. primarysource/draft/importer.py +163 -0
  56. primarysource/draft/licence.py +89 -0
  57. primarysource/draft/names.py +64 -0
  58. primarysource/draft/net.py +133 -0
  59. primarysource/draft/segment.py +192 -0
  60. primarysource/draft/server.py +91 -0
  61. primarysource/draft/status.py +146 -0
  62. primarysource/draft/store.py +199 -0
  63. primarysource/draft/tracked.json +104 -0
  64. primarysource/draft/views.py +430 -0
  65. primarysource/draft/xmlmeta.py +74 -0
  66. primarysource/ecma/__init__.py +52 -0
  67. primarysource/ecma/_pdf.py +143 -0
  68. primarysource/ecma/catalogue.json +22 -0
  69. primarysource/ecma/catalogue.py +100 -0
  70. primarysource/ecma/cli.py +67 -0
  71. primarysource/ecma/identify.py +25 -0
  72. primarysource/ecma/importer.py +108 -0
  73. primarysource/ecma/library.py +240 -0
  74. primarysource/ecma/server.py +67 -0
  75. primarysource/errors.py +29 -0
  76. primarysource/fetch.py +360 -0
  77. primarysource/fsutil.py +150 -0
  78. primarysource/gitdocs/__init__.py +15 -0
  79. primarysource/gitdocs/catalogue.py +116 -0
  80. primarysource/gitdocs/cli.py +12 -0
  81. primarysource/gitdocs/server.py +8 -0
  82. primarysource/home.py +65 -0
  83. primarysource/iana/__init__.py +32 -0
  84. primarysource/iana/catalogue.json +244 -0
  85. primarysource/iana/catalogue.py +69 -0
  86. primarysource/iana/cli.py +69 -0
  87. primarysource/iana/importer.py +157 -0
  88. primarysource/iana/model.py +325 -0
  89. primarysource/iana/reader.py +315 -0
  90. primarysource/iana/server.py +67 -0
  91. primarysource/iana/store.py +83 -0
  92. primarysource/iana/xmlsafe.py +47 -0
  93. primarysource/identify.py +186 -0
  94. primarysource/ieee/__init__.py +58 -0
  95. primarysource/ieee/_html.py +49 -0
  96. primarysource/ieee/catalogue.json +74 -0
  97. primarysource/ieee/catalogue.py +123 -0
  98. primarysource/ieee/cli.py +79 -0
  99. primarysource/ieee/identify.py +24 -0
  100. primarysource/ieee/ieee802.py +47 -0
  101. primarysource/ieee/importer.py +305 -0
  102. primarysource/ieee/posix.py +233 -0
  103. primarysource/ieee/server.py +79 -0
  104. primarysource/index.py +446 -0
  105. primarysource/iso/__init__.py +51 -0
  106. primarysource/iso/cli.py +98 -0
  107. primarysource/iso/designation.py +95 -0
  108. primarysource/iso/identify.py +100 -0
  109. primarysource/iso/importer.py +224 -0
  110. primarysource/iso/library.py +249 -0
  111. primarysource/iso/server.py +69 -0
  112. primarysource/itu/__init__.py +41 -0
  113. primarysource/itu/__main__.py +8 -0
  114. primarysource/itu/asn1mod.py +478 -0
  115. primarysource/itu/catalog.py +472 -0
  116. primarysource/itu/cli.py +445 -0
  117. primarysource/itu/collection.py +20 -0
  118. primarysource/itu/corpus.py +118 -0
  119. primarysource/itu/errata.py +16 -0
  120. primarysource/itu/formal.py +565 -0
  121. primarysource/itu/identify.py +26 -0
  122. primarysource/itu/index.py +490 -0
  123. primarysource/itu/mcp.py +474 -0
  124. primarysource/itu/py.typed +0 -0
  125. primarysource/itu/sources.py +600 -0
  126. primarysource/itu/text.py +609 -0
  127. primarysource/kconfig/__init__.py +16 -0
  128. primarysource/kconfig/catalogue.py +98 -0
  129. primarysource/kconfig/cli.py +12 -0
  130. primarysource/kconfig/server.py +8 -0
  131. primarysource/model.py +63 -0
  132. primarysource/nist/__init__.py +5 -0
  133. primarysource/nist/collection.py +46 -0
  134. primarysource/nist/data/acquired-nist.json +1657 -0
  135. primarysource/nist/data/nist-catalog.json +12090 -0
  136. primarysource/nist/data/sources.toml +1996 -0
  137. primarysource/nist/identify.py +41 -0
  138. primarysource/nist/ingest/__init__.py +1 -0
  139. primarysource/nist/ingest/acquire_nist.py +128 -0
  140. primarysource/nist/ingest/controls.py +82 -0
  141. primarysource/nist/ingest/discover_nist.py +218 -0
  142. primarysource/nist/ingest/extract.py +48 -0
  143. primarysource/nist/ingest/fetch.py +211 -0
  144. primarysource/nist/ingest/index.py +14 -0
  145. primarysource/nist/ingest/oscal.py +480 -0
  146. primarysource/nist/ingest/prose_layouts.py +236 -0
  147. primarysource/nist/ingest/sources.py +94 -0
  148. primarysource/nist/ingest/status.py +255 -0
  149. primarysource/nist/models.py +7 -0
  150. primarysource/nist/paths.py +33 -0
  151. primarysource/nist/server/__init__.py +3 -0
  152. primarysource/nist/server/corpus.py +38 -0
  153. primarysource/nist/server/mcp.py +19 -0
  154. primarysource/nist/server/tools.py +35 -0
  155. primarysource/nist/spec.py +38 -0
  156. primarysource/pdfcorpus/__init__.py +16 -0
  157. primarysource/pdfcorpus/corpus.py +159 -0
  158. primarysource/pdfcorpus/fts.py +175 -0
  159. primarysource/pdfcorpus/index.py +70 -0
  160. primarysource/pdfcorpus/models.py +30 -0
  161. primarysource/pdfcorpus/server.py +96 -0
  162. primarysource/pdfcorpus/spec.py +37 -0
  163. primarysource/pdfcorpus/split.py +391 -0
  164. primarysource/pdfcorpus/tools.py +244 -0
  165. primarysource/pep/__init__.py +15 -0
  166. primarysource/pep/catalogue.py +191 -0
  167. primarysource/pep/cli.py +12 -0
  168. primarysource/pep/server.py +8 -0
  169. primarysource/pkgdocs/__init__.py +19 -0
  170. primarysource/pkgdocs/fetch.py +356 -0
  171. primarysource/pkgdocs/index.py +89 -0
  172. primarysource/pkgdocs/model.py +76 -0
  173. primarysource/pkgdocs/readers/__init__.py +1 -0
  174. primarysource/pkgdocs/readers/_common.py +134 -0
  175. primarysource/pkgdocs/readers/asciidoc.py +160 -0
  176. primarysource/pkgdocs/readers/latex.py +315 -0
  177. primarysource/pkgdocs/readers/markdown.py +173 -0
  178. primarysource/pkgdocs/readers/pod.py +132 -0
  179. primarysource/pkgdocs/readers/rst.py +239 -0
  180. primarysource/pkgdocs/readers/scdoc.py +108 -0
  181. primarysource/pkgdocs/runtime.py +552 -0
  182. primarysource/pkgdocs/serving.py +158 -0
  183. primarysource/pkgdocs/store.py +167 -0
  184. primarysource/pms/__init__.py +16 -0
  185. primarysource/pms/catalogue.py +88 -0
  186. primarysource/pms/cli.py +12 -0
  187. primarysource/pms/server.py +8 -0
  188. primarysource/py.typed +0 -0
  189. primarysource/readers/__init__.py +10 -0
  190. primarysource/readers/bookmarks.py +192 -0
  191. primarysource/readers/html.py +1312 -0
  192. primarysource/readers/markdown.py +189 -0
  193. primarysource/readers/pdf.py +187 -0
  194. primarysource/readers/xml.py +230 -0
  195. primarysource/refs.py +87 -0
  196. primarysource/registry.py +161 -0
  197. primarysource/render.py +49 -0
  198. primarysource/rfc/__init__.py +3 -0
  199. primarysource/rfc/__main__.py +6 -0
  200. primarysource/rfc/cite.py +204 -0
  201. primarysource/rfc/cli.py +903 -0
  202. primarysource/rfc/collection.py +19 -0
  203. primarysource/rfc/config.py +138 -0
  204. primarysource/rfc/errata.py +203 -0
  205. primarysource/rfc/fsutil.py +82 -0
  206. primarysource/rfc/graph.py +213 -0
  207. primarysource/rfc/index/__init__.py +1 -0
  208. primarysource/rfc/index/chunker.py +281 -0
  209. primarysource/rfc/index/embed.py +166 -0
  210. primarysource/rfc/index/hybrid.py +305 -0
  211. primarysource/rfc/index/lexical.py +223 -0
  212. primarysource/rfc/index/rfcxml.py +673 -0
  213. primarysource/rfc/index/search.py +508 -0
  214. primarysource/rfc/index/txtseg.py +585 -0
  215. primarysource/rfc/library.py +294 -0
  216. primarysource/rfc/mcp/__init__.py +1 -0
  217. primarysource/rfc/mcp/server.py +381 -0
  218. primarysource/rfc/packs/__init__.py +1 -0
  219. primarysource/rfc/packs/builder.py +228 -0
  220. primarysource/rfc/packs/collections/all-current-txt.json +10 -0
  221. primarysource/rfc/packs/collections/essentials.json +19 -0
  222. primarysource/rfc/packs/collections/fyi-current.json +10 -0
  223. primarysource/rfc/packs/collections/standards-current.json +10 -0
  224. primarysource/rfc/packs/collections/year-2025.json +10 -0
  225. primarysource/rfc/packs/collections/year-2026.json +10 -0
  226. primarysource/rfc/packs/collections.py +163 -0
  227. primarysource/rfc/packs/docsetexport.py +163 -0
  228. primarysource/rfc/packs/epub.py +159 -0
  229. primarysource/rfc/packs/export.py +306 -0
  230. primarysource/rfc/packs/htmlexport.py +82 -0
  231. primarysource/rfc/packs/serve.py +143 -0
  232. primarysource/rfc/packs/zimexport.py +171 -0
  233. primarysource/rfc/query.py +466 -0
  234. primarysource/rfc/subjects.py +457 -0
  235. primarysource/rfc/sync/__init__.py +1 -0
  236. primarysource/rfc/sync/downloader.py +132 -0
  237. primarysource/rfc/sync/manifest.py +162 -0
  238. primarysource/rfc/sync/scanner.py +49 -0
  239. primarysource/rfc/sync/state.py +464 -0
  240. primarysource/rfc/views.py +1067 -0
  241. primarysource/rpmdocs/__init__.py +15 -0
  242. primarysource/rpmdocs/catalogue.py +117 -0
  243. primarysource/rpmdocs/cli.py +12 -0
  244. primarysource/rpmdocs/server.py +8 -0
  245. primarysource/scaffold.py +223 -0
  246. primarysource/serve.py +75 -0
  247. primarysource/settings.py +152 -0
  248. primarysource/usb/__init__.py +5 -0
  249. primarysource/usb/collection.py +37 -0
  250. primarysource/usb/data/acquired.json +2025 -0
  251. primarysource/usb/data/sources-usb.toml +852 -0
  252. primarysource/usb/data/sources.toml +10 -0
  253. primarysource/usb/data/usb-catalog.json +1663 -0
  254. primarysource/usb/data/usb-classification.json +1986 -0
  255. primarysource/usb/data/usb-corpus-build.json +608 -0
  256. primarysource/usb/data/usb-pdf-choice.json +839 -0
  257. primarysource/usb/data/usb-split-measure.json +1803 -0
  258. primarysource/usb/ingest/__init__.py +1 -0
  259. primarysource/usb/ingest/acquire.py +130 -0
  260. primarysource/usb/ingest/discover_usb.py +88 -0
  261. primarysource/usb/ingest/extract.py +48 -0
  262. primarysource/usb/ingest/fetch.py +197 -0
  263. primarysource/usb/ingest/index.py +14 -0
  264. primarysource/usb/ingest/sources.py +85 -0
  265. primarysource/usb/models.py +7 -0
  266. primarysource/usb/paths.py +33 -0
  267. primarysource/usb/server/__init__.py +3 -0
  268. primarysource/usb/server/corpus.py +38 -0
  269. primarysource/usb/server/mcp.py +19 -0
  270. primarysource/usb/server/tools.py +35 -0
  271. primarysource/usb/spec.py +20 -0
  272. primarysource/usbids/__init__.py +56 -0
  273. primarysource/usbids/cli.py +65 -0
  274. primarysource/usbids/importer.py +272 -0
  275. primarysource/usbids/parse.py +283 -0
  276. primarysource/usbids/server.py +55 -0
  277. primarysource/usbids/store.py +80 -0
  278. primarysource/usbids/tools.py +174 -0
  279. primarysource/zim/__init__.py +5 -0
  280. primarysource/zim/__main__.py +8 -0
  281. primarysource/zim/_libzim.py +37 -0
  282. primarysource/zim/catalog.py +594 -0
  283. primarysource/zim/cli.py +555 -0
  284. primarysource/zim/collection.py +41 -0
  285. primarysource/zim/errors.py +100 -0
  286. primarysource/zim/groups.py +30 -0
  287. primarysource/zim/library.py +668 -0
  288. primarysource/zim/manage.py +636 -0
  289. primarysource/zim/markdown.py +1247 -0
  290. primarysource/zim/mcp.py +201 -0
  291. primarysource/zim/purl.py +130 -0
  292. primarysource/zim/py.typed +0 -0
  293. primarysource/zim/refs.py +90 -0
  294. primarysource/zim/service.py +1182 -0
  295. primarysource/zim/snapshot.py +197 -0
  296. primarysource/zim/sync.py +269 -0
  297. primarysource/zim/views.py +230 -0
  298. primarysource-0.1.0.dist-info/METADATA +118 -0
  299. primarysource-0.1.0.dist-info/RECORD +303 -0
  300. primarysource-0.1.0.dist-info/WHEEL +4 -0
  301. primarysource-0.1.0.dist-info/entry_points.txt +2 -0
  302. primarysource-0.1.0.dist-info/licenses/LICENSE +203 -0
  303. primarysource-0.1.0.dist-info/licenses/NOTICE +51 -0
@@ -0,0 +1,294 @@
1
+ """Library lifecycle: what is on disk, how it gets there, what it can answer.
2
+
3
+ A library fills in tiers, and every view works with whatever tier exists:
4
+
5
+ catalog `rfclib init`: the RFC index (rfc-index.xml), bulk errata and
6
+ the RFC Editor subject tree. Status, obsolete chains, info,
7
+ document search, browse and errata work for every RFC.
8
+ on demand the first read/grep/outline/cite/links of an RFC fetches its
9
+ txt (and RFCXML for RFC 8650+) from rfc-editor.org, segments
10
+ and indexes it in well under a second, and keeps it.
11
+ synced `rfclib sync` / `rfclib init --content ...` prefetch documents
12
+ in bulk, so full-text section search covers them all.
13
+
14
+ Nothing here runs at import time, and the MCP server never blocks startup
15
+ on it: an empty library answers with what to do instead of "no matches".
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import sqlite3
22
+ import threading
23
+ from datetime import datetime, timezone
24
+ from pathlib import Path
25
+
26
+ from .config import NetworkDisabled, Paths, network_mode, read_only
27
+
28
+
29
+ def _now() -> str:
30
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
31
+
32
+
33
+ def state(conn: sqlite3.Connection) -> dict:
34
+ from .sync.state import get_meta
35
+ one = lambda sql, *a: conn.execute(sql, a).fetchone()[0] # noqa: E731
36
+ subjects = 0
37
+ if one("SELECT COUNT(*) FROM sqlite_master WHERE name='subjects'"):
38
+ subjects = one("SELECT COUNT(*) FROM subjects")
39
+ return {
40
+ "docs": one("SELECT COUNT(*) FROM docs"),
41
+ "rfcs": one("SELECT COUNT(*) FROM docs WHERE series='RFC'"),
42
+ "current_rfcs": one("SELECT COUNT(*) FROM docs WHERE series='RFC' "
43
+ "AND is_current=1"),
44
+ "txt_local": one("SELECT COUNT(*) FROM files WHERE fmt='txt'"),
45
+ "indexed": one("SELECT COUNT(*) FROM chunk_src"),
46
+ "indexed_current": one(
47
+ "SELECT COUNT(*) FROM chunk_src s JOIN docs d USING(doc_id) "
48
+ "WHERE d.is_current=1 AND d.series='RFC'"),
49
+ "embedded_chunks": one("SELECT COUNT(*) FROM embed_state"),
50
+ "errata": one("SELECT COUNT(*) FROM errata"),
51
+ "subjects": subjects,
52
+ "manifest_synced_at": get_meta(conn, "manifest_synced_at"),
53
+ "errata_synced_at": get_meta(conn, "errata_bulk_synced_at"),
54
+ "subjects_synced_at": get_meta(conn, "subjects_synced_at"),
55
+ "network": network_mode(),
56
+ }
57
+
58
+
59
+ def features() -> dict[str, bool]:
60
+ """Optional components (pip extras) available in this environment."""
61
+ import importlib.util as u
62
+ return {
63
+ "mcp": u.find_spec("mcp") is not None,
64
+ "semantic": (u.find_spec("model2vec") is not None
65
+ and u.find_spec("sqlite_vec") is not None),
66
+ "zim": u.find_spec("libzim") is not None,
67
+ }
68
+
69
+
70
+ def initialized(conn: sqlite3.Connection) -> bool:
71
+ return conn.execute("SELECT 1 FROM docs LIMIT 1").fetchone() is not None
72
+
73
+
74
+ def not_initialized(conn: sqlite3.Connection | None = None
75
+ ) -> tuple[str, str, str]:
76
+ """(message, hint, action) for a library with no catalog yet: the
77
+ fields of the error every view raises (PLAN.md §5.10)."""
78
+ if _bootstrap.running:
79
+ return ("The RFC library is being initialized in the background "
80
+ f"({_bootstrap.step})", "retry the same call in a minute", "")
81
+ home = Paths.default().home
82
+ return (f"The RFC library at {home} is empty; once its catalog is in "
83
+ "(the RFC index, errata and subject tree, about a minute), RFCs "
84
+ "are fetched on first use", "",
85
+ "rfclib init (or `rfclib init --content current`, which also "
86
+ "prefetches every current RFC for full-text search, ~10 min)")
87
+
88
+
89
+ def coverage_note(conn: sqlite3.Connection) -> str:
90
+ """'' when section search covers (nearly) every current RFC, else a
91
+ line saying what was searched, for the top of search results."""
92
+ s = conn.execute(
93
+ "SELECT (SELECT COUNT(*) FROM docs WHERE series='RFC' AND "
94
+ "is_current=1), (SELECT COUNT(*) FROM chunk_src s JOIN docs d "
95
+ "USING(doc_id) WHERE d.is_current=1 AND d.series='RFC')").fetchone()
96
+ total, done = s[0], s[1]
97
+ if not total or done >= total * 0.95:
98
+ return ""
99
+ return (f"coverage: section search covers the {done:,} of {total:,} "
100
+ "current RFCs fetched so far. scope='documents' searches all of "
101
+ "them; read() fetches any RFC on demand; `rfclib sync` "
102
+ "prefetches the rest.")
103
+
104
+
105
+ # --------------------------------------------------------------------------
106
+ # on demand
107
+
108
+ _fetch_locks: dict[str, threading.Lock] = {}
109
+ _fetch_guard = threading.Lock()
110
+
111
+
112
+ def ensure_indexed(conn: sqlite3.Connection, doc_id: str) -> bool:
113
+ """Make doc_id readable: fetch its files if missing (RFC 8650+ also gets
114
+ its RFCXML for exact structure), segment and index it, embed its
115
+ sections if the model is already on disk. True when the document is
116
+ indexed afterwards. Raises NetworkDisabled when it would need the
117
+ network and RFCLIB_NETWORK=offline."""
118
+ if conn.execute("SELECT 1 FROM chunk_src WHERE doc_id=?",
119
+ (doc_id,)).fetchone():
120
+ return True
121
+ if read_only(): # indexing is a write; the shared server does it
122
+ raise NetworkDisabled(
123
+ f"{doc_id} is not indexed in this library and this server is "
124
+ "read-only (RFCLIB_READONLY)",
125
+ "read it through the shared rfclib server once",
126
+ "rfclib sync && rfclib index build")
127
+ with _fetch_guard:
128
+ lock = _fetch_locks.setdefault(doc_id, threading.Lock())
129
+ with lock:
130
+ if conn.execute("SELECT 1 FROM chunk_src WHERE doc_id=?",
131
+ (doc_id,)).fetchone():
132
+ return True
133
+ from . import graph, query as q
134
+ from .index import search as ix_search
135
+ from .sync.state import mirror_of
136
+ mirror = mirror_of(conn)
137
+ txt = q._content_path(conn, doc_id, "txt", mirror, fetch=True)
138
+ if txt is None:
139
+ return False
140
+ row = conn.execute("SELECT formats FROM docs WHERE doc_id=?",
141
+ (doc_id,)).fetchone()
142
+ if row and "xml" in (row["formats"] or ""):
143
+ try:
144
+ q._content_path(conn, doc_id, "xml", mirror, fetch=True)
145
+ except (NetworkDisabled, OSError):
146
+ pass # txt structure is a fine fallback
147
+ ok = ix_search.index_doc(conn, doc_id)
148
+ try:
149
+ graph.build(conn, [doc_id])
150
+ except Exception: # noqa: BLE001 - citations are a bonus here
151
+ pass
152
+ _embed_if_model_present(conn, doc_id)
153
+ return ok
154
+
155
+
156
+ def _embed_if_model_present(conn: sqlite3.Connection, doc_id: str) -> None:
157
+ """Never download a 30 MB model inside a tool call: embed only when the
158
+ model is already in the library (rfclib init --content / index embed)."""
159
+ from .index import embed as ix_embed
160
+ if not (ix_embed.model_dir() / "config.json").is_file():
161
+ return
162
+ try:
163
+ ix_embed.embed_pending(conn, doc_id=doc_id)
164
+ except Exception: # noqa: BLE001 - semantic search is optional
165
+ pass
166
+
167
+
168
+ # --------------------------------------------------------------------------
169
+ # init: the catalog tier, then optional bulk content
170
+
171
+ def init_catalog(conn: sqlite3.Connection, paths: Paths, *,
172
+ subjects: bool = True, refresh: bool = False,
173
+ say=lambda m: None) -> dict:
174
+ """RFC index + errata + subject tree. Each step is idempotent and skips
175
+ work already done, so an interrupted init simply resumes."""
176
+ from . import errata as errata_mod, subjects as subj_mod
177
+ from .sync.manifest import download_manifest, parse_manifest
178
+ from .sync.state import (rebuild_fts, refresh_current_flags, set_meta,
179
+ upsert_docs)
180
+ out: dict = {}
181
+ _bootstrap.step = "RFC index"
182
+ say("RFC index (rfc-index.xml, ~14 MB) ...")
183
+ dest = download_manifest(paths.manifest_xml, force=refresh)
184
+ n_docs, n_rel = upsert_docs(conn, parse_manifest(dest))
185
+ refresh_current_flags(conn)
186
+ rebuild_fts(conn)
187
+ set_meta(conn, "manifest_synced_at", _now())
188
+ from .index.lexical import mine_acronyms
189
+ out["acronyms"] = mine_acronyms(conn) # titles/abstracts: seconds
190
+ out["documents"], out["relations"] = n_docs, n_rel
191
+
192
+ _bootstrap.step = "errata"
193
+ if refresh or not errata_mod.bulk_synced_at(conn):
194
+ say("errata (RFC Editor bulk feed, ~12 MB) ...")
195
+ try:
196
+ out["errata"] = errata_mod.sync_bulk(conn)["errata"]
197
+ except NetworkDisabled:
198
+ out["errata"] = "skipped (offline)"
199
+
200
+ if subjects:
201
+ _bootstrap.step = "subject tree"
202
+ if refresh or not subj_mod.subjects_ready(conn):
203
+ say("subject tree (rfc-editor.org/subjects, ~640 pages, "
204
+ "a few minutes; cached and resumable) ...")
205
+ res = subj_mod.sync(conn, paths, progress=say)
206
+ out["subjects"] = res.get("subjects", res)
207
+ return out
208
+
209
+
210
+ def init_content(conn: sqlite3.Connection, paths: Paths, scope: str, *,
211
+ embed: bool = True, say=lambda m: None,
212
+ concurrency: int = 8) -> dict:
213
+ """Prefetch documents and build the full indexes. scope: current (every
214
+ RFC not obsoleted) | all (including obsolete)."""
215
+ from . import graph
216
+ from .index import embed as ix_embed, search as ix_search
217
+ from .sync import downloader
218
+ from .sync.state import missing_docs
219
+ out: dict = {}
220
+ inc_obs = scope == "all"
221
+ for fmt in ("txt", "xml"):
222
+ todo = missing_docs(conn, [fmt], include_obsolete=inc_obs)
223
+ if todo:
224
+ _bootstrap.step = f"downloading {len(todo)} {fmt} files"
225
+ say(f"downloading {len(todo):,} {fmt} files from rfc-editor.org ...")
226
+ res = downloader.fetch_docs(conn, paths.mirror, todo, [fmt],
227
+ concurrency=concurrency)
228
+ out[f"{fmt}_ok"] = sum(1 for _, _, s in res if s == "ok")
229
+ _bootstrap.step = "indexing sections"
230
+ say("indexing sections (RFCXML for RFC 8650+, ToC-driven for older) ...")
231
+ out["index"] = ix_search.build_chunks(conn, paths.mirror)
232
+ say("mining citations ...")
233
+ out["refs"] = graph.build(conn)
234
+ if embed:
235
+ _bootstrap.step = "embedding"
236
+ say("embedding sections (model ~30 MB, kept in the library) ...")
237
+ try:
238
+ out["embed"] = ix_embed.embed_pending(conn)
239
+ except RuntimeError as e: # rfclib[semantic] not installed
240
+ out["embed"] = f"skipped: {e}"
241
+ return out
242
+
243
+
244
+ # --------------------------------------------------------------------------
245
+ # optional background bootstrap for the MCP server (RFCLIB_AUTO_INIT=1)
246
+
247
+ class _Bootstrap:
248
+ running = False
249
+ step = ""
250
+ error = ""
251
+ _lock = threading.Lock()
252
+
253
+ def start(self, paths: Paths) -> bool:
254
+ """Start a catalog init in a background thread once per process.
255
+ Opt-in: an MCP server that downloads on its own surprises people."""
256
+ if os.environ.get("RFCLIB_AUTO_INIT", "") not in ("1", "true", "yes"):
257
+ return False
258
+ if network_mode() == "offline":
259
+ return False
260
+ with self._lock:
261
+ if self.running:
262
+ return True
263
+ self.running, self.error, self.step = True, "", "starting"
264
+ threading.Thread(target=self._run, args=(paths,), daemon=True).start()
265
+ return True
266
+
267
+ def _run(self, paths: Paths) -> None:
268
+ from .fsutil import LockHeld, bulk_lock
269
+ from .sync.state import open_db
270
+ try:
271
+ with bulk_lock(paths.home):
272
+ conn = open_db(paths.db)
273
+ # index + errata only (seconds); the ~640-page subject crawl
274
+ # stays an explicit `rfclib init`
275
+ init_catalog(conn, paths, subjects=False)
276
+ except LockHeld:
277
+ self.error = "another rfclib process is initializing"
278
+ except Exception as e: # noqa: BLE001 - reported via status
279
+ self.error = f"{type(e).__name__}: {e}"
280
+ finally:
281
+ self.running, self.step = False, ""
282
+
283
+
284
+ _bootstrap = _Bootstrap()
285
+
286
+
287
+ def maybe_bootstrap(conn: sqlite3.Connection) -> None:
288
+ if not initialized(conn):
289
+ _bootstrap.start(Paths.default().ensure())
290
+
291
+
292
+ def _home(conn: sqlite3.Connection) -> Path:
293
+ from .sync.state import mirror_of
294
+ return mirror_of(conn).parent
@@ -0,0 +1 @@
1
+ """MCP interface package."""
@@ -0,0 +1,381 @@
1
+ """FastMCP server exposing the local RFC library over the MCP protocol.
2
+
3
+ Nine tools, one job each, all answering in compact plain text and all
4
+ addressed by the same refs (see rfclib.views): search -> read -> cite is the
5
+ core loop; outline/grep/links/info/browse/errata support it. Tool names are
6
+ what a deferred-tool client sees first, so each one says what it does. The
7
+ heavy lifting lives in rfclib.views; this module only maps errors to
8
+ isError results and declares annotations, prompts and resources.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import sqlite3
15
+
16
+ from mcp.server.mcpserver import MCPServer
17
+ from mcp.server.mcpserver.exceptions import ResourceError
18
+ from mcp.types import (Completion, PromptReference, ResourceTemplateReference,
19
+ ToolAnnotations)
20
+
21
+ from primarysource.errors import LibError
22
+ from primarysource.scaffold import guard
23
+
24
+ from .. import __version__, library
25
+ from .. import query as q
26
+ from .. import views
27
+ from ..config import NetworkDisabled, Paths, read_only
28
+ from ..sync.state import open_db
29
+
30
+ INSTRUCTIONS = (
31
+ "RFC library: the full RFC/BCP/STD/FYI corpus from the RFC Editor, "
32
+ "searchable by section, with status (obsolete/superseded), errata, "
33
+ "citations and the RFC Editor subject tree. Use it for any question "
34
+ "about an IETF protocol or standard (HTTP, TLS, QUIC, DNS, TCP, IP, "
35
+ "SMTP, URI, BGP...): search('<question>') -> read('<ref>') -> follow "
36
+ "the 'next:' ref -> cite('<ref>'). Refs are citations: 'RFC9000' "
37
+ "(overview), 'RFC9000§9.6' (section), 'RFC9000§9.6¶2' (paragraph), "
38
+ "'RFC9000:2964-2979' (lines); BCP/STD aliases resolve. NEVER open the "
39
+ "mirror's .txt files directly: they are long and get truncated. "
40
+ "Before quoting a document check that it is current (read/info show "
41
+ "OBSOLETE + successor). read(view='normative') lists MUST/SHOULD "
42
+ "statements; view='code' the ABNF/YANG/figures. If the library is "
43
+ "empty or partly synced, tools say so and what to run."
44
+ )
45
+
46
+ # Everything is read-only over local state; tools that may fetch a missing
47
+ # file or errata from rfc-editor.org are open-world.
48
+ RO = ToolAnnotations(read_only_hint=True, idempotent_hint=True,
49
+ destructive_hint=False, open_world_hint=False)
50
+ RO_NET = ToolAnnotations(read_only_hint=True, idempotent_hint=True,
51
+ destructive_hint=False, open_world_hint=True)
52
+ # Claude Code defers MCP tools behind tool search, and a model that calls a
53
+ # deferred tool without loading its schema guesses argument names (the
54
+ # agent eval caught errata(query=...) 5 times in 22 tasks). The core loop
55
+ # (search, read) and errata, which read headers send agents to, load up
56
+ # front.
57
+ ALWAYS = {"anthropic/alwaysLoad": True}
58
+
59
+ mcp = MCPServer("rfclib", version=__version__, instructions=INSTRUCTIONS)
60
+
61
+ _REF_HELP = ("ref forms: 'RFC9000' | 'RFC9000§9.6' | 'RFC9000§9.6¶2' | "
62
+ "'RFC9000#table-2' | 'RFC9000:2964-2979'.")
63
+
64
+
65
+ def _conn():
66
+ if read_only(): # never create or migrate anything
67
+ paths = Paths.default()
68
+ try:
69
+ return open_db(paths.db, read_only=True), paths
70
+ except sqlite3.OperationalError:
71
+ raise LibError(
72
+ f"cannot open {paths.db}: a read-only server needs an "
73
+ "existing library", "",
74
+ "rfclib init, or start the shared rfclib server first"
75
+ ) from None
76
+ paths = Paths.default().ensure()
77
+ conn = open_db(paths.db)
78
+ library.maybe_bootstrap(conn) # no-op unless RFCLIB_AUTO_INIT=1
79
+ return conn, paths
80
+
81
+
82
+ @mcp.custom_route("/healthz", methods=["GET"])
83
+ async def healthz(request):
84
+ """Liveness for the HTTP transport (Docker HEALTHCHECK, monitors): the
85
+ process is up and can open its library. Not part of MCP."""
86
+ from starlette.responses import JSONResponse
87
+ try:
88
+ conn, _paths = _conn()
89
+ docs = conn.execute("SELECT COUNT(*) FROM docs").fetchone()[0]
90
+ conn.close()
91
+ except Exception as e: # noqa: BLE001 - report, never raise
92
+ return JSONResponse({"status": "error", "error": str(e)[:300]},
93
+ status_code=503)
94
+ return JSONResponse({"status": "ok", "version": __version__,
95
+ "documents": docs, "read_only": read_only()})
96
+
97
+
98
+ def _run(fn, *a, **k) -> str:
99
+ """Call a view; anticipated failures become a `LibError`, which `guard`
100
+ turns into an isError result of `error:`/`hint:`/`action:` lines
101
+ (PLAN.md §5.10): `hint` is the tool call to make next, `action` the
102
+ command a human runs."""
103
+ try:
104
+ return fn(*a, **k)
105
+ except views.RefError as e:
106
+ # a ref problem that names no remedy of its own gets the ref forms
107
+ hint = e.hint or ("" if e.action else _REF_HELP)
108
+ raise LibError(e.message, hint, e.action) from None
109
+ except q.NotFound as e:
110
+ raise LibError(f"{e} is not in the local index", "",
111
+ "rfclib manifest --refresh") from None
112
+ except NetworkDisabled as e:
113
+ raise LibError(e.message, e.hint, e.action) from None
114
+ except sqlite3.OperationalError as e:
115
+ if "readonly" not in str(e) or not read_only():
116
+ raise
117
+ raise LibError(
118
+ "this server is read-only (RFCLIB_READONLY) and the library "
119
+ "needs a write first, most likely a schema update",
120
+ "call it through the shared rfclib server once",
121
+ "rfclib status") from None
122
+
123
+
124
+ @mcp.tool(annotations=RO, structured_output=False, meta=ALWAYS)
125
+ @guard
126
+ def search(query: str, scope: str = "sections", mode: str = "hybrid",
127
+ limit: int = 8, subject: str | None = None,
128
+ status: str | None = None, include_obsolete: bool = False,
129
+ detail: str = "concise") -> str:
130
+ """Find where RFCs answer a question. Hits are refs (paragraph-precise
131
+ when possible) with a snippet, ready for read() and cite().
132
+
133
+ scope: sections (default: which passage says X) | documents (which RFC
134
+ is about X; matches titles/abstracts/keywords).
135
+ mode (sections): hybrid (default, keyword+semantic) | exact (FTS5:
136
+ "quoted phrase", AND/OR, prefix*, NEAR(a b, 40)) | semantic
137
+ (paraphrase/concept).
138
+ subject/status: restrict documents to an RFC-Editor subject slug
139
+ ('quic', 'http') or status ('INTERNET STANDARD', 'BCP').
140
+ Obsolete documents are excluded unless include_obsolete=True.
141
+ detail: concise | detailed (adds line ranges and which index matched).
142
+ """
143
+ conn, _ = _conn()
144
+ return _run(views.search_text, conn, query, scope=scope, mode=mode,
145
+ limit=limit, subject=subject, status=status,
146
+ include_obsolete=include_obsolete, detail=detail)
147
+
148
+
149
+ @mcp.tool(annotations=RO_NET, structured_output=False, meta=ALWAYS)
150
+ @guard
151
+ def read(ref: str, max_tokens: int = 2000, view: str = "text",
152
+ lang: str | None = None, format: str = "txt") -> str:
153
+ """Read RFC text by ref. Pages are cut at paragraph boundaries within
154
+ max_tokens and end with 'next: <ref>' (call read with it to continue)
155
+ plus up/after neighbours. Paragraphs are marked [§9.6¶2] so any of them
156
+ is citable.
157
+
158
+ ref: 'RFC9000' (abstract + outline) | 'RFC9000§9.6' | 'RFC9000§9.6¶2'
159
+ | 'RFC9000#table-2' | 'RFC9000:2964-2979' | 'BCP14' (alias -> RFC).
160
+ view: text | normative (only MUST/SHOULD/MAY statements) | code
161
+ (sourcecode/figures; lang='abnf'|'yang'|'asn.1'|...) | refs (what
162
+ this scope cites).
163
+ format: txt (default) | html | xml | json: raw slice of another
164
+ rendering by line range (e.g. read('RFC9000:1-200', format='xml')).
165
+ """
166
+ conn, paths = _conn()
167
+ fmt = (format or "txt").lower().strip(". ")
168
+ if fmt != "txt":
169
+ if fmt not in ("html", "xml", "json", "pdf"):
170
+ raise LibError(f"unknown format {format!r}",
171
+ "format is txt | html | xml | json")
172
+ ref_ = _run(views.parse_ref, conn, ref, need_text=False)
173
+ try:
174
+ return q.read_document(conn, ref_.served, mirror_root=paths.mirror,
175
+ fmt=fmt, start_line=ref_.start or 1,
176
+ count=((ref_.end or (ref_.start or 1) + 199)
177
+ - (ref_.start or 1) + 1),
178
+ max_chars=max(400, max_tokens * 4))
179
+ except q.NotFound as e:
180
+ raise LibError(f"unknown document {e}", _REF_HELP) from None
181
+ return _run(views.read, conn, ref, mirror_root=paths.mirror,
182
+ max_tokens=max_tokens, view=view, lang=lang)
183
+
184
+
185
+ @mcp.tool(annotations=RO, structured_output=False)
186
+ @guard
187
+ def outline(ref: str, depth: int | None = 2,
188
+ contains: str | None = None) -> str:
189
+ """Section outline of one document (ref='RFC9112'): one line per section
190
+ with its line range (depth 2 by default, all levels when contains=
191
+ filters by number/title, e.g. contains='security'). Then
192
+ read('RFC9112§<num>')."""
193
+ conn, _ = _conn()
194
+ return _run(views.outline, conn, ref.split("§")[0].split(":")[0],
195
+ depth=depth, contains=contains)
196
+
197
+
198
+ @mcp.tool(annotations=RO_NET, structured_output=False)
199
+ @guard
200
+ def grep(ref: str, pattern: str, max_hits: int = 25,
201
+ ignore_case: bool = True) -> str:
202
+ """Regex search inside ONE document or section (ref='RFC9110' or
203
+ 'RFC9110§15'); each hit carries its paragraph ref and line. The cheap
204
+ way to find a field name, ABNF rule or error code in a known RFC.
205
+ Invalid regexes fall back to a literal search."""
206
+ conn, paths = _conn()
207
+ return _run(views.grep, conn, ref, pattern, mirror_root=paths.mirror,
208
+ max_hits=max_hits, ignore_case=ignore_case)
209
+
210
+
211
+ @mcp.tool(annotations=RO, structured_output=False)
212
+ @guard
213
+ def cite(ref: str, style: str = "verbose") -> str:
214
+ """Checkable citation for a ref: label, title, date, status (warns when
215
+ obsolete), anchor URL, errata status and a hash of the cited span.
216
+ style: verbose | short | markdown | comment | bibtex. Cite rather than
217
+ paraphrase normative text."""
218
+ conn, paths = _conn()
219
+ return _run(views.cite, conn, ref, mirror_root=paths.mirror, style=style)
220
+
221
+
222
+ @mcp.tool(annotations=RO, structured_output=False)
223
+ @guard
224
+ def links(ref: str, direction: str = "out", limit: int = 30) -> str:
225
+ """Citation links of a document or section, section-precise where the
226
+ text says so ('RFC9000§8.1 cites RFC9002 §6.2 [normative]').
227
+ direction: out (what it cites) | in (who cites it) | related (other
228
+ documents in the same RFC-Editor subjects)."""
229
+ conn, _ = _conn()
230
+ return _run(views.links, conn, ref, direction=direction, limit=limit)
231
+
232
+
233
+ @mcp.tool(annotations=RO, structured_output=False)
234
+ @guard
235
+ def info(ref: str, detail: str = "concise") -> str:
236
+ """Bibliographic record and status of one document: title, status,
237
+ current/OBSOLETE with successor, obsoletes/updates graph, BCP/STD
238
+ alias members (BCP14 -> RFC2119 + RFC8174), authors, subjects, errata
239
+ summary, abstract. ref is a document id ('RFC2616', 'BCP14'); a section
240
+ part is ignored. detail='full' returns the complete JSON record."""
241
+ conn, _ = _conn()
242
+ doc = ref.split("§")[0].split(":")[0].split("#")[0]
243
+ if (detail or "").lower() == "full":
244
+ try:
245
+ return json.dumps(q.get_metadata(conn, q.normalize_doc_id(doc)),
246
+ ensure_ascii=False, indent=1)
247
+ except (ValueError, q.NotFound) as e:
248
+ raise LibError(f"unknown document {doc!r}: {e}",
249
+ "search(<topic>, scope='documents')") from None
250
+ return _run(views.info_text, conn, doc)
251
+
252
+
253
+ @mcp.tool(annotations=RO, structured_output=False)
254
+ @guard
255
+ def browse(subject: str | None = None, parent: str | None = None,
256
+ contains: str | None = None, recent: bool = False,
257
+ offset: int = 0, limit: int = 30,
258
+ include_obsolete: bool = False) -> str:
259
+ """Browse without a query: the RFC Editor subject tree and what is new.
260
+
261
+ no args: the top-level subjects (slug, doc count, subtopics);
262
+ parent='web': one level down; contains='http': find subjects by name;
263
+ subject='quic': the documents in that subject (newest first, paged with
264
+ offset); recent=True: most recently published RFCs."""
265
+ conn, _ = _conn()
266
+ if recent:
267
+ return views.recent_text(conn, limit=limit)
268
+ if subject:
269
+ return _run(views.subject_docs, conn, subject, offset=offset,
270
+ limit=limit, include_obsolete=include_obsolete)
271
+ return _run(views.subjects_tree, conn, parent=parent, contains=contains)
272
+
273
+
274
+ @mcp.tool(annotations=RO_NET, structured_output=False, meta=ALWAYS)
275
+ @guard
276
+ def errata(ref: str, status: str = "verified",
277
+ section: str | None = None, max_tokens: int = 2000) -> str:
278
+ """Errata for a document or one section, with the corrected text:
279
+ ref='RFC791' or 'RFC791§3.1' (or section='3.1'). status: verified
280
+ (default; the authoritative corrections) | open (reported/held) | all.
281
+ Check before implementing."""
282
+ conn, _ = _conn()
283
+ try:
284
+ return _run(views.errata_text, conn, ref, status=status,
285
+ section=section, max_tokens=max_tokens)
286
+ except LibError:
287
+ raise
288
+ except Exception as e: # noqa: BLE001 - upstream/network
289
+ raise LibError(f"errata unavailable: {e}",
290
+ "retry the same call later",
291
+ "rfclib errata --sync-all (while online)") from None
292
+
293
+
294
+ # --------------------------------------------------------------------------
295
+ # resources: '@rfclib:rfc://RFC9000§9.6' attaches a section to a prompt
296
+
297
+ @mcp.resource("rfc://{ref}", name="rfc", mime_type="text/plain",
298
+ description="RFC text by ref, e.g. rfc://RFC9000§9.6 or "
299
+ "rfc://RFC9112§6.3¶2 (first page; the footer gives "
300
+ "the next ref).")
301
+ def rfc_resource(ref: str) -> str:
302
+ conn, paths = _conn()
303
+ try:
304
+ return views.read(conn, ref, mirror_root=paths.mirror,
305
+ max_tokens=4000)
306
+ except views.RefError as e:
307
+ raise ResourceError(e.as_text()) from None
308
+ except q.NotFound as e:
309
+ raise ResourceError(str(e)) from None
310
+
311
+
312
+ # --------------------------------------------------------------------------
313
+ # prompts: user-invoked workflows (/rfclib:explain, /rfclib:checklist, ...)
314
+
315
+ @mcp.prompt(name="explain", title="Explain an RFC section")
316
+ def explain_prompt(ref: str) -> str:
317
+ """Explain what a section requires, grounded in its text and status."""
318
+ return (f"Explain {ref} for an implementer. First call "
319
+ f"mcp__rfclib__read('{ref}') (follow 'next:' until the section "
320
+ "ends) and mcp__rfclib__info on its document; if the document "
321
+ "is obsolete, say so and find the successor's matching section. "
322
+ f"Check mcp__rfclib__errata for that section. Summarise the "
323
+ "behaviour in plain language, quote the MUST/SHOULD sentences "
324
+ "verbatim, and finish with mcp__rfclib__cite for each passage "
325
+ "you relied on.")
326
+
327
+
328
+ @mcp.prompt(name="checklist", title="Compliance checklist for a section")
329
+ def checklist_prompt(ref: str) -> str:
330
+ """Turn a section's normative statements into a checklist."""
331
+ return (f"Build an implementation checklist for {ref}. Call "
332
+ f"mcp__rfclib__read('{ref}', view='normative') and page with "
333
+ "'next:'. One checklist item per requirement: the keyword "
334
+ "(MUST/SHOULD/MAY), a short imperative, and the paragraph ref "
335
+ "it comes from. Group by subsection. Add verified errata from "
336
+ f"mcp__rfclib__errata that touch these sections. Do not "
337
+ "paraphrase away conditions: keep every 'if/when/unless'.")
338
+
339
+
340
+ @mcp.prompt(name="current", title="Find the current spec for a topic")
341
+ def current_prompt(topic: str) -> str:
342
+ """Which RFCs currently define a topic, and what they replaced."""
343
+ return (f"Which RFCs currently specify {topic}? Use "
344
+ f"mcp__rfclib__search('{topic}', scope='documents') and "
345
+ "mcp__rfclib__browse(contains=...) for the subject. For each "
346
+ "candidate call mcp__rfclib__info: report only current "
347
+ "documents, each with its status and what it obsoletes/updates. "
348
+ "Mention obsolete predecessors people still cite and what "
349
+ "replaced them.")
350
+
351
+
352
+ # --------------------------------------------------------------------------
353
+ # completions: document ids and section numbers for prompts and rfc://
354
+
355
+ @mcp.completion()
356
+ async def complete(ref, argument, context):
357
+ if not isinstance(ref, (PromptReference, ResourceTemplateReference)):
358
+ return None
359
+ if argument.name not in ("ref", "doc"):
360
+ return None
361
+ conn, _ = _conn()
362
+ val = (argument.value or "").strip()
363
+ if "§" in val: # doc chosen: complete its sections
364
+ doc, part = val.split("§", 1)
365
+ try:
366
+ did = q.resolve(conn, q.normalize_doc_id(doc))
367
+ except ValueError:
368
+ return Completion(values=[])
369
+ rows = conn.execute(
370
+ "SELECT DISTINCT sec_num FROM chunks WHERE doc_id=? AND "
371
+ "sec_num LIKE ? ORDER BY start_line LIMIT 50",
372
+ (did, part + "%")).fetchall()
373
+ vals = [f"{doc}§{r['sec_num']}" for r in rows if r["sec_num"]]
374
+ return Completion(values=vals[:100], has_more=len(vals) >= 100)
375
+ digits = "".join(ch for ch in val if ch.isdigit())
376
+ series = "".join(ch for ch in val if ch.isalpha()).upper() or "RFC"
377
+ rows = conn.execute(
378
+ "SELECT doc_id FROM docs WHERE doc_id LIKE ? ORDER BY is_current "
379
+ "DESC, length(doc_id), doc_id LIMIT 20",
380
+ (f"{series}{digits}%",)).fetchall()
381
+ return Completion(values=[r["doc_id"] for r in rows])
@@ -0,0 +1 @@
1
+ """Content packs: curated RFC collections exported as .rfcpack zips."""