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.
- primarysource/__init__.py +5 -0
- primarysource/bbf/__init__.py +56 -0
- primarysource/bbf/_xml.py +63 -0
- primarysource/bbf/catalogue.json +777 -0
- primarysource/bbf/catalogue.py +137 -0
- primarysource/bbf/cli.py +79 -0
- primarysource/bbf/datamodel.py +281 -0
- primarysource/bbf/importer.py +151 -0
- primarysource/bbf/library.py +380 -0
- primarysource/bbf/server.py +72 -0
- primarysource/budget.py +63 -0
- primarysource/c2sp/__init__.py +53 -0
- primarysource/c2sp/_fetch.py +5 -0
- primarysource/c2sp/catalogue.json +231 -0
- primarysource/c2sp/catalogue.py +95 -0
- primarysource/c2sp/cli.py +62 -0
- primarysource/c2sp/importer.py +90 -0
- primarysource/c2sp/library.py +309 -0
- primarysource/c2sp/markdown.py +31 -0
- primarysource/c2sp/server.py +68 -0
- primarysource/cavp/__init__.py +42 -0
- primarysource/cavp/cli.py +84 -0
- primarysource/cavp/data/vectors.toml +47 -0
- primarysource/cavp/importer.py +156 -0
- primarysource/cavp/model.py +279 -0
- primarysource/cavp/parse.py +269 -0
- primarysource/cavp/paths.py +17 -0
- primarysource/cavp/server.py +67 -0
- primarysource/cavp/tools.py +169 -0
- primarysource/cli.py +107 -0
- primarysource/cursor.py +82 -0
- primarysource/cve/__init__.py +5 -0
- primarysource/cve/__main__.py +6 -0
- primarysource/cve/cli.py +144 -0
- primarysource/cve/collection.py +20 -0
- primarysource/cve/config.py +58 -0
- primarysource/cve/errors.py +16 -0
- primarysource/cve/kernel.py +232 -0
- primarysource/cve/mcp.py +139 -0
- primarysource/cve/parse.py +318 -0
- primarysource/cve/py.typed +0 -0
- primarysource/cve/service.py +760 -0
- primarysource/cve/store.py +214 -0
- primarysource/cve/sync.py +438 -0
- primarysource/cve/views.py +304 -0
- primarysource/debpolicy/__init__.py +16 -0
- primarysource/debpolicy/catalogue.py +263 -0
- primarysource/debpolicy/cli.py +12 -0
- primarysource/debpolicy/server.py +11 -0
- primarysource/draft/__init__.py +11 -0
- primarysource/draft/catalogue.py +117 -0
- primarysource/draft/cli.py +149 -0
- primarysource/draft/collection.py +21 -0
- primarysource/draft/datatracker.py +158 -0
- primarysource/draft/importer.py +163 -0
- primarysource/draft/licence.py +89 -0
- primarysource/draft/names.py +64 -0
- primarysource/draft/net.py +133 -0
- primarysource/draft/segment.py +192 -0
- primarysource/draft/server.py +91 -0
- primarysource/draft/status.py +146 -0
- primarysource/draft/store.py +199 -0
- primarysource/draft/tracked.json +104 -0
- primarysource/draft/views.py +430 -0
- primarysource/draft/xmlmeta.py +74 -0
- primarysource/ecma/__init__.py +52 -0
- primarysource/ecma/_pdf.py +143 -0
- primarysource/ecma/catalogue.json +22 -0
- primarysource/ecma/catalogue.py +100 -0
- primarysource/ecma/cli.py +67 -0
- primarysource/ecma/identify.py +25 -0
- primarysource/ecma/importer.py +108 -0
- primarysource/ecma/library.py +240 -0
- primarysource/ecma/server.py +67 -0
- primarysource/errors.py +29 -0
- primarysource/fetch.py +360 -0
- primarysource/fsutil.py +150 -0
- primarysource/gitdocs/__init__.py +15 -0
- primarysource/gitdocs/catalogue.py +116 -0
- primarysource/gitdocs/cli.py +12 -0
- primarysource/gitdocs/server.py +8 -0
- primarysource/home.py +65 -0
- primarysource/iana/__init__.py +32 -0
- primarysource/iana/catalogue.json +244 -0
- primarysource/iana/catalogue.py +69 -0
- primarysource/iana/cli.py +69 -0
- primarysource/iana/importer.py +157 -0
- primarysource/iana/model.py +325 -0
- primarysource/iana/reader.py +315 -0
- primarysource/iana/server.py +67 -0
- primarysource/iana/store.py +83 -0
- primarysource/iana/xmlsafe.py +47 -0
- primarysource/identify.py +186 -0
- primarysource/ieee/__init__.py +58 -0
- primarysource/ieee/_html.py +49 -0
- primarysource/ieee/catalogue.json +74 -0
- primarysource/ieee/catalogue.py +123 -0
- primarysource/ieee/cli.py +79 -0
- primarysource/ieee/identify.py +24 -0
- primarysource/ieee/ieee802.py +47 -0
- primarysource/ieee/importer.py +305 -0
- primarysource/ieee/posix.py +233 -0
- primarysource/ieee/server.py +79 -0
- primarysource/index.py +446 -0
- primarysource/iso/__init__.py +51 -0
- primarysource/iso/cli.py +98 -0
- primarysource/iso/designation.py +95 -0
- primarysource/iso/identify.py +100 -0
- primarysource/iso/importer.py +224 -0
- primarysource/iso/library.py +249 -0
- primarysource/iso/server.py +69 -0
- primarysource/itu/__init__.py +41 -0
- primarysource/itu/__main__.py +8 -0
- primarysource/itu/asn1mod.py +478 -0
- primarysource/itu/catalog.py +472 -0
- primarysource/itu/cli.py +445 -0
- primarysource/itu/collection.py +20 -0
- primarysource/itu/corpus.py +118 -0
- primarysource/itu/errata.py +16 -0
- primarysource/itu/formal.py +565 -0
- primarysource/itu/identify.py +26 -0
- primarysource/itu/index.py +490 -0
- primarysource/itu/mcp.py +474 -0
- primarysource/itu/py.typed +0 -0
- primarysource/itu/sources.py +600 -0
- primarysource/itu/text.py +609 -0
- primarysource/kconfig/__init__.py +16 -0
- primarysource/kconfig/catalogue.py +98 -0
- primarysource/kconfig/cli.py +12 -0
- primarysource/kconfig/server.py +8 -0
- primarysource/model.py +63 -0
- primarysource/nist/__init__.py +5 -0
- primarysource/nist/collection.py +46 -0
- primarysource/nist/data/acquired-nist.json +1657 -0
- primarysource/nist/data/nist-catalog.json +12090 -0
- primarysource/nist/data/sources.toml +1996 -0
- primarysource/nist/identify.py +41 -0
- primarysource/nist/ingest/__init__.py +1 -0
- primarysource/nist/ingest/acquire_nist.py +128 -0
- primarysource/nist/ingest/controls.py +82 -0
- primarysource/nist/ingest/discover_nist.py +218 -0
- primarysource/nist/ingest/extract.py +48 -0
- primarysource/nist/ingest/fetch.py +211 -0
- primarysource/nist/ingest/index.py +14 -0
- primarysource/nist/ingest/oscal.py +480 -0
- primarysource/nist/ingest/prose_layouts.py +236 -0
- primarysource/nist/ingest/sources.py +94 -0
- primarysource/nist/ingest/status.py +255 -0
- primarysource/nist/models.py +7 -0
- primarysource/nist/paths.py +33 -0
- primarysource/nist/server/__init__.py +3 -0
- primarysource/nist/server/corpus.py +38 -0
- primarysource/nist/server/mcp.py +19 -0
- primarysource/nist/server/tools.py +35 -0
- primarysource/nist/spec.py +38 -0
- primarysource/pdfcorpus/__init__.py +16 -0
- primarysource/pdfcorpus/corpus.py +159 -0
- primarysource/pdfcorpus/fts.py +175 -0
- primarysource/pdfcorpus/index.py +70 -0
- primarysource/pdfcorpus/models.py +30 -0
- primarysource/pdfcorpus/server.py +96 -0
- primarysource/pdfcorpus/spec.py +37 -0
- primarysource/pdfcorpus/split.py +391 -0
- primarysource/pdfcorpus/tools.py +244 -0
- primarysource/pep/__init__.py +15 -0
- primarysource/pep/catalogue.py +191 -0
- primarysource/pep/cli.py +12 -0
- primarysource/pep/server.py +8 -0
- primarysource/pkgdocs/__init__.py +19 -0
- primarysource/pkgdocs/fetch.py +356 -0
- primarysource/pkgdocs/index.py +89 -0
- primarysource/pkgdocs/model.py +76 -0
- primarysource/pkgdocs/readers/__init__.py +1 -0
- primarysource/pkgdocs/readers/_common.py +134 -0
- primarysource/pkgdocs/readers/asciidoc.py +160 -0
- primarysource/pkgdocs/readers/latex.py +315 -0
- primarysource/pkgdocs/readers/markdown.py +173 -0
- primarysource/pkgdocs/readers/pod.py +132 -0
- primarysource/pkgdocs/readers/rst.py +239 -0
- primarysource/pkgdocs/readers/scdoc.py +108 -0
- primarysource/pkgdocs/runtime.py +552 -0
- primarysource/pkgdocs/serving.py +158 -0
- primarysource/pkgdocs/store.py +167 -0
- primarysource/pms/__init__.py +16 -0
- primarysource/pms/catalogue.py +88 -0
- primarysource/pms/cli.py +12 -0
- primarysource/pms/server.py +8 -0
- primarysource/py.typed +0 -0
- primarysource/readers/__init__.py +10 -0
- primarysource/readers/bookmarks.py +192 -0
- primarysource/readers/html.py +1312 -0
- primarysource/readers/markdown.py +189 -0
- primarysource/readers/pdf.py +187 -0
- primarysource/readers/xml.py +230 -0
- primarysource/refs.py +87 -0
- primarysource/registry.py +161 -0
- primarysource/render.py +49 -0
- primarysource/rfc/__init__.py +3 -0
- primarysource/rfc/__main__.py +6 -0
- primarysource/rfc/cite.py +204 -0
- primarysource/rfc/cli.py +903 -0
- primarysource/rfc/collection.py +19 -0
- primarysource/rfc/config.py +138 -0
- primarysource/rfc/errata.py +203 -0
- primarysource/rfc/fsutil.py +82 -0
- primarysource/rfc/graph.py +213 -0
- primarysource/rfc/index/__init__.py +1 -0
- primarysource/rfc/index/chunker.py +281 -0
- primarysource/rfc/index/embed.py +166 -0
- primarysource/rfc/index/hybrid.py +305 -0
- primarysource/rfc/index/lexical.py +223 -0
- primarysource/rfc/index/rfcxml.py +673 -0
- primarysource/rfc/index/search.py +508 -0
- primarysource/rfc/index/txtseg.py +585 -0
- primarysource/rfc/library.py +294 -0
- primarysource/rfc/mcp/__init__.py +1 -0
- primarysource/rfc/mcp/server.py +381 -0
- primarysource/rfc/packs/__init__.py +1 -0
- primarysource/rfc/packs/builder.py +228 -0
- primarysource/rfc/packs/collections/all-current-txt.json +10 -0
- primarysource/rfc/packs/collections/essentials.json +19 -0
- primarysource/rfc/packs/collections/fyi-current.json +10 -0
- primarysource/rfc/packs/collections/standards-current.json +10 -0
- primarysource/rfc/packs/collections/year-2025.json +10 -0
- primarysource/rfc/packs/collections/year-2026.json +10 -0
- primarysource/rfc/packs/collections.py +163 -0
- primarysource/rfc/packs/docsetexport.py +163 -0
- primarysource/rfc/packs/epub.py +159 -0
- primarysource/rfc/packs/export.py +306 -0
- primarysource/rfc/packs/htmlexport.py +82 -0
- primarysource/rfc/packs/serve.py +143 -0
- primarysource/rfc/packs/zimexport.py +171 -0
- primarysource/rfc/query.py +466 -0
- primarysource/rfc/subjects.py +457 -0
- primarysource/rfc/sync/__init__.py +1 -0
- primarysource/rfc/sync/downloader.py +132 -0
- primarysource/rfc/sync/manifest.py +162 -0
- primarysource/rfc/sync/scanner.py +49 -0
- primarysource/rfc/sync/state.py +464 -0
- primarysource/rfc/views.py +1067 -0
- primarysource/rpmdocs/__init__.py +15 -0
- primarysource/rpmdocs/catalogue.py +117 -0
- primarysource/rpmdocs/cli.py +12 -0
- primarysource/rpmdocs/server.py +8 -0
- primarysource/scaffold.py +223 -0
- primarysource/serve.py +75 -0
- primarysource/settings.py +152 -0
- primarysource/usb/__init__.py +5 -0
- primarysource/usb/collection.py +37 -0
- primarysource/usb/data/acquired.json +2025 -0
- primarysource/usb/data/sources-usb.toml +852 -0
- primarysource/usb/data/sources.toml +10 -0
- primarysource/usb/data/usb-catalog.json +1663 -0
- primarysource/usb/data/usb-classification.json +1986 -0
- primarysource/usb/data/usb-corpus-build.json +608 -0
- primarysource/usb/data/usb-pdf-choice.json +839 -0
- primarysource/usb/data/usb-split-measure.json +1803 -0
- primarysource/usb/ingest/__init__.py +1 -0
- primarysource/usb/ingest/acquire.py +130 -0
- primarysource/usb/ingest/discover_usb.py +88 -0
- primarysource/usb/ingest/extract.py +48 -0
- primarysource/usb/ingest/fetch.py +197 -0
- primarysource/usb/ingest/index.py +14 -0
- primarysource/usb/ingest/sources.py +85 -0
- primarysource/usb/models.py +7 -0
- primarysource/usb/paths.py +33 -0
- primarysource/usb/server/__init__.py +3 -0
- primarysource/usb/server/corpus.py +38 -0
- primarysource/usb/server/mcp.py +19 -0
- primarysource/usb/server/tools.py +35 -0
- primarysource/usb/spec.py +20 -0
- primarysource/usbids/__init__.py +56 -0
- primarysource/usbids/cli.py +65 -0
- primarysource/usbids/importer.py +272 -0
- primarysource/usbids/parse.py +283 -0
- primarysource/usbids/server.py +55 -0
- primarysource/usbids/store.py +80 -0
- primarysource/usbids/tools.py +174 -0
- primarysource/zim/__init__.py +5 -0
- primarysource/zim/__main__.py +8 -0
- primarysource/zim/_libzim.py +37 -0
- primarysource/zim/catalog.py +594 -0
- primarysource/zim/cli.py +555 -0
- primarysource/zim/collection.py +41 -0
- primarysource/zim/errors.py +100 -0
- primarysource/zim/groups.py +30 -0
- primarysource/zim/library.py +668 -0
- primarysource/zim/manage.py +636 -0
- primarysource/zim/markdown.py +1247 -0
- primarysource/zim/mcp.py +201 -0
- primarysource/zim/purl.py +130 -0
- primarysource/zim/py.typed +0 -0
- primarysource/zim/refs.py +90 -0
- primarysource/zim/service.py +1182 -0
- primarysource/zim/snapshot.py +197 -0
- primarysource/zim/sync.py +269 -0
- primarysource/zim/views.py +230 -0
- primarysource-0.1.0.dist-info/METADATA +118 -0
- primarysource-0.1.0.dist-info/RECORD +303 -0
- primarysource-0.1.0.dist-info/WHEEL +4 -0
- primarysource-0.1.0.dist-info/entry_points.txt +2 -0
- primarysource-0.1.0.dist-info/licenses/LICENSE +203 -0
- 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."""
|