mcp-server-rfc 0.1.0__tar.gz

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.
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .ruff_cache/
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-server-rfc
3
+ Version: 0.1.0
4
+ Summary: MCP server for looking up IETF RFCs, with obsolescence warnings and section-level reads
5
+ Project-URL: Homepage, https://github.com/shbernal/rfc-ai-tooling
6
+ Project-URL: Issues, https://github.com/shbernal/rfc-ai-tooling/issues
7
+ Author: Santiago Bernal
8
+ License-Expression: MIT
9
+ Keywords: ietf,llm,mcp,rfc,standards
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Topic :: Internet
15
+ Classifier: Topic :: Software Development :: Documentation
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: mcp>=2.0.0
18
+ Description-Content-Type: text/markdown
19
+
20
+ # mcp-server-rfc
21
+
22
+ An MCP server for looking up IETF RFCs.
23
+
24
+ ```json
25
+ {
26
+ "mcpServers": {
27
+ "rfc": {
28
+ "command": "uvx",
29
+ "args": ["mcp-server-rfc"]
30
+ }
31
+ }
32
+ }
33
+ ```
34
+
35
+ No clone, no virtualenv, no path. That is the whole installation.
36
+
37
+ ## Tools
38
+
39
+ | Tool | What it does |
40
+ |---|---|
41
+ | `search_rfcs` | Search titles, or the full text of every RFC if a mirror has been synced |
42
+ | `list_sections` | An RFC's headings with line numbers — the cheap first call |
43
+ | `get_rfc` | Read one section, or a line range |
44
+
45
+ Every response carries a banner with the RFC's status and, when it applies, a
46
+ warning that it has been superseded:
47
+
48
+ ```
49
+ RFC 2616 — Hypertext Transfer Protocol -- HTTP/1.1 [DRAFT STANDARD]
50
+ !! OBSOLETED BY: RFC 7230, 7231, 7232, 7233, 7234, 7235
51
+ ```
52
+
53
+ That warning is the reason to use this rather than let a model fetch
54
+ `rfc-editor.org` itself. Models cite dead specifications with great confidence.
55
+
56
+ ## Full-text search
57
+
58
+ Works with no setup, fetching documents on demand. Full-text search additionally
59
+ requires a local mirror, which the user creates from a shell:
60
+
61
+ ```bash
62
+ uvx --from mcp-server-rfc python -m mcp_server_rfc.rfc sync
63
+ ```
64
+
65
+ 512 MB, a few minutes, entirely optional. The server deliberately does not
66
+ expose a sync tool: half a gigabyte pulled from a volunteer-run mirror should be
67
+ a person's decision, not a model's.
68
+
69
+ If a mirror already exists — synced for the companion skill, say — this server
70
+ picks it up automatically. Set `$RFC_MIRROR` to point at a non-default location.
71
+
72
+ ## If your client has a shell
73
+
74
+ Use the skill instead. It does the same things with fewer moving parts and gives
75
+ the agent ripgrep over the corpus:
76
+
77
+ ```bash
78
+ clawhub install @shbernal/rfc-lookup
79
+ ```
80
+
81
+ This server exists for clients that cannot run shell commands — claude.ai on the
82
+ web, Claude Desktop, Cursor, Zed.
83
+
84
+ Source, issues and the skill: https://github.com/shbernal/rfc-ai-tooling
85
+
86
+ MIT.
@@ -0,0 +1,67 @@
1
+ # mcp-server-rfc
2
+
3
+ An MCP server for looking up IETF RFCs.
4
+
5
+ ```json
6
+ {
7
+ "mcpServers": {
8
+ "rfc": {
9
+ "command": "uvx",
10
+ "args": ["mcp-server-rfc"]
11
+ }
12
+ }
13
+ }
14
+ ```
15
+
16
+ No clone, no virtualenv, no path. That is the whole installation.
17
+
18
+ ## Tools
19
+
20
+ | Tool | What it does |
21
+ |---|---|
22
+ | `search_rfcs` | Search titles, or the full text of every RFC if a mirror has been synced |
23
+ | `list_sections` | An RFC's headings with line numbers — the cheap first call |
24
+ | `get_rfc` | Read one section, or a line range |
25
+
26
+ Every response carries a banner with the RFC's status and, when it applies, a
27
+ warning that it has been superseded:
28
+
29
+ ```
30
+ RFC 2616 — Hypertext Transfer Protocol -- HTTP/1.1 [DRAFT STANDARD]
31
+ !! OBSOLETED BY: RFC 7230, 7231, 7232, 7233, 7234, 7235
32
+ ```
33
+
34
+ That warning is the reason to use this rather than let a model fetch
35
+ `rfc-editor.org` itself. Models cite dead specifications with great confidence.
36
+
37
+ ## Full-text search
38
+
39
+ Works with no setup, fetching documents on demand. Full-text search additionally
40
+ requires a local mirror, which the user creates from a shell:
41
+
42
+ ```bash
43
+ uvx --from mcp-server-rfc python -m mcp_server_rfc.rfc sync
44
+ ```
45
+
46
+ 512 MB, a few minutes, entirely optional. The server deliberately does not
47
+ expose a sync tool: half a gigabyte pulled from a volunteer-run mirror should be
48
+ a person's decision, not a model's.
49
+
50
+ If a mirror already exists — synced for the companion skill, say — this server
51
+ picks it up automatically. Set `$RFC_MIRROR` to point at a non-default location.
52
+
53
+ ## If your client has a shell
54
+
55
+ Use the skill instead. It does the same things with fewer moving parts and gives
56
+ the agent ripgrep over the corpus:
57
+
58
+ ```bash
59
+ clawhub install @shbernal/rfc-lookup
60
+ ```
61
+
62
+ This server exists for clients that cannot run shell commands — claude.ai on the
63
+ web, Claude Desktop, Cursor, Zed.
64
+
65
+ Source, issues and the skill: https://github.com/shbernal/rfc-ai-tooling
66
+
67
+ MIT.
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "mcp-server-rfc"
3
+ version = "0.1.0"
4
+ description = "MCP server for looking up IETF RFCs, with obsolescence warnings and section-level reads"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ authors = [{ name = "Santiago Bernal" }]
9
+ keywords = ["mcp", "rfc", "ietf", "standards", "llm"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Developers",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.10",
15
+ "Topic :: Internet",
16
+ "Topic :: Software Development :: Documentation",
17
+ ]
18
+ # The core is standard library only, so this is the entire dependency set.
19
+ dependencies = ["mcp>=2.0.0"]
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/shbernal/rfc-ai-tooling"
23
+ Issues = "https://github.com/shbernal/rfc-ai-tooling/issues"
24
+
25
+ [project.scripts]
26
+ mcp-server-rfc = "mcp_server_rfc.server:main"
27
+
28
+ [build-system]
29
+ requires = ["hatchling"]
30
+ build-backend = "hatchling.build"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/mcp_server_rfc"]
@@ -0,0 +1,7 @@
1
+ """MCP server over the IETF RFC corpus.
2
+
3
+ Intentionally imports nothing at package level. `python -m mcp_server_rfc.rfc`
4
+ is the documented way to reach the corpus CLI (it is how a user syncs a local
5
+ mirror), and runpy warns if the submodule has already been imported by the
6
+ package's __init__.
7
+ """
@@ -0,0 +1,981 @@
1
+ #!/usr/bin/env python3
2
+ """Look up IETF RFCs from the command line.
3
+
4
+ Standard library only, deliberately: this file is vendored verbatim into a
5
+ ClawHub skill and into a PyPI package, and both need to run wherever there is a
6
+ Python interpreter and nothing else.
7
+
8
+ Two runtime modes, decided by one predicate — whether a local mirror is
9
+ populated. Online mode fetches documents on demand over HTTPS; offline mode
10
+ reads them from disk and can search their full text. There is no build-time
11
+ flavour and no separate "offline edition"; `rfc sync` moves you from one mode to
12
+ the other and everything else behaves the same.
13
+
14
+ The index-parsing approach here — match entries by a leading RFC number, treat
15
+ "Not Issued" as a placeholder — follows mcp-server-ietf (MIT, Copyright (c) 2025
16
+ Jeff Chiang), https://github.com/tizee/mcp-server-ietf. See NOTICE. The parser
17
+ itself is a rewrite; the original's regexes are documented as counter-examples
18
+ in parse_index() below.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import contextlib
25
+ import json
26
+ import os
27
+ import re
28
+ import shutil
29
+ import subprocess
30
+ import sys
31
+ import time
32
+ import urllib.error
33
+ import urllib.request
34
+ from dataclasses import dataclass, field
35
+ from email.utils import formatdate
36
+ from pathlib import Path
37
+
38
+ __version__ = "0.1.0"
39
+
40
+ INDEX_URL = "https://www.rfc-editor.org/rfc-index.txt"
41
+ RFC_URL = "https://www.rfc-editor.org/rfc/rfc{number}.txt"
42
+ RSYNC_MODULE = "rsync.rfc-editor.org::rfcs-text-only/"
43
+
44
+ # 512 MB of RFC text is data the user asked for, not a cache a cleaner should be
45
+ # free to reclaim, so this lives under ~/.local/share rather than ~/.cache.
46
+ DEFAULT_MIRROR = Path.home() / ".local" / "share" / "rfc-ai-tooling"
47
+
48
+ INDEX_TTL_SECONDS = 7 * 24 * 60 * 60
49
+ # Enough documents that full-text search is worth offering. A handful of
50
+ # on-demand fetches accumulating in the mirror should not look like a sync.
51
+ POPULATED_THRESHOLD = 1000
52
+ SYNC_STAMP = ".rfc-sync"
53
+
54
+ USER_AGENT = f"rfc-ai-tooling/{__version__} (+https://github.com/shbernal/rfc-ai-tooling)"
55
+
56
+
57
+ class RFCError(Exception):
58
+ """Anything the user should see as a clean error rather than a traceback."""
59
+
60
+
61
+ # --------------------------------------------------------------------------
62
+ # Index records
63
+ # --------------------------------------------------------------------------
64
+
65
+
66
+ @dataclass
67
+ class Record:
68
+ number: int
69
+ title: str = ""
70
+ authors: str = ""
71
+ date: str = ""
72
+ status: str = ""
73
+ doi: str = ""
74
+ obsoletes: list[int] = field(default_factory=list)
75
+ obsoleted_by: list[int] = field(default_factory=list)
76
+ updates: list[int] = field(default_factory=list)
77
+ updated_by: list[int] = field(default_factory=list)
78
+ also: list[str] = field(default_factory=list)
79
+ not_issued: bool = False
80
+
81
+ def to_dict(self) -> dict:
82
+ return {
83
+ "number": self.number,
84
+ "title": self.title,
85
+ "authors": self.authors,
86
+ "date": self.date,
87
+ "status": self.status,
88
+ "doi": self.doi,
89
+ "obsoletes": self.obsoletes,
90
+ "obsoleted_by": self.obsoleted_by,
91
+ "updates": self.updates,
92
+ "updated_by": self.updated_by,
93
+ "also": self.also,
94
+ "not_issued": self.not_issued,
95
+ "obsolete": bool(self.obsoleted_by),
96
+ "header": self.header(),
97
+ }
98
+
99
+ def header(self) -> str:
100
+ """The banner shown above every result.
101
+
102
+ Obsolescence is the highest-value field in the index and the easiest for
103
+ a model to skip, so it goes on its own line with a marker, never folded
104
+ into a list of attributes.
105
+ """
106
+ if self.not_issued:
107
+ return f"RFC {self.number} — Not Issued"
108
+ status = f" [{self.status}]" if self.status else ""
109
+ lines = [f"RFC {self.number} — {self.title}{status}"]
110
+ if self.obsoleted_by:
111
+ refs = ", ".join(str(n) for n in self.obsoleted_by)
112
+ lines.append(f"!! OBSOLETED BY: RFC {refs}")
113
+ if self.updated_by:
114
+ refs = ", ".join(str(n) for n in self.updated_by)
115
+ lines.append(f"!! UPDATED BY: RFC {refs}")
116
+ return "\n".join(lines)
117
+
118
+
119
+ # The metadata block that closes every entry. Format/Status/DOI must be followed
120
+ # by a colon: RFC 4304's *title* contains a bare "(DOI)" and matching that as
121
+ # metadata truncates the title and loses the date.
122
+ _META_START = re.compile(
123
+ r"\s*\((?:Format:|Status:|DOI:|Obsoletes\s|Obsoleted by\s|Updates\s|Updated by\s|Also\s)"
124
+ )
125
+ _MONTHS = "January|February|March|April|May|June|July|August|September|October|November|December"
126
+ _DATE_TAIL = re.compile(rf"\s*(?:(?:{_MONTHS})\s+)?\d{{4}}\.?\s*$")
127
+ # Where the author list starts: one to four initials followed by a surname.
128
+ # Initials may be hyphenated ("M-K.", "Y.-K.") and surnames may begin lowercase
129
+ # ("N. ten Oever"), both of which occur in the real index.
130
+ _AUTHOR_ONSET = re.compile(r"^(?:[A-Z](?:[.\-]{1,2}[A-Z])*\.\s*){1,4}[A-Za-z]")
131
+ _SENTENCE_BREAK = re.compile(r"\.\s+")
132
+ _REFS = re.compile(r"\((Obsoletes|Obsoleted by|Updates|Updated by)\s+([^)]*)\)")
133
+ _ALSO = re.compile(r"\(Also\s+([^)]*)\)")
134
+ _STATUS = re.compile(r"\(Status:\s*([^)]*)\)")
135
+ _DOI = re.compile(r"\(DOI:\s*([^)]*)\)")
136
+ _RFC_REF = re.compile(r"RFC0*(\d+)")
137
+
138
+
139
+ def parse_index(text: str) -> dict[int, Record]:
140
+ """Parse rfc-index.txt into records keyed by RFC number.
141
+
142
+ The file's shape, which the three rules below all depend on:
143
+
144
+ 2616 Hypertext Transfer Protocol -- HTTP/1.1. R. Fielding, J. Gettys, J.
145
+ Mogul, H. Frystyk, L. Masinter, P. Leach, T. Berners-Lee. June 1999.
146
+ (Format: TXT, PS, PDF, HTML) (Obsoletes RFC2068) (Obsoleted by
147
+ RFC7230, RFC7231, RFC7232, RFC7233, RFC7234, RFC7235) (Updated by
148
+ RFC2817, RFC5785, RFC6266, RFC6585) (Status: DRAFT STANDARD) (DOI:
149
+ 10.17487/RFC2616)
150
+
151
+ Three traps, each of which has bitten a previous implementation:
152
+
153
+ 1. Entries begin at column 0; continuation lines are indented and entries are
154
+ separated by blank lines. Reading line by line therefore sees only the
155
+ first physical line of an entry and loses most of the metadata. Join the
156
+ record before parsing anything.
157
+
158
+ 2. RFC numbers are *not* zero-padded. A regex expecting four or five digits
159
+ silently drops all 999 RFCs below 1000 — including 768, 791, 793 and 959.
160
+
161
+ 3. The title cannot be taken as everything before the first period. That
162
+ yields "Hypertext Transfer Protocol -- HTTP/1" above, and corrupts every
163
+ title containing a version number, an abbreviation or a hostname. The
164
+ title ends where the author list begins; find that boundary explicitly.
165
+ """
166
+ records: dict[int, Record] = {}
167
+ for block in re.split(r"\n\s*\n", text):
168
+ lines = block.split("\n")
169
+ if not lines or not re.match(r"^\d+\s", lines[0]):
170
+ continue
171
+ joined = " ".join(line.strip() for line in lines if line.strip())
172
+ record = _parse_record(joined)
173
+ if record is not None:
174
+ records[record.number] = record
175
+ return records
176
+
177
+
178
+ def _parse_record(joined: str) -> Record | None:
179
+ num_text, _, rest = joined.partition(" ")
180
+ try:
181
+ number = int(num_text)
182
+ except ValueError:
183
+ return None
184
+
185
+ meta_match = _META_START.search(rest)
186
+ prose = (rest[: meta_match.start()] if meta_match else rest).strip()
187
+ metadata = rest[meta_match.start() :] if meta_match else ""
188
+
189
+ if prose.rstrip(".").strip() == "Not Issued":
190
+ return Record(number=number, title="Not Issued", not_issued=True)
191
+
192
+ record = Record(number=number)
193
+
194
+ date_match = _DATE_TAIL.search(prose)
195
+ if date_match:
196
+ record.date = prose[date_match.start() :].strip().rstrip(".")
197
+ body = prose[: date_match.start()]
198
+ else:
199
+ body = prose
200
+ body = body.strip()
201
+
202
+ split_at = None
203
+ for match in _SENTENCE_BREAK.finditer(body):
204
+ if _AUTHOR_ONSET.match(body[match.end() :]):
205
+ split_at = match
206
+ break
207
+ if split_at is None:
208
+ # Corporate authorship ("Sun Microsystems", "International Organization
209
+ # for Standardization") has no initials to key on; the first sentence
210
+ # break is the best available boundary.
211
+ split_at = _SENTENCE_BREAK.search(body)
212
+
213
+ if split_at is None:
214
+ record.title = body.rstrip(".")
215
+ else:
216
+ record.title = body[: split_at.start()].strip()
217
+ # The author list keeps its terminating period, so that a trailing
218
+ # "Ed." stays "Ed." rather than becoming "Ed" — at the cost of a doubled
219
+ # period where the last author is already an abbreviation, which the
220
+ # index itself prints as "J. Reschke, Ed..".
221
+ authors = body[split_at.end() :].strip()
222
+ record.authors = authors[:-1] if authors.endswith("..") else authors
223
+
224
+ for match in _REFS.finditer(metadata):
225
+ refs = [int(n) for n in _RFC_REF.findall(match.group(2))]
226
+ key = match.group(1).lower().replace(" ", "_")
227
+ getattr(record, key).extend(refs)
228
+ for match in _ALSO.finditer(metadata):
229
+ record.also.append(match.group(1).strip())
230
+ status_match = _STATUS.search(metadata)
231
+ if status_match:
232
+ record.status = status_match.group(1).strip()
233
+ doi_match = _DOI.search(metadata)
234
+ if doi_match:
235
+ record.doi = doi_match.group(1).strip()
236
+ return record
237
+
238
+
239
+ # --------------------------------------------------------------------------
240
+ # Mirror and mode
241
+ # --------------------------------------------------------------------------
242
+
243
+
244
+ def resolve_mirror(override: str | None = None) -> Path:
245
+ if override:
246
+ return Path(override).expanduser()
247
+ env = os.environ.get("RFC_MIRROR")
248
+ if env:
249
+ return Path(env).expanduser()
250
+ return DEFAULT_MIRROR
251
+
252
+
253
+ def index_path(mirror: Path) -> Path:
254
+ return mirror / "rfc-index.txt"
255
+
256
+
257
+ def document_path(mirror: Path, number: int) -> Path:
258
+ return mirror / f"rfc{number}.txt"
259
+
260
+
261
+ def count_documents(mirror: Path) -> int:
262
+ if not mirror.is_dir():
263
+ return 0
264
+ return sum(1 for _ in mirror.glob("rfc[0-9]*.txt"))
265
+
266
+
267
+ def is_populated(mirror: Path) -> bool:
268
+ """True when the mirror holds enough of the corpus to search its full text."""
269
+ if not index_path(mirror).exists():
270
+ return False
271
+ if (mirror / SYNC_STAMP).exists():
272
+ return True
273
+ return count_documents(mirror) > POPULATED_THRESHOLD
274
+
275
+
276
+ # --------------------------------------------------------------------------
277
+ # Network
278
+ # --------------------------------------------------------------------------
279
+
280
+
281
+ def _fetch(
282
+ url: str,
283
+ if_modified_since: float | None = None,
284
+ etag: str | None = None,
285
+ want_etag: bool = False,
286
+ ) -> bytes | None | tuple[bytes | None, str | None]:
287
+ """GET a URL. Returns None (or (None, etag)) if the server answers 304."""
288
+ request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
289
+ if if_modified_since is not None:
290
+ request.add_header("If-Modified-Since", formatdate(if_modified_since, usegmt=True))
291
+ if etag:
292
+ request.add_header("If-None-Match", etag)
293
+ try:
294
+ with urllib.request.urlopen(request, timeout=30) as response:
295
+ body = response.read()
296
+ if want_etag:
297
+ return body, response.headers.get("ETag")
298
+ return body
299
+ except urllib.error.HTTPError as exc:
300
+ if exc.code == 304:
301
+ return (None, etag) if want_etag else None
302
+ if exc.code == 404:
303
+ raise RFCError(f"not found: {url}") from exc
304
+ raise RFCError(f"HTTP {exc.code} fetching {url}") from exc
305
+ except urllib.error.URLError as exc:
306
+ raise RFCError(f"network error fetching {url}: {exc.reason}") from exc
307
+
308
+
309
+ def ensure_index(mirror: Path, ttl: int = INDEX_TTL_SECONDS, force: bool = False) -> Path:
310
+ """Return the local index, refreshing it if it is missing or older than the TTL.
311
+
312
+ The index must not be a write-once cache. An index fetched in January will
313
+ report an RFC published in March as nonexistent, even though its URL fetches
314
+ perfectly well, and there is no way for the user to tell why.
315
+
316
+ Revalidation sends both If-None-Match and If-Modified-Since. As of
317
+ 2026-08-01 the RFC Editor's CDN honours neither — it answers 200 with the
318
+ full 2 MB even when handed back its own ETag verbatim — so treat the 304
319
+ path as an optimisation that may start working rather than one that does.
320
+ That is affordable only because the TTL is a week; do not shorten it without
321
+ re-checking whether conditional requests have started working.
322
+ """
323
+ path = index_path(mirror)
324
+ etag_path = mirror / ".rfc-index.etag"
325
+ fresh_enough = False
326
+ if path.exists() and not force:
327
+ age = time.time() - path.stat().st_mtime
328
+ fresh_enough = age < ttl
329
+ if fresh_enough:
330
+ return path
331
+
332
+ known_etag = None
333
+ if path.exists() and etag_path.exists():
334
+ known_etag = etag_path.read_text(encoding="utf-8").strip() or None
335
+
336
+ result = _fetch(
337
+ INDEX_URL,
338
+ if_modified_since=path.stat().st_mtime if path.exists() else None,
339
+ etag=known_etag,
340
+ want_etag=True,
341
+ )
342
+ data, etag = result # type: ignore[misc]
343
+
344
+ if data is None:
345
+ path.touch()
346
+ return path
347
+
348
+ mirror.mkdir(parents=True, exist_ok=True)
349
+ path.write_bytes(data)
350
+ if etag:
351
+ # Losing the ETag only costs a revalidation next time.
352
+ with contextlib.suppress(OSError):
353
+ etag_path.write_text(etag, encoding="utf-8")
354
+ return path
355
+
356
+
357
+ def load_index(mirror: Path, offline_only: bool = False) -> dict[int, Record]:
358
+ path = index_path(mirror)
359
+ if not offline_only:
360
+ path = ensure_index(mirror)
361
+ if not path.exists():
362
+ raise RFCError(
363
+ f"no RFC index at {path}. Run `rfc status` while online to fetch it, "
364
+ f"or `rfc sync` for the full corpus."
365
+ )
366
+ return parse_index(path.read_text(encoding="utf-8", errors="replace"))
367
+
368
+
369
+ def read_document(mirror: Path, number: int) -> str:
370
+ """Read an RFC from the mirror, fetching and caching it if absent.
371
+
372
+ Note that this does not consult the index first. An RFC published since the
373
+ last index refresh still fetches perfectly well, and gating retrieval on a
374
+ cached index is how a stale index turns into "no such RFC".
375
+ """
376
+ path = document_path(mirror, number)
377
+ if path.exists():
378
+ return path.read_text(encoding="utf-8", errors="replace")
379
+ data = _fetch(RFC_URL.format(number=number))
380
+ if data is None:
381
+ raise RFCError(f"RFC {number} could not be retrieved")
382
+ text = data.decode("utf-8", errors="replace")
383
+ try:
384
+ mirror.mkdir(parents=True, exist_ok=True)
385
+ path.write_text(text, encoding="utf-8")
386
+ except OSError:
387
+ pass # A read-only mirror is fine; we just do not get to cache.
388
+ return text
389
+
390
+
391
+ # --------------------------------------------------------------------------
392
+ # Document structure
393
+ # --------------------------------------------------------------------------
394
+
395
+
396
+ def split_lines(text: str) -> list[str]:
397
+ """Split on newlines only.
398
+
399
+ Not str.splitlines(): it also breaks on form feeds, which paginated RFCs use
400
+ by the hundred. Line numbers computed that way disagree with the file itself
401
+ and with every other tool the agent might reach for, including the ripgrep
402
+ output from full-text search.
403
+ """
404
+ return text.split("\n")
405
+
406
+
407
+ _HEADING = re.compile(r"^(\d+(?:\.\d+)*)\.?[ \t]+(\S.*)$")
408
+ _PAGE_FOOTER = re.compile(r"\[Page \d+\]\s*$")
409
+ _RUNNING_HEADER = re.compile(r"^RFC \d+\s+.*\s\d{4}\s*$")
410
+
411
+
412
+ def furniture_mask(lines: list[str]) -> list[bool]:
413
+ """Mark page headers, footers and form feeds.
414
+
415
+ Pre-RFC-8650 documents are paginated for print: each page ends with a
416
+ "[Page N]" footer, a form feed, and a running header repeating the RFC
417
+ number and date. None of it is content. Post-8650 documents are generated
418
+ from XML and have none of it, so the mask is simply all False there.
419
+
420
+ Returned as a mask rather than a filtered list so that reported line numbers
421
+ always refer to the original file and stay valid across --raw.
422
+ """
423
+ mask = [False] * len(lines)
424
+ for i, line in enumerate(lines):
425
+ if "\f" in line or _PAGE_FOOTER.search(line) or _RUNNING_HEADER.match(line):
426
+ mask[i] = True
427
+ return mask
428
+
429
+
430
+ def find_sections(lines: list[str]) -> list[dict]:
431
+ """Numbered headings with their 1-based line numbers.
432
+
433
+ Headings sit at column 0 in every RFC generation; the table of contents is
434
+ indented, which is what keeps it out of the results.
435
+ """
436
+ mask = furniture_mask(lines)
437
+ sections = []
438
+ for i, line in enumerate(lines):
439
+ if mask[i]:
440
+ continue
441
+ match = _HEADING.match(line)
442
+ if not match:
443
+ continue
444
+ number = match.group(1)
445
+ sections.append(
446
+ {
447
+ "section": number,
448
+ "title": match.group(2).strip(),
449
+ "line": i + 1,
450
+ "depth": number.count(".") + 1,
451
+ }
452
+ )
453
+ return sections
454
+
455
+
456
+ def section_range(sections: list[dict], wanted: str, total_lines: int) -> tuple[int, int, dict]:
457
+ """Resolve a section selector to a 1-based inclusive line range.
458
+
459
+ A section runs until the next heading at the same or a shallower depth, so
460
+ asking for section 6 includes 6.1 and 6.2 but stops at section 7.
461
+ """
462
+ target = wanted.strip().rstrip(".")
463
+ index = None
464
+ for i, section in enumerate(sections):
465
+ if section["section"] == target:
466
+ index = i
467
+ break
468
+ if index is None:
469
+ lowered = target.lower()
470
+ for i, section in enumerate(sections):
471
+ if lowered in section["title"].lower():
472
+ index = i
473
+ break
474
+ if index is None:
475
+ raise RFCError(f"no section {wanted!r}; run `sections` to list them")
476
+
477
+ start = sections[index]["line"]
478
+ depth = sections[index]["depth"]
479
+ end = total_lines
480
+ for section in sections[index + 1 :]:
481
+ if section["depth"] <= depth:
482
+ end = section["line"] - 1
483
+ break
484
+ return start, end, sections[index]
485
+
486
+
487
+ def slice_lines(lines: list[str], start: int, end: int, raw: bool) -> str:
488
+ """Extract 1-based inclusive lines, dropping page furniture unless raw."""
489
+ start = max(1, start)
490
+ end = min(len(lines), end)
491
+ if start > end:
492
+ return ""
493
+ chunk = lines[start - 1 : end]
494
+ if raw:
495
+ return "\n".join(chunk)
496
+ mask = furniture_mask(lines)[start - 1 : end]
497
+ kept = [line for line, is_furniture in zip(chunk, mask, strict=True) if not is_furniture]
498
+ return _collapse_blank_runs(kept)
499
+
500
+
501
+ def _collapse_blank_runs(lines: list[str]) -> str:
502
+ out: list[str] = []
503
+ blanks = 0
504
+ for line in lines:
505
+ if line.strip():
506
+ blanks = 0
507
+ out.append(line)
508
+ else:
509
+ blanks += 1
510
+ if blanks <= 1:
511
+ out.append("")
512
+ return "\n".join(out).strip("\n")
513
+
514
+
515
+ # --------------------------------------------------------------------------
516
+ # Search
517
+ # --------------------------------------------------------------------------
518
+
519
+
520
+ def search_titles(
521
+ records: dict[int, Record], query: str, limit: int, use_regex: bool = False
522
+ ) -> list[Record]:
523
+ if use_regex:
524
+ try:
525
+ pattern = re.compile(query, re.IGNORECASE)
526
+ except re.error as exc:
527
+ raise RFCError(f"bad regular expression: {exc}") from exc
528
+
529
+ def matches(title: str) -> bool:
530
+ return pattern.search(title) is not None
531
+ else:
532
+ terms = [t.lower() for t in query.split() if t]
533
+
534
+ def matches(title: str) -> bool:
535
+ lowered = title.lower()
536
+ return all(term in lowered for term in terms)
537
+
538
+ hits = [r for _, r in sorted(records.items()) if not r.not_issued and matches(r.title)]
539
+ return hits[:limit]
540
+
541
+
542
+ def _search_tool() -> tuple[str, bool]:
543
+ """Pick a full-text search backend: ripgrep if present, else grep."""
544
+ if shutil.which("rg"):
545
+ return "rg", True
546
+ if shutil.which("grep"):
547
+ return "grep", False
548
+ raise RFCError("full-text search needs `rg` or `grep` on PATH; neither was found")
549
+
550
+
551
+ def search_fulltext(
552
+ mirror: Path, query: str, limit: int, max_lines_per_doc: int = 3
553
+ ) -> tuple[list[dict], str]:
554
+ """Rank documents by hit count, then pull a few matching lines from each.
555
+
556
+ Two passes so that ranking reflects real hit counts: capping matches per
557
+ file in a single pass would flatten every document to the same score.
558
+ """
559
+ tool, is_rg = _search_tool()
560
+ if is_rg:
561
+ count_cmd = [
562
+ "rg",
563
+ "--count-matches",
564
+ "--no-messages",
565
+ "-i",
566
+ "-e",
567
+ query,
568
+ "--glob",
569
+ "rfc*.txt",
570
+ str(mirror),
571
+ ]
572
+ else:
573
+ count_cmd = ["grep", "-rciE", "--include=rfc*.txt", "--", query, str(mirror)]
574
+
575
+ counts: list[tuple[int, int]] = []
576
+ for line in _run_search(count_cmd):
577
+ path, _, count = line.rpartition(":")
578
+ number = _number_from_path(path)
579
+ if number is None:
580
+ continue
581
+ try:
582
+ hits = int(count)
583
+ except ValueError:
584
+ continue
585
+ if hits:
586
+ counts.append((hits, number))
587
+ counts.sort(key=lambda pair: (-pair[0], pair[1]))
588
+
589
+ results = []
590
+ for hits, number in counts[:limit]:
591
+ path = document_path(mirror, number)
592
+ # --no-filename / -h keep the output shape at "line:text" for both tools.
593
+ if is_rg:
594
+ line_cmd = [
595
+ "rg",
596
+ "--line-number",
597
+ "--no-filename",
598
+ "--no-heading",
599
+ "--no-messages",
600
+ "--color",
601
+ "never",
602
+ "--max-count",
603
+ str(max_lines_per_doc),
604
+ "-i",
605
+ "-e",
606
+ query,
607
+ str(path),
608
+ ]
609
+ else:
610
+ line_cmd = ["grep", "-nhiE", "-m", str(max_lines_per_doc), "--", query, str(path)]
611
+ matches = []
612
+ for line in _run_search(line_cmd):
613
+ lineno, _, body = line.partition(":")
614
+ if not lineno.isdigit():
615
+ continue
616
+ matches.append({"line": int(lineno), "text": body.strip()})
617
+ results.append({"number": number, "hits": hits, "matches": matches})
618
+ return results, tool
619
+
620
+
621
+ def _run_search(cmd: list[str]) -> list[str]:
622
+ try:
623
+ proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
624
+ except OSError as exc:
625
+ raise RFCError(f"could not run {cmd[0]}: {exc}") from exc
626
+ # Exit status 1 means "no matches" for both rg and grep; only 2+ is an error.
627
+ if proc.returncode >= 2:
628
+ message = proc.stderr.strip() or f"{cmd[0]} exited {proc.returncode}"
629
+ raise RFCError(f"search failed: {message}")
630
+ return [line for line in proc.stdout.splitlines() if line]
631
+
632
+
633
+ def _number_from_path(path: str) -> int | None:
634
+ match = re.search(r"rfc(\d+)\.txt$", path)
635
+ return int(match.group(1)) if match else None
636
+
637
+
638
+ # --------------------------------------------------------------------------
639
+ # Sync
640
+ # --------------------------------------------------------------------------
641
+
642
+
643
+ def sync_command(mirror: Path, bwlimit: str, dry_run: bool = False) -> list[str]:
644
+ """Build the rsync invocation. --delete is not optional.
645
+
646
+ Without --delete, documents removed upstream linger while the index no
647
+ longer lists them, and the two views of the corpus drift apart.
648
+ """
649
+ cmd = [
650
+ "rsync",
651
+ "-az",
652
+ "--delete",
653
+ f"--bwlimit={bwlimit}",
654
+ "--include=rfc[0-9]*.txt",
655
+ "--include=rfc-index.txt",
656
+ "--exclude=*",
657
+ RSYNC_MODULE,
658
+ f"{mirror}/",
659
+ ]
660
+ if dry_run:
661
+ cmd.insert(1, "--dry-run")
662
+ return cmd
663
+
664
+
665
+ def run_sync(mirror: Path, bwlimit: str, assume_yes: bool, dry_run: bool) -> int:
666
+ if not shutil.which("rsync"):
667
+ raise RFCError("`rsync` is not on PATH; install it and try again")
668
+ cmd = sync_command(mirror, bwlimit, dry_run)
669
+ print(f"About to sync the RFC text corpus into {mirror}")
670
+ print(" roughly 9,800 documents, 512 MB, a few minutes")
671
+ print(f" source: {RSYNC_MODULE} (volunteer-operated; bandwidth limited to {bwlimit})")
672
+ print(f" {' '.join(cmd)}")
673
+ if not assume_yes and not dry_run:
674
+ if not sys.stdin.isatty():
675
+ raise RFCError("refusing to sync non-interactively without --yes")
676
+ answer = input("Proceed? [y/N] ").strip().lower()
677
+ if answer not in {"y", "yes"}:
678
+ print("Cancelled.")
679
+ return 1
680
+ mirror.mkdir(parents=True, exist_ok=True)
681
+ result = subprocess.run(cmd, check=False)
682
+ if result.returncode == 0 and not dry_run:
683
+ (mirror / SYNC_STAMP).write_text(f"synced {formatdate(usegmt=True)}\n", encoding="utf-8")
684
+ print(f"\nDone. {count_documents(mirror)} documents in {mirror}")
685
+ return result.returncode
686
+
687
+
688
+ # --------------------------------------------------------------------------
689
+ # CLI
690
+ # --------------------------------------------------------------------------
691
+
692
+
693
+ def parse_number(text: str) -> int:
694
+ match = re.fullmatch(r"(?:rfc\s*)?0*(\d+)", text.strip(), re.IGNORECASE)
695
+ if not match:
696
+ raise RFCError(f"not an RFC number: {text!r}")
697
+ return int(match.group(1))
698
+
699
+
700
+ def _emit(payload: dict, human: str, as_json: bool) -> None:
701
+ if as_json:
702
+ print(json.dumps(payload, indent=2))
703
+ else:
704
+ print(human)
705
+
706
+
707
+ def _describe_age(seconds: float) -> str:
708
+ if seconds < 86400:
709
+ hours = round(seconds / 3600)
710
+ return f"{hours} hour{'s' if hours != 1 else ''} old"
711
+ days = round(seconds / 86400)
712
+ return f"{days} day{'s' if days != 1 else ''} old"
713
+
714
+
715
+ def cmd_status(args: argparse.Namespace) -> int:
716
+ mirror = resolve_mirror(args.mirror)
717
+ populated = is_populated(mirror)
718
+ docs = count_documents(mirror)
719
+ path = index_path(mirror)
720
+
721
+ index_info: dict = {"present": path.exists(), "path": str(path)}
722
+ lines = [
723
+ f"mode: {'offline' if populated else 'online'}"
724
+ f" ({'full-text search available' if populated else 'title search only'})",
725
+ f"mirror: {mirror} ({docs} document{'s' if docs != 1 else ''})",
726
+ ]
727
+ if path.exists():
728
+ age = time.time() - path.stat().st_mtime
729
+ index_info["age_seconds"] = int(age)
730
+ index_info["stale"] = age > INDEX_TTL_SECONDS
731
+ try:
732
+ entries = len(parse_index(path.read_text(encoding="utf-8", errors="replace")))
733
+ except OSError:
734
+ entries = 0
735
+ index_info["entries"] = entries
736
+ lines.append(f"index: {path} ({entries} entries, {_describe_age(age)})")
737
+ else:
738
+ lines.append(f"index: not present (will be fetched on first use) — {path}")
739
+ if not populated:
740
+ lines.append("run `rfc sync` for full-text search across the whole corpus (512 MB)")
741
+
742
+ payload = {
743
+ "mode": "offline" if populated else "online",
744
+ "fulltext_available": populated,
745
+ "mirror": str(mirror),
746
+ "documents": docs,
747
+ "index": index_info,
748
+ }
749
+ _emit(payload, "\n".join(lines), args.json)
750
+ return 0
751
+
752
+
753
+ def cmd_meta(args: argparse.Namespace) -> int:
754
+ mirror = resolve_mirror(args.mirror)
755
+ number = parse_number(args.number)
756
+ records = load_index(mirror)
757
+ record = records.get(number)
758
+ if record is None:
759
+ raise RFCError(
760
+ f"RFC {number} is not in the index. If it was published very recently, "
761
+ f"`rfc get {number}` may still retrieve it."
762
+ )
763
+ lines = [record.header()]
764
+ if record.authors:
765
+ lines.append(f"Authors: {record.authors}")
766
+ if record.date:
767
+ lines.append(f"Date: {record.date}")
768
+ for label, refs in (
769
+ ("Obsoletes", record.obsoletes),
770
+ ("Updates", record.updates),
771
+ ):
772
+ if refs:
773
+ lines.append(f"{label}: RFC " + ", ".join(str(n) for n in refs))
774
+ if record.also:
775
+ lines.append("Also: " + ", ".join(record.also))
776
+ if record.doi:
777
+ lines.append(f"DOI: {record.doi}")
778
+ _emit(record.to_dict(), "\n".join(lines), args.json)
779
+ return 0
780
+
781
+
782
+ def cmd_search(args: argparse.Namespace) -> int:
783
+ mirror = resolve_mirror(args.mirror)
784
+ populated = is_populated(mirror)
785
+
786
+ if args.fulltext and not populated:
787
+ raise RFCError(
788
+ "full-text search needs a local mirror, which is not present.\n"
789
+ "Run `rfc sync` (512 MB, a few minutes) to enable it, or search titles "
790
+ "by dropping --fulltext.\n"
791
+ "Not falling back to title search: it would quietly answer a different "
792
+ "question than the one asked."
793
+ )
794
+
795
+ if args.fulltext:
796
+ results, tool = search_fulltext(mirror, args.query, args.limit)
797
+ records = load_index(mirror, offline_only=True)
798
+ enriched = []
799
+ human = []
800
+ for result in results:
801
+ record = records.get(result["number"])
802
+ header = record.header() if record else f"RFC {result['number']}"
803
+ enriched.append(
804
+ {**result, "header": header, "record": record.to_dict() if record else None}
805
+ )
806
+ human.append(header)
807
+ for match in result["matches"]:
808
+ human.append(f" {match['line']}: {match['text']}")
809
+ human.append("")
810
+ payload = {
811
+ "query": args.query,
812
+ "scope": "fulltext",
813
+ "tool": tool,
814
+ "count": len(enriched),
815
+ "results": enriched,
816
+ }
817
+ _emit(payload, "\n".join(human).strip() or "no matches", args.json)
818
+ return 0
819
+
820
+ records = load_index(mirror)
821
+ hits = search_titles(records, args.query, args.limit, args.regex)
822
+ payload = {
823
+ "query": args.query,
824
+ "scope": "title",
825
+ "count": len(hits),
826
+ "results": [r.to_dict() for r in hits],
827
+ }
828
+ if hits:
829
+ human = "\n".join(r.header() for r in hits)
830
+ if not populated:
831
+ human += "\n\n(titles only — `rfc sync` enables full-text search)"
832
+ else:
833
+ human = "no matches"
834
+ _emit(payload, human, args.json)
835
+ return 0
836
+
837
+
838
+ def cmd_sections(args: argparse.Namespace) -> int:
839
+ mirror = resolve_mirror(args.mirror)
840
+ number = parse_number(args.number)
841
+ lines = split_lines(read_document(mirror, number))
842
+ sections = find_sections(lines)
843
+ header = _header_for(mirror, number)
844
+ human = [header]
845
+ if sections:
846
+ human += [
847
+ f"{' ' * (s['depth'] - 1)}{s['section']} {s['title']} (line {s['line']})"
848
+ for s in sections
849
+ ]
850
+ else:
851
+ human.append(
852
+ "(no numbered headings found — this RFC is not sectioned in the usual "
853
+ "way; use `get --lines A:B`)"
854
+ )
855
+ payload = {"number": number, "header": header, "total_lines": len(lines), "sections": sections}
856
+ _emit(payload, "\n".join(human), args.json)
857
+ return 0
858
+
859
+
860
+ def _header_for(mirror: Path, number: int) -> str:
861
+ """Best-effort banner.
862
+
863
+ Worth fetching the index for if it is missing, because the obsolescence
864
+ warning is the main thing this adds over reading the document directly. But
865
+ a missing index must never block reading: fall back to a bare banner.
866
+ """
867
+ try:
868
+ record = load_index(mirror).get(number)
869
+ except RFCError:
870
+ record = None
871
+ return record.header() if record else f"RFC {number}"
872
+
873
+
874
+ def cmd_get(args: argparse.Namespace) -> int:
875
+ mirror = resolve_mirror(args.mirror)
876
+ number = parse_number(args.number)
877
+ lines = split_lines(read_document(mirror, number))
878
+ header = _header_for(mirror, number)
879
+
880
+ section_info = None
881
+ if args.section:
882
+ sections = find_sections(lines)
883
+ start, end, section_info = section_range(sections, args.section, len(lines))
884
+ elif args.lines:
885
+ match = re.fullmatch(r"(\d+):(\d+)?", args.lines.strip())
886
+ if not match:
887
+ raise RFCError(f"--lines wants START:END, got {args.lines!r}")
888
+ start = int(match.group(1))
889
+ end = int(match.group(2)) if match.group(2) else len(lines)
890
+ else:
891
+ start, end = 1, len(lines)
892
+
893
+ body = slice_lines(lines, start, end, args.raw)
894
+ payload = {
895
+ "number": number,
896
+ "header": header,
897
+ "section": section_info,
898
+ "start_line": start,
899
+ "end_line": min(end, len(lines)),
900
+ "total_lines": len(lines),
901
+ "content": body,
902
+ }
903
+ _emit(payload, f"{header}\n\n{body}", args.json)
904
+ return 0
905
+
906
+
907
+ def cmd_sync(args: argparse.Namespace) -> int:
908
+ mirror = resolve_mirror(args.mirror)
909
+ return run_sync(mirror, args.bwlimit, args.yes, args.dry_run)
910
+
911
+
912
+ def build_parser() -> argparse.ArgumentParser:
913
+ parser = argparse.ArgumentParser(
914
+ prog="rfc",
915
+ description="Look up IETF RFCs. Works with no setup; `rfc sync` adds full-text search.",
916
+ )
917
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
918
+ parser.add_argument(
919
+ "--mirror", help=f"corpus directory (default: $RFC_MIRROR or {DEFAULT_MIRROR})"
920
+ )
921
+ sub = parser.add_subparsers(dest="command", required=True)
922
+
923
+ def add_read_flags(p: argparse.ArgumentParser) -> None:
924
+ p.add_argument("--json", action="store_true", help="machine-readable output")
925
+
926
+ p_status = sub.add_parser("status", help="show mode, mirror and index age")
927
+ add_read_flags(p_status)
928
+ p_status.set_defaults(func=cmd_status)
929
+
930
+ p_search = sub.add_parser("search", help="search RFC titles, or full text with --fulltext")
931
+ p_search.add_argument("query")
932
+ p_search.add_argument(
933
+ "--fulltext", action="store_true", help="search document bodies (needs a synced mirror)"
934
+ )
935
+ p_search.add_argument("--regex", action="store_true", help="treat the query as a regex")
936
+ p_search.add_argument("--limit", type=int, default=20)
937
+ add_read_flags(p_search)
938
+ p_search.set_defaults(func=cmd_search)
939
+
940
+ p_meta = sub.add_parser("meta", help="title, status, and what obsoletes what")
941
+ p_meta.add_argument("number")
942
+ add_read_flags(p_meta)
943
+ p_meta.set_defaults(func=cmd_meta)
944
+
945
+ p_sections = sub.add_parser("sections", help="list headings with line numbers")
946
+ p_sections.add_argument("number")
947
+ add_read_flags(p_sections)
948
+ p_sections.set_defaults(func=cmd_sections)
949
+
950
+ p_get = sub.add_parser("get", help="read an RFC, ideally one section at a time")
951
+ p_get.add_argument("number")
952
+ p_get.add_argument("--section", help="section number (e.g. 6.1) or heading text")
953
+ p_get.add_argument("--lines", help="line range START:END, for RFCs without headings")
954
+ p_get.add_argument("--raw", action="store_true", help="keep page headers and footers")
955
+ add_read_flags(p_get)
956
+ p_get.set_defaults(func=cmd_get)
957
+
958
+ p_sync = sub.add_parser("sync", help="download the corpus for full-text search (512 MB)")
959
+ p_sync.add_argument("--bwlimit", default="2M", help="rsync bandwidth limit (default: 2M)")
960
+ p_sync.add_argument("--yes", action="store_true", help="skip the confirmation prompt")
961
+ p_sync.add_argument("--dry-run", action="store_true")
962
+ p_sync.set_defaults(func=cmd_sync)
963
+
964
+ return parser
965
+
966
+
967
+ def main(argv: list[str] | None = None) -> int:
968
+ args = build_parser().parse_args(argv)
969
+ try:
970
+ return args.func(args)
971
+ except RFCError as exc:
972
+ print(f"rfc: {exc}", file=sys.stderr)
973
+ return 1
974
+ except BrokenPipeError:
975
+ return 0
976
+ except KeyboardInterrupt:
977
+ return 130
978
+
979
+
980
+ if __name__ == "__main__":
981
+ sys.exit(main())
@@ -0,0 +1,188 @@
1
+ """MCP server exposing the RFC corpus.
2
+
3
+ Deliberately thin. Everything of substance lives in rfc.py, which is shared
4
+ verbatim with the skill; anything that grows here is logic the two surfaces
5
+ would eventually disagree about.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import os
12
+ import sys
13
+
14
+ from mcp.server import MCPServer
15
+ from mcp.types import ToolAnnotations
16
+
17
+ from . import rfc
18
+
19
+ # stderr, INFO, and payloads never logged. A stdio server's stdout is the
20
+ # protocol channel, and a document fetcher that logs what it returns writes the
21
+ # corpus to disk a second time — the implementation this replaces had
22
+ # accumulated 3.8 MB of log against 1.1 MB of retrieved RFCs.
23
+ logging.basicConfig(
24
+ level=os.environ.get("RFC_LOG_LEVEL", "INFO").upper(),
25
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
26
+ handlers=[logging.FileHandler(os.environ["RFC_LOG_FILE"])]
27
+ if os.environ.get("RFC_LOG_FILE")
28
+ else [logging.StreamHandler(sys.stderr)],
29
+ )
30
+ logger = logging.getLogger("mcp-server-rfc")
31
+
32
+ server = MCPServer(
33
+ "rfc",
34
+ version=rfc.__version__,
35
+ instructions=(
36
+ "Look up IETF RFCs. Call list_sections before get_rfc so you read one "
37
+ "section rather than a whole specification. Every response carries a "
38
+ "banner; when it says OBSOLETED BY, read the replacement and cite that "
39
+ "instead."
40
+ ),
41
+ )
42
+
43
+ READ_ONLY = ToolAnnotations(read_only_hint=True, open_world_hint=True)
44
+
45
+ _index: dict[int, rfc.Record] | None = None
46
+
47
+
48
+ def _mirror():
49
+ return rfc.resolve_mirror()
50
+
51
+
52
+ def _index_records() -> dict[int, rfc.Record]:
53
+ """Load the index on first use and keep it.
54
+
55
+ Not loaded at startup: a client that connects and asks nothing should not
56
+ pay for parsing 2 MB.
57
+ """
58
+ global _index
59
+ if _index is None:
60
+ _index = rfc.load_index(_mirror())
61
+ return _index
62
+
63
+
64
+ def _fail(message: str) -> dict:
65
+ return {"error": message}
66
+
67
+
68
+ @server.tool(
69
+ annotations=READ_ONLY,
70
+ description=(
71
+ "Search RFCs. scope='title' matches all query terms against RFC titles and "
72
+ "always works. scope='fulltext' searches the text of every RFC but needs a "
73
+ "local mirror the user has synced; it returns an error explaining what to run "
74
+ "if there is none. Results carry each RFC's status and obsolescence."
75
+ ),
76
+ )
77
+ def search_rfcs(query: str, scope: str = "title", limit: int = 20) -> dict:
78
+ mirror = _mirror()
79
+ if scope not in {"title", "fulltext"}:
80
+ return _fail("scope must be 'title' or 'fulltext'")
81
+
82
+ try:
83
+ if scope == "fulltext":
84
+ if not rfc.is_populated(mirror):
85
+ return _fail(
86
+ "Full-text search needs a local RFC mirror, which is not present. "
87
+ "The user can create one by running `rfc sync` in a shell "
88
+ "(512 MB, a few minutes). Searching titles instead would answer a "
89
+ "different question, so this is an error rather than a fallback. "
90
+ "Retry with scope='title' if a title search is what you want."
91
+ )
92
+ results, _ = rfc.search_fulltext(mirror, query, limit)
93
+ records = rfc.load_index(mirror, offline_only=True)
94
+ for result in results:
95
+ record = records.get(result["number"])
96
+ result["header"] = record.header() if record else f"RFC {result['number']}"
97
+ result["title"] = record.title if record else ""
98
+ return {"query": query, "scope": scope, "count": len(results), "results": results}
99
+
100
+ hits = rfc.search_titles(_index_records(), query, limit)
101
+ return {
102
+ "query": query,
103
+ "scope": scope,
104
+ "count": len(hits),
105
+ "fulltext_available": rfc.is_populated(mirror),
106
+ "results": [r.to_dict() for r in hits],
107
+ }
108
+ except rfc.RFCError as exc:
109
+ return _fail(str(exc))
110
+
111
+
112
+ @server.tool(
113
+ annotations=READ_ONLY,
114
+ description=(
115
+ "List an RFC's numbered headings with their line numbers. Cheap, and the right "
116
+ "first call when you need part of a specification: use it to pick a section, "
117
+ "then pass that section to get_rfc. Some pre-1990 RFCs have no numbered "
118
+ "headings, in which case this returns an empty list and get_rfc's line range "
119
+ "is the way in."
120
+ ),
121
+ )
122
+ def list_sections(number: int) -> dict:
123
+ mirror = _mirror()
124
+ try:
125
+ lines = rfc.split_lines(rfc.read_document(mirror, number))
126
+ record = _index_records().get(number)
127
+ return {
128
+ "number": number,
129
+ "header": record.header() if record else f"RFC {number}",
130
+ "total_lines": len(lines),
131
+ "sections": rfc.find_sections(lines),
132
+ }
133
+ except rfc.RFCError as exc:
134
+ return _fail(str(exc))
135
+
136
+
137
+ @server.tool(
138
+ annotations=READ_ONLY,
139
+ description=(
140
+ "Read an RFC. Call list_sections first and pass a section — whole RFCs average "
141
+ "53 KB and run past 1.6 MB, and reading one in full is almost never what the "
142
+ "question needs. section accepts a number ('9.3.1') or heading text "
143
+ "('Idempotent Methods') and includes that section's subsections. start_line and "
144
+ "max_lines are the fallback for RFCs without numbered headings. Page headers "
145
+ "and footers are stripped. The response carries a banner naming the RFC's "
146
+ "status and, if it has been superseded, what replaced it."
147
+ ),
148
+ )
149
+ def get_rfc(
150
+ number: int,
151
+ section: str | None = None,
152
+ start_line: int | None = None,
153
+ max_lines: int | None = None,
154
+ ) -> dict:
155
+ mirror = _mirror()
156
+ try:
157
+ lines = rfc.split_lines(rfc.read_document(mirror, number))
158
+ record = _index_records().get(number)
159
+ header = record.header() if record else f"RFC {number}"
160
+
161
+ section_info = None
162
+ if section:
163
+ sections = rfc.find_sections(lines)
164
+ start, end, section_info = rfc.section_range(sections, section, len(lines))
165
+ else:
166
+ start = start_line or 1
167
+ end = start + max_lines - 1 if max_lines else len(lines)
168
+
169
+ return {
170
+ "number": number,
171
+ "header": header,
172
+ "section": section_info,
173
+ "start_line": start,
174
+ "end_line": min(end, len(lines)),
175
+ "total_lines": len(lines),
176
+ "content": rfc.slice_lines(lines, start, end, raw=False),
177
+ }
178
+ except rfc.RFCError as exc:
179
+ return _fail(str(exc))
180
+
181
+
182
+ def main() -> None:
183
+ logger.info("mcp-server-rfc %s starting on stdio", rfc.__version__)
184
+ server.run("stdio")
185
+
186
+
187
+ if __name__ == "__main__":
188
+ main()