fojin-mcp 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,5 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ .pytest_cache/
4
+ dist/
5
+ build/
@@ -0,0 +1,140 @@
1
+ Metadata-Version: 2.4
2
+ Name: fojin-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server exposing fojin's verified cross-canon Buddhist corpus (cited, URN-addressable passages) to AI research tools.
5
+ Project-URL: Homepage, https://fojin.app
6
+ Project-URL: Repository, https://github.com/xr843/fojin
7
+ Project-URL: Issues, https://github.com/xr843/fojin/issues
8
+ Author: fojin (佛津)
9
+ License: Apache-2.0
10
+ Keywords: buddhism,cbeta,digital-humanities,llm-tools,mcp,rag
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Religion
16
+ Classifier: Topic :: Text Processing :: Linguistic
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: httpx>=0.27
19
+ Requires-Dist: mcp>=1.2
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
22
+ Requires-Dist: pytest>=8; extra == 'dev'
23
+ Requires-Dist: ruff>=0.6; extra == 'dev'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # fojin MCP server
27
+
28
+ An [MCP](https://modelcontextprotocol.io) server that exposes **fojin's verified
29
+ cross-canon Buddhist corpus** to any MCP client (Claude Desktop, ChatGPT,
30
+ research tooling). It turns fojin from "an app you visit" into **infrastructure
31
+ an AI can call** — every passage it returns carries a portable, resolvable
32
+ citation (`fojin:cbeta/T0251.1`), so a model can ground and deep-link its claims
33
+ instead of inventing scripture.
34
+
35
+ Read-only by construction: it only issues GETs against fojin's public API.
36
+
37
+ ## Tools
38
+
39
+ | Tool | What it returns |
40
+ |------|-----------------|
41
+ | `search_corpus(query, limit, lang)` | Semantic search hits, each with a `urn`, title, snippet, score |
42
+ | `read_passage(text_id, juan_num)` | The actual content of one fascicle (卷) + its `urn` |
43
+ | `get_parallels(text_id, juan_num)` | Cross-canon aligned parallels (Chinese↔Pali↔Tibetan), each with a `urn` and deep-link `reader_ref` — the alignment moat |
44
+ | `lookup_dictionary(term, limit)` | Entries across fojin's 32 dictionaries |
45
+ | `lookup_entity(query, limit)` | Knowledge-graph entities (people, places, works, terms) |
46
+ | `resolve_urn(urn)` | Resolve/verify a `fojin:` URN → reader URL + existence |
47
+
48
+ Every result that names a canonical passage carries a **`urn`** — fojin's stable
49
+ cross-canon identifier, interoperable with CBETA / SuttaCentral (`sc/`) / 84000
50
+ (`84k/toh`) / GRETIL / VRI numbering. Pass it back to `resolve_urn`, or cite it
51
+ directly.
52
+
53
+ ## Install & run
54
+
55
+ Once published to PyPI, no clone needed — run it with `uvx` (nothing to
56
+ install) or `pip`:
57
+
58
+ ```bash
59
+ uvx fojin-mcp # zero-install, stdio transport (the MCP default)
60
+ # or
61
+ pip install fojin-mcp && fojin-mcp
62
+ ```
63
+
64
+ From a checkout (development):
65
+
66
+ ```bash
67
+ cd mcp-server && pip install -e . && python -m fojin_mcp
68
+ ```
69
+
70
+ By default it talks to `https://fojin.app/api`. Point it elsewhere (self-host,
71
+ staging) with an env var:
72
+
73
+ ```bash
74
+ FOJIN_API_BASE_URL=http://localhost:8000/api uvx fojin-mcp
75
+ ```
76
+
77
+ ### Claude Desktop
78
+
79
+ Add to `claude_desktop_config.json` (Settings → Developer → Edit Config):
80
+
81
+ ```json
82
+ {
83
+ "mcpServers": {
84
+ "fojin": {
85
+ "command": "uvx",
86
+ "args": ["fojin-mcp"]
87
+ }
88
+ }
89
+ }
90
+ ```
91
+
92
+ (or `"command": "fojin-mcp"` if you `pip install`ed it, or `python -m fojin_mcp`
93
+ from a checkout.) Restart Claude Desktop; the six fojin tools then appear.
94
+
95
+ ### ChatGPT / other MCP clients
96
+
97
+ Any client that speaks MCP over stdio can launch `uvx fojin-mcp` (or the
98
+ `fojin-mcp` console script) and will discover the six tools automatically.
99
+
100
+ ## Design
101
+
102
+ - **Standalone.** Talks to fojin only over HTTP; it does not import the backend,
103
+ so it installs and runs independently (deps: `mcp`, `httpx`).
104
+ - **URNs built client-side.** fojin's search/read/alignment endpoints expose
105
+ `cbeta_id`; the server builds the `urn` from it (`fojin_mcp/urn.py`, a vendored
106
+ copy of the backend's `build_urn`, kept behaviourally identical and
107
+ round-trip-tested). So citations work today, independent of any server change.
108
+ `get_parallels` enriches each parallel's URN via a bounded, concurrent,
109
+ best-effort `text_id → cbeta_id` lookup (a miss just leaves `urn: null`).
110
+ - **Testable core.** All HTTP + reshaping lives in `client.py` and is tested
111
+ against a mocked transport; `server.py` is a thin FastMCP wiring layer.
112
+ - **Fails soft.** An upstream error returns `{"error": "..."}` to the model
113
+ rather than crashing the tool call.
114
+
115
+ ## Test
116
+
117
+ ```bash
118
+ pip install -e '.[dev]'
119
+ pytest -q # unit tests (no network)
120
+ ```
121
+
122
+ ## Publishing (maintainer)
123
+
124
+ CI (`.github/workflows/mcp-server.yml`) lints, tests, and builds the package on
125
+ every change under `mcp-server/`. To release to PyPI, bump `version` in
126
+ `pyproject.toml`, then push a tag:
127
+
128
+ ```bash
129
+ git tag mcp-v0.1.0 && git push origin mcp-v0.1.0
130
+ ```
131
+
132
+ `.github/workflows/mcp-publish.yml` builds and uploads. It needs credentials
133
+ once — set up **either** PyPI Trusted Publishing (recommended; no secret) for
134
+ project `fojin-mcp` / repo `xr843/fojin` / workflow `mcp-publish.yml` /
135
+ environment `pypi`, **or** a `PYPI_API_TOKEN` repo secret (then uncomment the
136
+ `password:` line in the workflow).
137
+
138
+ ## License
139
+
140
+ Apache-2.0 (same as fojin).
@@ -0,0 +1,115 @@
1
+ # fojin MCP server
2
+
3
+ An [MCP](https://modelcontextprotocol.io) server that exposes **fojin's verified
4
+ cross-canon Buddhist corpus** to any MCP client (Claude Desktop, ChatGPT,
5
+ research tooling). It turns fojin from "an app you visit" into **infrastructure
6
+ an AI can call** — every passage it returns carries a portable, resolvable
7
+ citation (`fojin:cbeta/T0251.1`), so a model can ground and deep-link its claims
8
+ instead of inventing scripture.
9
+
10
+ Read-only by construction: it only issues GETs against fojin's public API.
11
+
12
+ ## Tools
13
+
14
+ | Tool | What it returns |
15
+ |------|-----------------|
16
+ | `search_corpus(query, limit, lang)` | Semantic search hits, each with a `urn`, title, snippet, score |
17
+ | `read_passage(text_id, juan_num)` | The actual content of one fascicle (卷) + its `urn` |
18
+ | `get_parallels(text_id, juan_num)` | Cross-canon aligned parallels (Chinese↔Pali↔Tibetan), each with a `urn` and deep-link `reader_ref` — the alignment moat |
19
+ | `lookup_dictionary(term, limit)` | Entries across fojin's 32 dictionaries |
20
+ | `lookup_entity(query, limit)` | Knowledge-graph entities (people, places, works, terms) |
21
+ | `resolve_urn(urn)` | Resolve/verify a `fojin:` URN → reader URL + existence |
22
+
23
+ Every result that names a canonical passage carries a **`urn`** — fojin's stable
24
+ cross-canon identifier, interoperable with CBETA / SuttaCentral (`sc/`) / 84000
25
+ (`84k/toh`) / GRETIL / VRI numbering. Pass it back to `resolve_urn`, or cite it
26
+ directly.
27
+
28
+ ## Install & run
29
+
30
+ Once published to PyPI, no clone needed — run it with `uvx` (nothing to
31
+ install) or `pip`:
32
+
33
+ ```bash
34
+ uvx fojin-mcp # zero-install, stdio transport (the MCP default)
35
+ # or
36
+ pip install fojin-mcp && fojin-mcp
37
+ ```
38
+
39
+ From a checkout (development):
40
+
41
+ ```bash
42
+ cd mcp-server && pip install -e . && python -m fojin_mcp
43
+ ```
44
+
45
+ By default it talks to `https://fojin.app/api`. Point it elsewhere (self-host,
46
+ staging) with an env var:
47
+
48
+ ```bash
49
+ FOJIN_API_BASE_URL=http://localhost:8000/api uvx fojin-mcp
50
+ ```
51
+
52
+ ### Claude Desktop
53
+
54
+ Add to `claude_desktop_config.json` (Settings → Developer → Edit Config):
55
+
56
+ ```json
57
+ {
58
+ "mcpServers": {
59
+ "fojin": {
60
+ "command": "uvx",
61
+ "args": ["fojin-mcp"]
62
+ }
63
+ }
64
+ }
65
+ ```
66
+
67
+ (or `"command": "fojin-mcp"` if you `pip install`ed it, or `python -m fojin_mcp`
68
+ from a checkout.) Restart Claude Desktop; the six fojin tools then appear.
69
+
70
+ ### ChatGPT / other MCP clients
71
+
72
+ Any client that speaks MCP over stdio can launch `uvx fojin-mcp` (or the
73
+ `fojin-mcp` console script) and will discover the six tools automatically.
74
+
75
+ ## Design
76
+
77
+ - **Standalone.** Talks to fojin only over HTTP; it does not import the backend,
78
+ so it installs and runs independently (deps: `mcp`, `httpx`).
79
+ - **URNs built client-side.** fojin's search/read/alignment endpoints expose
80
+ `cbeta_id`; the server builds the `urn` from it (`fojin_mcp/urn.py`, a vendored
81
+ copy of the backend's `build_urn`, kept behaviourally identical and
82
+ round-trip-tested). So citations work today, independent of any server change.
83
+ `get_parallels` enriches each parallel's URN via a bounded, concurrent,
84
+ best-effort `text_id → cbeta_id` lookup (a miss just leaves `urn: null`).
85
+ - **Testable core.** All HTTP + reshaping lives in `client.py` and is tested
86
+ against a mocked transport; `server.py` is a thin FastMCP wiring layer.
87
+ - **Fails soft.** An upstream error returns `{"error": "..."}` to the model
88
+ rather than crashing the tool call.
89
+
90
+ ## Test
91
+
92
+ ```bash
93
+ pip install -e '.[dev]'
94
+ pytest -q # unit tests (no network)
95
+ ```
96
+
97
+ ## Publishing (maintainer)
98
+
99
+ CI (`.github/workflows/mcp-server.yml`) lints, tests, and builds the package on
100
+ every change under `mcp-server/`. To release to PyPI, bump `version` in
101
+ `pyproject.toml`, then push a tag:
102
+
103
+ ```bash
104
+ git tag mcp-v0.1.0 && git push origin mcp-v0.1.0
105
+ ```
106
+
107
+ `.github/workflows/mcp-publish.yml` builds and uploads. It needs credentials
108
+ once — set up **either** PyPI Trusted Publishing (recommended; no secret) for
109
+ project `fojin-mcp` / repo `xr843/fojin` / workflow `mcp-publish.yml` /
110
+ environment `pypi`, **or** a `PYPI_API_TOKEN` repo secret (then uncomment the
111
+ `password:` line in the workflow).
112
+
113
+ ## License
114
+
115
+ Apache-2.0 (same as fojin).
@@ -0,0 +1,3 @@
1
+ """fojin MCP server package — cross-canon Buddhist corpus tools over stdio."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Enable ``python -m fojin_mcp`` to launch the stdio server."""
2
+
3
+ from .server import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,298 @@
1
+ """Async HTTP client over fojin's public read-only API, plus the pure functions
2
+ that reshape each endpoint's response into a citation-carrying tool result.
3
+
4
+ Design: everything here is import-light (httpx only, no ``mcp``) so the reshaping
5
+ logic is unit-testable with a mocked transport, independent of the MCP runtime.
6
+ ``server.py`` is the thin layer that registers these as MCP tools.
7
+
8
+ Every result that identifies a canonical passage carries a ``urn`` — the whole
9
+ point of the server: an AI calling these tools gets back a portable, resolvable
10
+ citation (``fojin:cbeta/T0251.1``) it can cite and deep-link, not an opaque
11
+ internal id. Read-only: no endpoint here mutates fojin state.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ from typing import Any
18
+
19
+ import httpx
20
+
21
+ from .urn import build_urn
22
+
23
+ # Cap on per-call text→cbeta_id lookups when enriching parallels with URNs, so a
24
+ # pathological juan with many distinct aligned texts can't fan out unboundedly.
25
+ _MAX_URN_ENRICH_LOOKUPS = 20
26
+
27
+ DEFAULT_BASE_URL = "https://fojin.app/api"
28
+ DEFAULT_TIMEOUT = 20.0
29
+ # fojin is a scholarly public good; identify the client so its traffic is
30
+ # attributable and it can be rate-limited/allow-listed distinctly from browsers.
31
+ USER_AGENT = "fojin-mcp/0.1 (+https://github.com/xr843/fojin)"
32
+
33
+
34
+ class FojinAPIError(RuntimeError):
35
+ """A fojin API call failed (network, timeout, or non-2xx). The message is
36
+ safe to surface to the calling model as a tool error."""
37
+
38
+
39
+ class FojinClient:
40
+ """Thin async wrapper over the fojin HTTP API.
41
+
42
+ One client owns one ``httpx.AsyncClient``; use it as an async context
43
+ manager so the connection pool is closed deterministically. ``base_url``
44
+ defaults to prod but is overridable (env/tests/self-host).
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ base_url: str = DEFAULT_BASE_URL,
50
+ *,
51
+ timeout: float = DEFAULT_TIMEOUT,
52
+ client: httpx.AsyncClient | None = None,
53
+ ) -> None:
54
+ self._base_url = base_url.rstrip("/")
55
+ # Injectable client so tests pass a mocked transport; otherwise we own it.
56
+ self._client = client or httpx.AsyncClient(
57
+ timeout=timeout, headers={"User-Agent": USER_AGENT}
58
+ )
59
+ self._owns_client = client is None
60
+
61
+ async def __aenter__(self) -> FojinClient:
62
+ return self
63
+
64
+ async def __aexit__(self, *exc: object) -> None:
65
+ await self.aclose()
66
+
67
+ async def aclose(self) -> None:
68
+ if self._owns_client:
69
+ await self._client.aclose()
70
+
71
+ async def _get(self, path: str, params: dict[str, Any] | None = None) -> Any:
72
+ url = f"{self._base_url}{path}"
73
+ try:
74
+ resp = await self._client.get(url, params=params)
75
+ resp.raise_for_status()
76
+ return resp.json()
77
+ except httpx.HTTPStatusError as exc:
78
+ raise FojinAPIError(
79
+ f"fojin API {exc.response.status_code} for {path}"
80
+ ) from exc
81
+ except httpx.HTTPError as exc:
82
+ raise FojinAPIError(f"fojin API request failed for {path}: {exc}") from exc
83
+
84
+ # ── tools ────────────────────────────────────────────────────────────
85
+
86
+ async def search_corpus(
87
+ self, query: str, limit: int = 10, lang: str | None = None
88
+ ) -> dict[str, Any]:
89
+ """Semantic search across the corpus → citation-carrying hits."""
90
+ params: dict[str, Any] = {"q": query, "size": _clamp(limit, 1, 50)}
91
+ if lang:
92
+ params["lang"] = lang
93
+ data = await self._get("/search/semantic", params)
94
+ return shape_search_results(data)
95
+
96
+ async def read_passage(self, text_id: int, juan_num: int) -> dict[str, Any]:
97
+ """Fetch the actual content of one fascicle (卷) with its URN."""
98
+ data = await self._get(f"/texts/{text_id}/juans/{juan_num}")
99
+ return shape_passage(data)
100
+
101
+ async def get_parallels(self, text_id: int, juan_num: int) -> dict[str, Any]:
102
+ """Cross-canon aligned parallels for a fascicle (the alignment moat).
103
+
104
+ The alignment endpoint returns parallels keyed by internal text_id (no
105
+ cbeta_id), so we enrich each fojin-native parallel with a portable URN by
106
+ resolving its distinct text_ids → cbeta_id in a bounded, concurrent,
107
+ best-effort pass. A lookup miss just leaves that parallel's urn None;
108
+ it never fails the whole call.
109
+ """
110
+ data = await self._get(f"/alignment/texts/{text_id}/juans/{juan_num}")
111
+ shaped = shape_parallels(data)
112
+ await self._enrich_parallel_urns(shaped["parallels"])
113
+ return shaped
114
+
115
+ async def _text_cbeta_id(self, text_id: int) -> str | None:
116
+ """Best-effort text_id → cbeta_id via /texts/{id}; None on any failure."""
117
+ try:
118
+ meta = await self._get(f"/texts/{text_id}")
119
+ except FojinAPIError:
120
+ return None
121
+ return meta.get("cbeta_id") if isinstance(meta, dict) else None
122
+
123
+ async def _enrich_parallel_urns(self, parallels: list[dict[str, Any]]) -> None:
124
+ """Fill each parallel's ``urn`` in place from its reader_ref.text_id.
125
+
126
+ Only fojin-native parallels (real text_id > 0) are resolvable; inline
127
+ MITRA foreign sentences (text_id 0) have no fojin work to cite and keep
128
+ urn=None. Distinct text_ids are fetched once, concurrently, capped."""
129
+ want = [
130
+ p for p in parallels
131
+ if p.get("urn") is None
132
+ and isinstance(p.get("reader_ref"), dict)
133
+ and isinstance(p["reader_ref"].get("text_id"), int)
134
+ and p["reader_ref"]["text_id"] > 0
135
+ ]
136
+ distinct_ids = list({p["reader_ref"]["text_id"] for p in want})[:_MAX_URN_ENRICH_LOOKUPS]
137
+ if not distinct_ids:
138
+ return
139
+ cbeta_by_id = dict(
140
+ zip(
141
+ distinct_ids,
142
+ await asyncio.gather(*(self._text_cbeta_id(tid) for tid in distinct_ids)),
143
+ strict=True,
144
+ )
145
+ )
146
+ for p in want:
147
+ ref = p["reader_ref"]
148
+ cbeta_id = cbeta_by_id.get(ref["text_id"])
149
+ if cbeta_id:
150
+ p["urn"] = build_urn(cbeta_id, ref.get("juan_num"))
151
+
152
+ async def lookup_dictionary(self, term: str, limit: int = 10) -> dict[str, Any]:
153
+ """Buddhist dictionary lookup across fojin's 32 dictionaries."""
154
+ data = await self._get(
155
+ "/dictionary/search", {"q": term, "size": _clamp(limit, 1, 50)}
156
+ )
157
+ return {"query": term, "entries": _as_list(data, "results", "entries")}
158
+
159
+ async def lookup_entity(self, query: str, limit: int = 10) -> dict[str, Any]:
160
+ """Knowledge-graph entity search (persons, places, works, terms)."""
161
+ data = await self._get(
162
+ "/kg/entities", {"q": query, "size": _clamp(limit, 1, 50)}
163
+ )
164
+ return {"query": query, "entities": _as_list(data, "results", "entities")}
165
+
166
+ async def resolve_urn(self, urn: str) -> dict[str, Any]:
167
+ """Resolve a fojin URN → reader URL + existence (server-authoritative)."""
168
+ # Anchor '#' must be percent-encoded or the server sees an anchorless URN.
169
+ return await self._get("/urn/resolve", {"urn": urn.replace("#", "%23")})
170
+
171
+
172
+ # ── pure reshaping (unit-tested with plain dicts) ────────────────────────
173
+
174
+
175
+ def shape_search_results(data: Any) -> dict[str, Any]:
176
+ """Normalize /search/semantic into hits that each carry a URN.
177
+
178
+ Robust to the two shapes the field has had: a top-level ``results`` list of
179
+ ``SemanticSearchHit``. Each hit gets a juan-level ``urn`` built from its
180
+ ``cbeta_id`` (None when the source has no round-trippable id)."""
181
+ hits_in = _as_list(data, "results")
182
+ hits_out: list[dict[str, Any]] = []
183
+ for h in hits_in:
184
+ if not isinstance(h, dict):
185
+ continue
186
+ hits_out.append(
187
+ {
188
+ "urn": build_urn(h.get("cbeta_id"), h.get("juan_num")),
189
+ "text_id": h.get("text_id"),
190
+ "juan_num": h.get("juan_num"),
191
+ "title_zh": h.get("title_zh"),
192
+ "cbeta_id": h.get("cbeta_id"),
193
+ "snippet": h.get("snippet"),
194
+ "score": h.get("similarity_score", h.get("score")),
195
+ }
196
+ )
197
+ return {"total": _as_int(data, "total", len(hits_out)), "results": hits_out}
198
+
199
+
200
+ def shape_passage(data: Any) -> dict[str, Any]:
201
+ """Normalize a JuanContentResponse, adding its URN."""
202
+ d = data if isinstance(data, dict) else {}
203
+ return {
204
+ "urn": build_urn(d.get("cbeta_id"), d.get("juan_num")),
205
+ "text_id": d.get("text_id"),
206
+ "cbeta_id": d.get("cbeta_id"),
207
+ "title_zh": d.get("title_zh"),
208
+ "juan_num": d.get("juan_num"),
209
+ "total_juans": d.get("total_juans"),
210
+ "lang": d.get("lang", "lzh"),
211
+ "content": d.get("content"),
212
+ "char_count": d.get("char_count"),
213
+ }
214
+
215
+
216
+ def shape_parallels(data: Any) -> dict[str, Any]:
217
+ """Flatten a JuanAlignmentResponse into a list of parallel passages.
218
+
219
+ The real envelope is ``entries[] -> {chunk_index, chunk_text, parallels[]}``
220
+ where each parallel is a ``ParallelPair`` keyed by internal text_id (NOT
221
+ cbeta_id). We flatten to one row per parallel, tagged with the source chunk
222
+ it aligns to, and carry each parallel's ``reader_ref`` = {text_id, juan_num,
223
+ chunk_index} — fojin's real deep-link handle. ``urn`` starts None here and is
224
+ filled by the client's bounded text_id→cbeta_id enrichment pass (a
225
+ ``ParallelPair`` carries no cbeta_id to build one from inline).
226
+
227
+ ``source="mitra-parallel"`` rows are inline foreign (Skt/Tib) sentences from
228
+ MITRA with text_id 0 — no fojin work to cite, so they stay urn=None but keep
229
+ their ``original_preview``/``original_lang`` text."""
230
+ d = data if isinstance(data, dict) else {}
231
+ out: list[dict[str, Any]] = []
232
+ for entry in _as_list(d, "entries"):
233
+ if not isinstance(entry, dict):
234
+ continue
235
+ src_chunk = entry.get("chunk_index")
236
+ for p in _as_list(entry, "parallels"):
237
+ if not isinstance(p, dict):
238
+ continue
239
+ out.append(
240
+ {
241
+ "urn": None, # enriched by FojinClient._enrich_parallel_urns
242
+ "lang": p.get("lang"),
243
+ "title": p.get("title") or "",
244
+ "text": p.get("chunk_text"),
245
+ "confidence": p.get("confidence"),
246
+ "source": p.get("source", "fojin"),
247
+ "original_preview": p.get("original_preview"),
248
+ "original_lang": p.get("original_lang"),
249
+ "aligns_source_chunk": src_chunk,
250
+ "reader_ref": {
251
+ "text_id": p.get("text_id"),
252
+ "juan_num": p.get("juan_num"),
253
+ "chunk_index": p.get("chunk_index"),
254
+ },
255
+ }
256
+ )
257
+ return {
258
+ "source": {
259
+ "text_id": d.get("text_id"),
260
+ "juan_num": d.get("juan_num"),
261
+ "total_chunks": d.get("total_chunks"),
262
+ "chunks_with_parallels": d.get("chunks_with_parallels"),
263
+ },
264
+ "parallels": out,
265
+ }
266
+
267
+
268
+ # ── small helpers ────────────────────────────────────────────────────────
269
+
270
+
271
+ def _clamp(n: int, lo: int, hi: int) -> int:
272
+ try:
273
+ n = int(n)
274
+ except (TypeError, ValueError):
275
+ return lo
276
+ return max(lo, min(hi, n))
277
+
278
+
279
+ def _as_list(data: Any, *keys: str) -> list[Any]:
280
+ """Return the first key in ``data`` whose value is a list; [] if none.
281
+
282
+ Tolerates both ``{"results": [...]}`` envelopes and a bare top-level list."""
283
+ if isinstance(data, list):
284
+ return data
285
+ if isinstance(data, dict):
286
+ for key in keys:
287
+ val = data.get(key)
288
+ if isinstance(val, list):
289
+ return val
290
+ return []
291
+
292
+
293
+ def _as_int(data: Any, key: str, default: int) -> int:
294
+ if isinstance(data, dict):
295
+ val = data.get(key)
296
+ if isinstance(val, int):
297
+ return val
298
+ return default
File without changes
@@ -0,0 +1,112 @@
1
+ """fojin MCP server — exposes fojin's verified cross-canon Buddhist corpus as
2
+ tools any MCP client (Claude, ChatGPT, research tooling) can call.
3
+
4
+ Thin layer: each tool delegates to :class:`fojin_mcp.client.FojinClient` (the
5
+ tested core) and returns its citation-carrying result. The design promise is
6
+ that every passage a tool surfaces comes with a portable ``urn``
7
+ (``fojin:cbeta/T0251.1``) the calling model can cite and resolve — the AI-facing
8
+ side of fojin's "每一句都能点回原典" contract.
9
+
10
+ Read-only by construction: the client only issues GETs against public
11
+ endpoints. Configure the target with ``FOJIN_API_BASE_URL`` (defaults to prod).
12
+
13
+ Run over stdio (the default MCP transport):
14
+
15
+ python -m fojin_mcp
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ from typing import Any
22
+
23
+ from mcp.server.fastmcp import FastMCP
24
+
25
+ from .client import DEFAULT_BASE_URL, FojinAPIError, FojinClient
26
+
27
+ _BASE_URL = os.environ.get("FOJIN_API_BASE_URL", DEFAULT_BASE_URL)
28
+
29
+ mcp = FastMCP(
30
+ "fojin",
31
+ instructions=(
32
+ "Tools over fojin's cross-canon Buddhist digital-text platform. Prefer "
33
+ "these over your own memory for any claim about a Buddhist text: they "
34
+ "return passages grounded in real sources, each with a portable `urn` "
35
+ "(e.g. fojin:cbeta/T0251.1). Cite the `urn` and, where possible, quote "
36
+ "only text returned by read_passage/get_parallels — do not invent "
37
+ "scripture. Use get_parallels to compare a passage across the Chinese, "
38
+ "Pali and Tibetan canons."
39
+ ),
40
+ )
41
+
42
+
43
+ async def _call(coro_factory: Any) -> Any:
44
+ """Run one client call, mapping FojinAPIError to a tool-visible error dict
45
+ instead of raising — a failed upstream call should tell the model what went
46
+ wrong, not crash the tool invocation."""
47
+ async with FojinClient(_BASE_URL) as client:
48
+ try:
49
+ return await coro_factory(client)
50
+ except FojinAPIError as exc:
51
+ return {"error": str(exc)}
52
+
53
+
54
+ @mcp.tool()
55
+ async def search_corpus(query: str, limit: int = 10, lang: str | None = None) -> dict:
56
+ """Semantic search across fojin's Buddhist corpus (10K+ texts, 30+ langs).
57
+
58
+ Returns the most relevant passages, each with a `urn`, title, snippet and
59
+ similarity score. `lang` optionally filters by language code
60
+ (lzh=Classical Chinese, pi=Pali, sa=Sanskrit, bo=Tibetan, en=English).
61
+ """
62
+ return await _call(lambda c: c.search_corpus(query, limit=limit, lang=lang))
63
+
64
+
65
+ @mcp.tool()
66
+ async def read_passage(text_id: int, juan_num: int) -> dict:
67
+ """Read the full content of one fascicle (卷) of a text, with its `urn`.
68
+
69
+ Use the `text_id`/`juan_num` from a search_corpus hit. Returns the actual
70
+ canonical text — quote from this, not from memory.
71
+ """
72
+ return await _call(lambda c: c.read_passage(text_id, juan_num))
73
+
74
+
75
+ @mcp.tool()
76
+ async def get_parallels(text_id: int, juan_num: int) -> dict:
77
+ """Cross-canon parallel passages aligned to a fascicle.
78
+
79
+ fojin's alignment moat: given a Chinese fascicle, returns the aligned
80
+ Pali/Tibetan/Sanskrit parallels (each with its own `urn`) so you can compare
81
+ how a passage is rendered across traditions.
82
+ """
83
+ return await _call(lambda c: c.get_parallels(text_id, juan_num))
84
+
85
+
86
+ @mcp.tool()
87
+ async def lookup_dictionary(term: str, limit: int = 10) -> dict:
88
+ """Look up a Buddhist term across fojin's dictionaries (748K+ entries)."""
89
+ return await _call(lambda c: c.lookup_dictionary(term, limit=limit))
90
+
91
+
92
+ @mcp.tool()
93
+ async def lookup_entity(query: str, limit: int = 10) -> dict:
94
+ """Search fojin's knowledge graph for entities — people, places, works,
95
+ doctrinal terms — matching `query`."""
96
+ return await _call(lambda c: c.lookup_entity(query, limit=limit))
97
+
98
+
99
+ @mcp.tool()
100
+ async def resolve_urn(urn: str) -> dict:
101
+ """Resolve a fojin URN (e.g. fojin:cbeta/T0001.1) to a reader URL and confirm
102
+ it exists in the corpus. Use to verify or dereference a citation."""
103
+ return await _call(lambda c: c.resolve_urn(urn))
104
+
105
+
106
+ def main() -> None:
107
+ """Console-script / ``python -m fojin_mcp`` entry point (stdio transport)."""
108
+ mcp.run()
109
+
110
+
111
+ if __name__ == "__main__":
112
+ main()
@@ -0,0 +1,75 @@
1
+ """Build fojin cross-canon URNs from a stored cbeta_id.
2
+
3
+ This is a *vendored*, dependency-free copy of the forward builder in the fojin
4
+ backend (``backend/app/services/urn.py::build_urn``). The MCP server is a
5
+ standalone process that talks to fojin only over HTTP — it deliberately does not
6
+ import the backend package (different dependency closure, independently
7
+ installable) — so the ~30 lines that turn a ``cbeta_id`` into a portable URN are
8
+ duplicated here on purpose. Keep the two in sync; both are covered by
9
+ round-trip tests against the same identifier shapes.
10
+
11
+ Why the MCP server builds URNs itself rather than reading them off the API:
12
+ fojin's search / read / alignment endpoints already expose ``cbeta_id`` but not
13
+ a ``urn`` field, so constructing it client-side means every tool result carries
14
+ a citable identifier today, without waiting on any server change.
15
+
16
+ URN grammar (mirrors the backend):
17
+
18
+ fojin:<scheme>/<work_id>[.<juan>][#<anchor>]
19
+
20
+ Schemes map to the cbeta_id prefix conventions: T/X → cbeta, SC- → sc,
21
+ 84K-toh → 84k, GRETIL- → gretil, VRI- → vri.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+
28
+ # work_id / anchor grammar — identical to the backend parser so a URN this
29
+ # module emits always parses back cleanly (the round-trip guarantee).
30
+ _WORK_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
31
+ _ANCHOR_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
32
+
33
+ # (cbeta_id prefix, canonical short scheme). Ordered so the more specific
34
+ # "84K-toh" is tested before any shorter prefix could match.
35
+ _CANON_PREFIX_SCHEMES: tuple[tuple[str, str], ...] = (
36
+ ("84K-toh", "84k"),
37
+ ("GRETIL-", "gretil"),
38
+ ("SC-", "sc"),
39
+ ("VRI-", "vri"),
40
+ )
41
+
42
+
43
+ def build_urn(
44
+ cbeta_id: str | None,
45
+ juan: int | None = None,
46
+ anchor: str | None = None,
47
+ ) -> str | None:
48
+ """Construct a fojin URN from a stored cbeta_id, or ``None`` if it can't
49
+ produce one that round-trips cleanly.
50
+
51
+ Never raises: callers attach the result to a tool payload as a best-effort
52
+ citation id, so an un-buildable case must degrade to "no URN" silently.
53
+ """
54
+ if not isinstance(cbeta_id, str) or not cbeta_id:
55
+ return None
56
+
57
+ scheme = "cbeta"
58
+ work_id = cbeta_id
59
+ for prefix, prefix_scheme in _CANON_PREFIX_SCHEMES:
60
+ if cbeta_id.startswith(prefix):
61
+ scheme = prefix_scheme
62
+ work_id = cbeta_id[len(prefix):]
63
+ break
64
+
65
+ if not work_id or not _WORK_ID_RE.match(work_id):
66
+ return None
67
+ if anchor is not None and not (isinstance(anchor, str) and _ANCHOR_RE.match(anchor)):
68
+ anchor = None
69
+
70
+ urn = f"fojin:{scheme}/{work_id}"
71
+ if isinstance(juan, int) and juan >= 1:
72
+ urn += f".{juan}"
73
+ if anchor:
74
+ urn += f"#{anchor}"
75
+ return urn
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "fojin-mcp"
7
+ version = "0.1.0"
8
+ description = "MCP server exposing fojin's verified cross-canon Buddhist corpus (cited, URN-addressable passages) to AI research tools."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "Apache-2.0" }
12
+ authors = [{ name = "fojin (佛津)" }]
13
+ keywords = ["mcp", "buddhism", "cbeta", "digital-humanities", "rag", "llm-tools"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Science/Research",
17
+ "License :: OSI Approved :: Apache Software License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Religion",
20
+ "Topic :: Text Processing :: Linguistic",
21
+ ]
22
+ dependencies = [
23
+ "mcp>=1.2",
24
+ "httpx>=0.27",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://fojin.app"
29
+ Repository = "https://github.com/xr843/fojin"
30
+ Issues = "https://github.com/xr843/fojin/issues"
31
+
32
+ [project.optional-dependencies]
33
+ dev = [
34
+ "pytest>=8",
35
+ "pytest-asyncio>=0.23",
36
+ "ruff>=0.6",
37
+ ]
38
+
39
+ [project.scripts]
40
+ fojin-mcp = "fojin_mcp.server:main"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["fojin_mcp"]
44
+
45
+ [tool.pytest.ini_options]
46
+ asyncio_mode = "auto"
47
+
48
+ [tool.ruff]
49
+ line-length = 100
@@ -0,0 +1,208 @@
1
+ """Tests for FojinClient's reshaping + HTTP behaviour.
2
+
3
+ The pure ``shape_*`` functions are tested with plain dicts (no I/O). The client
4
+ methods are tested against a mocked httpx transport so we assert the exact
5
+ request path/params AND that every returned passage carries a URN — without
6
+ touching the network.
7
+ """
8
+
9
+ import httpx
10
+ import pytest
11
+
12
+ from fojin_mcp.client import (
13
+ FojinAPIError,
14
+ FojinClient,
15
+ shape_parallels,
16
+ shape_passage,
17
+ shape_search_results,
18
+ )
19
+
20
+
21
+ # ── pure reshaping ───────────────────────────────────────────────────────
22
+
23
+
24
+ def test_shape_search_results_adds_urn_per_hit():
25
+ data = {
26
+ "total": 2,
27
+ "results": [
28
+ {"text_id": 5, "juan_num": 1, "title_zh": "心經", "cbeta_id": "T0251",
29
+ "snippet": "色即是空", "similarity_score": 0.91},
30
+ {"text_id": 9, "juan_num": 2, "title_zh": "中部", "cbeta_id": "SC-mn10",
31
+ "snippet": "satipaṭṭhāna", "similarity_score": 0.83},
32
+ ],
33
+ }
34
+ out = shape_search_results(data)
35
+ assert out["total"] == 2
36
+ assert out["results"][0]["urn"] == "fojin:cbeta/T0251.1"
37
+ assert out["results"][0]["score"] == 0.91
38
+ assert out["results"][1]["urn"] == "fojin:sc/mn10.2"
39
+
40
+
41
+ def test_shape_search_results_urn_none_when_no_cbeta_id():
42
+ out = shape_search_results({"results": [{"text_id": 1, "juan_num": 1, "title_zh": "x"}]})
43
+ assert out["results"][0]["urn"] is None
44
+
45
+
46
+ def test_shape_search_results_tolerates_garbage():
47
+ assert shape_search_results(None) == {"total": 0, "results": []}
48
+ assert shape_search_results({"results": ["notadict", 5]})["results"] == []
49
+
50
+
51
+ def test_shape_passage_adds_urn():
52
+ data = {"text_id": 5, "cbeta_id": "T0251", "title_zh": "心經", "juan_num": 1,
53
+ "total_juans": 1, "content": "觀自在菩薩…", "char_count": 260, "lang": "lzh"}
54
+ out = shape_passage(data)
55
+ assert out["urn"] == "fojin:cbeta/T0251.1"
56
+ assert out["content"].startswith("觀自在")
57
+
58
+
59
+ def test_shape_parallels_flattens_real_envelope():
60
+ """Real shape: entries[] -> parallels[] (ParallelPair, keyed by text_id)."""
61
+ data = {
62
+ "text_id": 5, "juan_num": 1, "total_chunks": 10, "chunks_with_parallels": 1,
63
+ "entries": [
64
+ {"chunk_index": 2, "chunk_text": "汉文源文", "parallels": [
65
+ {"text_id": 99, "juan_num": 1, "chunk_index": 0, "lang": "pi",
66
+ "title": "Majjhima 10", "chunk_text": "pali para", "confidence": 0.88,
67
+ "source": "fojin"},
68
+ {"text_id": 0, "juan_num": 0, "chunk_index": 0, "lang": "sa",
69
+ "title": "", "chunk_text": "", "confidence": 1.0,
70
+ "source": "mitra-parallel", "original_preview": "skt sentence",
71
+ "original_lang": "sa"},
72
+ ]},
73
+ ],
74
+ }
75
+ out = shape_parallels(data)
76
+ assert out["source"]["text_id"] == 5
77
+ assert out["source"]["chunks_with_parallels"] == 1
78
+ assert len(out["parallels"]) == 2
79
+ p0 = out["parallels"][0]
80
+ assert p0["urn"] is None # filled later by enrichment
81
+ assert p0["lang"] == "pi"
82
+ assert p0["aligns_source_chunk"] == 2
83
+ assert p0["reader_ref"] == {"text_id": 99, "juan_num": 1, "chunk_index": 0}
84
+ # MITRA inline foreign sentence: no fojin work → text_id 0, keeps preview.
85
+ assert out["parallels"][1]["source"] == "mitra-parallel"
86
+ assert out["parallels"][1]["original_preview"] == "skt sentence"
87
+
88
+
89
+ def test_shape_parallels_tolerates_empty():
90
+ assert shape_parallels({})["parallels"] == []
91
+ assert shape_parallels({"entries": []})["parallels"] == []
92
+
93
+
94
+ # ── client HTTP behaviour (mocked transport) ─────────────────────────────
95
+
96
+
97
+ def _client_with(handler) -> FojinClient:
98
+ transport = httpx.MockTransport(handler)
99
+ http = httpx.AsyncClient(transport=transport, base_url="http://test")
100
+ return FojinClient("http://test/api", client=http)
101
+
102
+
103
+ @pytest.mark.asyncio
104
+ async def test_search_corpus_hits_semantic_endpoint_with_clamped_size():
105
+ seen = {}
106
+
107
+ def handler(request: httpx.Request) -> httpx.Response:
108
+ seen["path"] = request.url.path
109
+ seen["params"] = dict(request.url.params)
110
+ return httpx.Response(200, json={"total": 1, "results": [
111
+ {"text_id": 5, "juan_num": 1, "title_zh": "心經", "cbeta_id": "T0251",
112
+ "snippet": "色即是空", "similarity_score": 0.9}]})
113
+
114
+ async with _client_with(handler) as c:
115
+ out = await c.search_corpus("空", limit=999, lang="lzh")
116
+
117
+ assert seen["path"] == "/api/search/semantic"
118
+ assert seen["params"]["q"] == "空"
119
+ assert seen["params"]["size"] == "50" # clamped 999 → 50
120
+ assert seen["params"]["lang"] == "lzh"
121
+ assert out["results"][0]["urn"] == "fojin:cbeta/T0251.1"
122
+
123
+
124
+ @pytest.mark.asyncio
125
+ async def test_read_passage_path_and_urn():
126
+ def handler(request: httpx.Request) -> httpx.Response:
127
+ assert request.url.path == "/api/texts/5/juans/1"
128
+ return httpx.Response(200, json={
129
+ "text_id": 5, "cbeta_id": "T0251", "title_zh": "心經", "juan_num": 1,
130
+ "total_juans": 1, "content": "觀自在菩薩", "char_count": 5, "lang": "lzh"})
131
+
132
+ async with _client_with(handler) as c:
133
+ out = await c.read_passage(5, 1)
134
+ assert out["urn"] == "fojin:cbeta/T0251.1"
135
+
136
+
137
+ @pytest.mark.asyncio
138
+ async def test_resolve_urn_percent_encodes_anchor():
139
+ seen = {}
140
+
141
+ def handler(request: httpx.Request) -> httpx.Response:
142
+ # httpx exposes the DECODED param; assert the anchor survived (wasn't
143
+ # stripped as a fragment) by checking the raw query carries %2523/%23.
144
+ seen["raw_query"] = request.url.query.decode()
145
+ return httpx.Response(200, json={"urn": "fojin:cbeta/T0001.1", "exists": True})
146
+
147
+ async with _client_with(handler) as c:
148
+ await c.resolve_urn("fojin:cbeta/T0001.1#p0001a01")
149
+ # The '#' was replaced with %23 before sending, so no fragment was dropped.
150
+ assert "p0001a01" in seen["raw_query"]
151
+
152
+
153
+ @pytest.mark.asyncio
154
+ async def test_get_parallels_enriches_urns_via_text_lookup():
155
+ """A fojin-native parallel (text_id 99) gets a URN from a text_id→cbeta_id
156
+ lookup; a MITRA inline parallel (text_id 0) stays urn=None."""
157
+ def handler(request: httpx.Request) -> httpx.Response:
158
+ path = request.url.path
159
+ if path == "/api/alignment/texts/5/juans/1":
160
+ return httpx.Response(200, json={
161
+ "text_id": 5, "juan_num": 1, "total_chunks": 3, "chunks_with_parallels": 1,
162
+ "entries": [{"chunk_index": 0, "chunk_text": "源", "parallels": [
163
+ {"text_id": 99, "juan_num": 2, "chunk_index": 0, "lang": "pi",
164
+ "title": "M10", "chunk_text": "p", "confidence": 0.9, "source": "fojin"},
165
+ {"text_id": 0, "juan_num": 0, "chunk_index": 0, "lang": "sa",
166
+ "title": "", "chunk_text": "", "confidence": 1.0,
167
+ "source": "mitra-parallel", "original_preview": "s", "original_lang": "sa"},
168
+ ]}],
169
+ })
170
+ if path == "/api/texts/99":
171
+ return httpx.Response(200, json={"text_id": 99, "cbeta_id": "SC-mn10",
172
+ "title_zh": "中部", "juan_num": 2})
173
+ return httpx.Response(404, json={"detail": "unexpected " + path})
174
+
175
+ async with _client_with(handler) as c:
176
+ out = await c.get_parallels(5, 1)
177
+
178
+ fojin_par = out["parallels"][0]
179
+ assert fojin_par["reader_ref"]["text_id"] == 99
180
+ assert fojin_par["urn"] == "fojin:sc/mn10.2" # enriched from /texts/99
181
+ assert out["parallels"][1]["urn"] is None # MITRA inline, unresolvable
182
+
183
+
184
+ @pytest.mark.asyncio
185
+ async def test_get_parallels_survives_enrichment_lookup_failure():
186
+ """A failed text lookup during enrichment must leave urn=None, not raise."""
187
+ def handler(request: httpx.Request) -> httpx.Response:
188
+ if request.url.path.startswith("/api/alignment"):
189
+ return httpx.Response(200, json={"text_id": 5, "juan_num": 1,
190
+ "total_chunks": 1, "chunks_with_parallels": 1,
191
+ "entries": [{"chunk_index": 0, "chunk_text": "源", "parallels": [
192
+ {"text_id": 99, "juan_num": 1, "chunk_index": 0, "lang": "pi",
193
+ "title": "M", "chunk_text": "p", "confidence": 0.9, "source": "fojin"}]}]})
194
+ return httpx.Response(500, json={"detail": "boom"}) # /texts/99 fails
195
+
196
+ async with _client_with(handler) as c:
197
+ out = await c.get_parallels(5, 1)
198
+ assert out["parallels"][0]["urn"] is None
199
+
200
+
201
+ @pytest.mark.asyncio
202
+ async def test_http_error_becomes_fojin_api_error():
203
+ def handler(request: httpx.Request) -> httpx.Response:
204
+ return httpx.Response(500, json={"detail": "boom"})
205
+
206
+ async with _client_with(handler) as c:
207
+ with pytest.raises(FojinAPIError):
208
+ await c.read_passage(1, 1)
@@ -0,0 +1,41 @@
1
+ """Round-trip + degradation tests for the vendored URN builder.
2
+
3
+ Must stay behaviourally identical to backend/app/services/urn.py::build_urn —
4
+ these cases mirror backend/tests/test_urn.py's build_urn coverage.
5
+ """
6
+
7
+ import pytest
8
+
9
+ from fojin_mcp.urn import build_urn
10
+
11
+
12
+ @pytest.mark.parametrize("cbeta_id,expected", [
13
+ ("T0001", "fojin:cbeta/T0001"),
14
+ ("X0123", "fojin:cbeta/X0123"),
15
+ ("SC-mn10", "fojin:sc/mn10"),
16
+ ("84K-toh11", "fojin:84k/11"),
17
+ ("GRETIL-ramayana", "fojin:gretil/ramayana"),
18
+ ("VRI-dn1", "fojin:vri/dn1"),
19
+ ])
20
+ def test_emits_canonical_scheme(cbeta_id, expected):
21
+ assert build_urn(cbeta_id) == expected
22
+
23
+
24
+ def test_appends_juan_and_anchor():
25
+ assert build_urn("T0001", 5) == "fojin:cbeta/T0001.5"
26
+ assert build_urn("SC-mn10", 2) == "fojin:sc/mn10.2"
27
+ assert build_urn("T0001", 5, "p0001a01") == "fojin:cbeta/T0001.5#p0001a01"
28
+
29
+
30
+ @pytest.mark.parametrize("cbeta_id", [None, "", "SC-", "SC-an1.1", "T 0001", "T0001.", 123])
31
+ def test_returns_none_when_not_round_trippable(cbeta_id):
32
+ assert build_urn(cbeta_id) is None
33
+
34
+
35
+ def test_drops_bad_anchor_keeps_urn():
36
+ assert build_urn("T0001", 5, "has spaces") == "fojin:cbeta/T0001.5"
37
+
38
+
39
+ def test_ignores_non_positive_juan():
40
+ assert build_urn("T0001", 0) == "fojin:cbeta/T0001"
41
+ assert build_urn("T0001", None) == "fojin:cbeta/T0001"