diff --git a/packages/seed-bible-mcp/README.md b/packages/seed-bible-mcp/README.md index b300e22..b351f0a 100644 --- a/packages/seed-bible-mcp/README.md +++ b/packages/seed-bible-mcp/README.md @@ -1,9 +1,13 @@ # Free Use Bible MCP Server -An [MCP](https://modelcontextprotocol.io) server that exposes the [Free Use Bible API](https://bible.helloao.org) (BSB, WEB, Hebrew WLC, SBL Greek NT) through two tools: +An [MCP](https://modelcontextprotocol.io) server that exposes the [Free Use Bible API](https://bible.helloao.org) (BSB, WEB, Hebrew WLC, SBL Greek NT) through four tools: -- `search` — turns a reference such as `John 3:16` or `Gen 1:1-3 WEB` into a passage result id. -- `fetch` — returns the passage text for a result id from `search`. +- `getBibleReference` — turns a reference such as `John 3:16` or `Gen 1:1-3 WEB` into a passage result id. +- `fetchChapter` — returns the passage text for a result id from `getBibleReference`. +- `fetchVerse` — returns the text of a verse or verse range within one chapter, for a reference such as `John 3:16` or `Gen 1:1-3 WEB`. +- `listTranslations` — lists the available translations, optionally filtered by language (e.g. `spa` or `Spanish`) and by name. + +`getBibleReference` and `fetchVerse` take an optional `translation` parameter that accepts any translation id from `listTranslations` (e.g. `spa_rv1909`). Without it, the translation is picked from the reference (BSB, WEB, WLC, or SBL), defaulting to BSB. The server speaks the Streamable HTTP transport at `/mcp` and runs statelessly, so the same code runs on Node.js and on Cloudflare Workers. diff --git a/packages/seed-bible-mcp/src/bible.ts b/packages/seed-bible-mcp/src/bible.ts index b0767e9..3c085c9 100644 --- a/packages/seed-bible-mcp/src/bible.ts +++ b/packages/seed-bible-mcp/src/bible.ts @@ -58,6 +58,100 @@ async function getBooksMap( return m; } +export interface TranslationSummary { + id: string; + name: string; + englishName: string; + shortName?: string; + language: string; + languageName?: string; + languageEnglishName?: string; + textDirection: string; + numberOfBooks: number; +} + +const translationsCache = new Map(); + +/** + * Fetches the list of translations from the API's available_translations.json endpoint. + */ +export async function getAvailableTranslations( + apiBase: string +): Promise { + const cached = translationsCache.get(apiBase); + if (cached) return cached; + + const res = await fetch(`${apiBase}/available_translations.json`); + if (!res.ok) { + throw new Error( + `available_translations.json request failed: ${res.status}` + ); + } + const data: any = await res.json(); + + const translations: TranslationSummary[] = (data.translations ?? []).map( + (t: any) => ({ + id: t.id, + name: t.name, + englishName: t.englishName, + shortName: t.shortName, + language: t.language, + languageName: t.languageName, + languageEnglishName: t.languageEnglishName, + textDirection: t.textDirection, + numberOfBooks: t.numberOfBooks, + }) + ); + + translationsCache.set(apiBase, translations); + return translations; +} + +function includesIgnoreCase( + values: (string | undefined)[], + search: string +): boolean { + const s = search.toLowerCase(); + return values.some((v) => !!v && v.toLowerCase().includes(s)); +} + +/** + * Filters translations by language and/or name. + * + * - `language` matches the ISO 639 language code exactly, or any part of the + * language's native or English name (e.g. "spa", "Spanish", "Español"). + * - `name` matches any part of the translation's id, name, English name, or short name. + */ +export function filterTranslations( + translations: TranslationSummary[], + filter: { language?: string; name?: string } +): TranslationSummary[] { + const language = filter.language?.trim(); + const name = filter.name?.trim(); + return translations.filter((t) => { + if ( + language && + t.language?.toLowerCase() !== language.toLowerCase() && + !includesIgnoreCase( + [t.languageName, t.languageEnglishName], + language + ) + ) { + return false; + } + if ( + name && + !includesIgnoreCase( + [t.id, t.name, t.englishName, t.shortName], + name + ) + ) { + return false; + } + return true; + }); +} + function chooseTranslation(query: string): string { const uq = query.toUpperCase(); for (const [alias, tid] of Object.entries(TRANSLATION_ALIASES)) { @@ -103,14 +197,41 @@ export function passageTitle( return `${book} ${chapter}${verses ? ':' + verses : ''} (${translation})`; } +/** + * Resolves a user-supplied translation to an API translation id. + * Accepts the short aliases (e.g. "WEB", "Hebrew") or any id from + * available_translations.json, ignoring case. + * Returns null if the translation isn't available. + */ +export async function resolveTranslationId( + apiBase: string, + translation: string +): Promise { + const t = translation.trim(); + const alias = TRANSLATION_ALIASES[t.toUpperCase()]; + if (alias) return alias; + + const translations = await getAvailableTranslations(apiBase); + const match = translations.find( + (x) => x.id.toLowerCase() === t.toLowerCase() + ); + return match?.id ?? null; +} + +/** + * Parses a passage reference such as "John 3:16" or "Gen 1:1-3 WEB". + * + * @param translation An API translation id to use instead of picking one from the query. + */ export async function parseQueryToRef( apiBase: string, - query: string + query: string, + translation?: string ): Promise { const m = REF_RE.exec(query); if (!m || !m.groups) return null; - const translation = chooseTranslation(query); + translation ??= chooseTranslation(query); const bookRaw = m.groups.book.trim(); const chapter = parseInt(m.groups.chapter, 10); const verses = m.groups.verses ?? null; @@ -122,56 +243,47 @@ export async function parseQueryToRef( // Otherwise map from book name -> id using API book metadata. // Using BSB book list is generally fine because IDs are standard across translations. - const booksMap = await getBooksMap(apiBase, 'BSB'); - const bookId = booksMap[norm(bookRaw)]; + // Fall back to the translation's own book names (e.g. "Juan" in a Spanish translation). + let bookId = (await getBooksMap(apiBase, 'BSB'))[norm(bookRaw)]; + if (!bookId && translation !== 'BSB') { + bookId = (await getBooksMap(apiBase, translation))[norm(bookRaw)]; + } if (!bookId) return null; return [translation, bookId, chapter, verses]; } -export async function fetchChapterJson( +export function simpleChapterUrl( apiBase: string, translation: string, book: string, chapter: number -): Promise { - const res = await fetch(chapterUrl(apiBase, translation, book, chapter)); - if (!res.ok) throw new Error(`chapter request failed: ${res.status}`); - return res.json(); +): string { + return `${apiBase}/${translation}/${book}/${chapter}.simple.json`; } /** - * Verse content entries can be: - * - strings - * - formatted text objects { "text": "...", ... } - * - inline objects like { "lineBreak": true } or { "noteId": 0 } - * We keep readable text and turn line breaks into newlines; ignore footnote refs. + * Fetches a chapter in the simplified format, where each verse's content is + * already flattened into a single `text` string. */ -function flattenVerseContent(items: any[]): string { - const out: string[] = []; - for (const x of items) { - if (typeof x === 'string') { - out.push(x); - } else if (x && typeof x === 'object') { - if (typeof x.text === 'string') { - out.push(x.text); - } else if (x.lineBreak === true) { - out.push('\n'); - } - // ignore noteId, headings, etc for plain verse text - } - } - let s = out.join(''); - s = s.replace(/\s+\n/g, '\n'); - s = s.replace(/\n\s+/g, '\n'); - return s.trim(); +export async function fetchSimpleChapterJson( + apiBase: string, + translation: string, + book: string, + chapter: number +): Promise { + const res = await fetch( + simpleChapterUrl(apiBase, translation, book, chapter) + ); + if (!res.ok) throw new Error(`chapter request failed: ${res.status}`); + return res.json(); } export function extractVerses( - chapterJson: any, + simpleChapterJson: any, verseRange: string | null ): string { - const content: any[] = chapterJson?.chapter?.content ?? []; + const content: any[] = simpleChapterJson?.chapter?.content ?? []; let start: number | null = null; let end: number | null = null; if (verseRange) { @@ -192,8 +304,7 @@ export function extractVerses( if (typeof num !== 'number' || !Number.isInteger(num)) continue; if (start !== null && (num < start || num > (end as number))) continue; - const verseText = flattenVerseContent(item.content ?? []); - lines.push(`${num}. ${verseText}`); + lines.push(`${num}. ${(item.text ?? '').trim()}`); } return lines.join('\n').trim(); diff --git a/packages/seed-bible-mcp/src/mcp.ts b/packages/seed-bible-mcp/src/mcp.ts index e6f87c1..d28b2d0 100644 --- a/packages/seed-bible-mcp/src/mcp.ts +++ b/packages/seed-bible-mcp/src/mcp.ts @@ -6,10 +6,13 @@ import { DEFAULT_BIBLE_API_BASE, chapterUrl, extractVerses, - fetchChapterJson, + fetchSimpleChapterJson, + filterTranslations, + getAvailableTranslations, makeResultId, parseQueryToRef, parseResultId, + resolveTranslationId, passageTitle, } from './bible.js'; @@ -24,6 +27,26 @@ function json(value: unknown) { }; } +function error(message: string) { + return { + content: [{ type: 'text' as const, text: message }], + isError: true, + }; +} + +const translationParam = z + .string() + .optional() + .describe( + 'The translation to use: any translation id from listTranslations() (e.g. "spa_rv1909"), or one of BSB, WEB, WLC, SBL. Overrides a translation named in the reference. Defaults to BSB.' + ); + +function unknownTranslation(translation: string) { + return error( + `Unknown translation "${translation}". Use listTranslations() to find available translation ids.` + ); +} + /** * Create a new MCP server instance with the Bible tools registered. * @@ -43,9 +66,11 @@ export function createBibleMcpServer(options: BibleMcpOptions = {}): McpServer { }, { instructions: - 'Use search() to interpret a user query into a Bible passage result. ' + - 'Then use fetch() to retrieve the full passage text. ' + - 'Supports BSB, WEB (ENGWEBP), Hebrew WLC (heb_wlc), and SBL Greek NT (grc_sbl).', + 'Use getBibleReference() to interpret a user query into a Bible passage result. ' + + 'Then use fetchChapter() to retrieve the full passage text. ' + + 'To get the text of a verse or verse range directly, use fetchVerse(). ' + + 'Use listTranslations() to find available translations by language or name, then pass a translation id as the translation parameter. ' + + 'References can name BSB, WEB (ENGWEBP), Hebrew WLC (heb_wlc), or SBL Greek NT (grc_sbl) directly; other translations need the translation parameter.', // The default Ajv validator compiles schemas with `new Function`, // which Cloudflare Workers forbids. The cfworker validator is pure JS // and works on every runtime. @@ -54,7 +79,7 @@ export function createBibleMcpServer(options: BibleMcpOptions = {}): McpServer { ); server.registerTool( - 'search', + 'getBibleReference', { description: 'Interpret a natural-language query into a single Bible passage result.', @@ -64,10 +89,17 @@ export function createBibleMcpServer(options: BibleMcpOptions = {}): McpServer { .describe( 'A passage reference, e.g. "John 3:16" or "Gen 1:1-3 WEB".' ), + translation: translationParam, }, }, - async ({ query }) => { - const ref = await parseQueryToRef(apiBase, query); + async ({ query, translation: requestedTranslation }) => { + const translationId = requestedTranslation + ? await resolveTranslationId(apiBase, requestedTranslation) + : undefined; + if (translationId === null) { + return unknownTranslation(requestedTranslation!); + } + const ref = await parseQueryToRef(apiBase, query, translationId); if (!ref) return json({ results: [] }); const [translation, book, chapter, verses] = ref; @@ -84,17 +116,19 @@ export function createBibleMcpServer(options: BibleMcpOptions = {}): McpServer { ); server.registerTool( - 'fetch', + 'fetchChapter', { description: - 'Retrieve the full passage text for a result id from search().', + 'Retrieve the full passage text for a result id from getBibleReference().', inputSchema: { - id: z.string().describe('A result id returned by search().'), + id: z + .string() + .describe('A result id returned by getBibleReference().'), }, }, async ({ id }) => { const [translation, book, chapter, verses] = parseResultId(id); - const chapterJson = await fetchChapterJson( + const chapterJson = await fetchSimpleChapterJson( apiBase, translation, book, @@ -111,5 +145,104 @@ export function createBibleMcpServer(options: BibleMcpOptions = {}): McpServer { } ); + server.registerTool( + 'fetchVerse', + { + description: + 'Retrieve the text of a single Bible verse or a range of verses within one chapter, from a reference such as "John 3:16" or "John 3:16-18".', + inputSchema: { + reference: z + .string() + .describe( + 'A verse or verse range reference, optionally followed by a translation, e.g. "John 3:16", "Gen 1:1-3" or "Ps 23:1-6 WEB".' + ), + translation: translationParam, + }, + }, + async ({ reference, translation: requestedTranslation }) => { + const translationId = requestedTranslation + ? await resolveTranslationId(apiBase, requestedTranslation) + : undefined; + if (translationId === null) { + return unknownTranslation(requestedTranslation!); + } + const ref = await parseQueryToRef( + apiBase, + reference, + translationId + ); + if (!ref || !ref[3]) { + return error( + `Could not interpret "${reference}" as a verse reference.` + ); + } + + const [translation, book, chapter, verses] = ref; + const title = passageTitle(translation, book, chapter, verses); + const chapterJson = await fetchSimpleChapterJson( + apiBase, + translation, + book, + chapter + ); + let text = extractVerses(chapterJson, verses); + if (!text) { + return error(`${title} was not found.`); + } + if (!verses.includes('-')) { + // Drop the "N. " prefix that extractVerses() adds; it's + // redundant for a single verse. + text = text.replace(/^\d+\. /, ''); + } + + return json({ + id: makeResultId(translation, book, chapter, verses), + title, + text, + url: chapterUrl(apiBase, translation, book, chapter), + metadata: { translation, book, chapter, verses }, + }); + } + ); + + server.registerTool( + 'listTranslations', + { + description: + 'List the Bible translations available in the Free Use Bible API, optionally filtered by language and/or name.', + inputSchema: { + language: z + .string() + .optional() + .describe( + 'Filter by language: an ISO 639-3 code (e.g. "spa") or part of the language name in English or the language itself (e.g. "Spanish", "Español").' + ), + name: z + .string() + .optional() + .describe( + 'Filter by part of the translation id, name, English name, or short name (e.g. "BSB", "Reina", "King James").' + ), + limit: z + .number() + .int() + .min(1) + .max(500) + .optional() + .describe( + 'The maximum number of translations to return. Defaults to 50.' + ), + }, + }, + async ({ language, name, limit }) => { + const all = await getAvailableTranslations(apiBase); + const matches = filterTranslations(all, { language, name }); + return json({ + total: matches.length, + translations: matches.slice(0, limit ?? 50), + }); + } + ); + return server; }