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.
- mcp_server_rfc-0.1.0/.gitignore +9 -0
- mcp_server_rfc-0.1.0/PKG-INFO +86 -0
- mcp_server_rfc-0.1.0/README.md +67 -0
- mcp_server_rfc-0.1.0/pyproject.toml +33 -0
- mcp_server_rfc-0.1.0/src/mcp_server_rfc/__init__.py +7 -0
- mcp_server_rfc-0.1.0/src/mcp_server_rfc/rfc.py +981 -0
- mcp_server_rfc-0.1.0/src/mcp_server_rfc/server.py +188 -0
|
@@ -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()
|