Skip to main content
Glama
shared-tools.ts21.1 kB
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; export interface ToolAccess { ensureDb: () => Promise<void>; getDB: () => any; } // Fuzzy matching utilities function levenshteinDistance(a: string, b: string): number { const matrix = Array(b.length + 1).fill(null).map(() => Array(a.length + 1).fill(null)); for (let i = 0; i <= a.length; i++) matrix[0][i] = i; for (let j = 0; j <= b.length; j++) matrix[j][0] = j; for (let j = 1; j <= b.length; j++) { for (let i = 1; i <= a.length; i++) { const indicator = a[i - 1] === b[j - 1] ? 0 : 1; matrix[j][i] = Math.min( matrix[j][i - 1] + 1, // deletion matrix[j - 1][i] + 1, // insertion matrix[j - 1][i - 1] + indicator // substitution ); } } return matrix[b.length][a.length]; } function normalizeString(str: string): string { return str.toLowerCase() .replace(/[^\w\s]/g, '') // Remove punctuation .replace(/\s+/g, ' ') // Normalize whitespace .trim(); } function normalizeNameForMatching(name: string): string { return name.toLowerCase() .replace(/\b(elder|president)\b/g, '') // Remove titles .replace(/[^\w\s]/g, ' ') // Replace punctuation with spaces .replace(/\s+/g, ' ') // Normalize whitespace .trim(); } function fuzzyMatch(input: string, target: string, threshold: number = 0.7): boolean { const normalizedInput = normalizeString(input); const normalizedTarget = normalizeString(target); // Exact match or contains if (normalizedTarget.includes(normalizedInput) || normalizedInput.includes(normalizedTarget)) { return true; } // Levenshtein distance similarity const maxLength = Math.max(normalizedInput.length, normalizedTarget.length); if (maxLength === 0) return true; const distance = levenshteinDistance(normalizedInput, normalizedTarget); const similarity = 1 - (distance / maxLength); return similarity >= threshold; } // Scripture book name mappings and fuzzy matching const SCRIPTURE_BOOKS = [ // Bible - Old Testament 'Genesis', 'Exodus', 'Leviticus', 'Numbers', 'Deuteronomy', 'Joshua', 'Judges', 'Ruth', '1 Samuel', '2 Samuel', '1 Kings', '2 Kings', '1 Chronicles', '2 Chronicles', 'Ezra', 'Nehemiah', 'Esther', 'Job', 'Psalms', 'Proverbs', 'Ecclesiastes', 'Song of Solomon', 'Isaiah', 'Jeremiah', 'Lamentations', 'Ezekiel', 'Daniel', 'Hosea', 'Joel', 'Amos', 'Obadiah', 'Jonah', 'Micah', 'Nahum', 'Habakkuk', 'Zephaniah', 'Haggai', 'Zechariah', 'Malachi', // Bible - New Testament 'Matthew', 'Mark', 'Luke', 'John', 'Acts', 'Romans', '1 Corinthians', '2 Corinthians', 'Galatians', 'Ephesians', 'Philippians', 'Colossians', '1 Thessalonians', '2 Thessalonians', '1 Timothy', '2 Timothy', 'Titus', 'Philemon', 'Hebrews', 'James', '1 Peter', '2 Peter', '1 John', '2 John', '3 John', 'Jude', 'Revelation', // Book of Mormon '1 Nephi', '2 Nephi', 'Jacob', 'Enos', 'Jarom', 'Omni', 'Words of Mormon', 'Mosiah', 'Alma', 'Helaman', '3 Nephi', '4 Nephi', 'Mormon', 'Ether', 'Moroni', // Doctrine and Covenants 'Doctrine and Covenants', 'D&C', // Pearl of Great Price 'Moses', 'Abraham', 'Joseph Smith—Matthew', 'Joseph Smith—History', 'Articles of Faith' ]; const BOOK_ALIASES: { [key: string]: string } = { // Common abbreviations 'gen': 'Genesis', 'ex': 'Exodus', 'lev': 'Leviticus', 'num': 'Numbers', 'deut': 'Deuteronomy', 'josh': 'Joshua', 'judg': 'Judges', '1 sam': '1 Samuel', '2 sam': '2 Samuel', '1 kgs': '1 Kings', '2 kgs': '2 Kings', '1 chr': '1 Chronicles', '2 chr': '2 Chronicles', 'neh': 'Nehemiah', 'ps': 'Psalms', 'psalm': 'Psalms', 'prov': 'Proverbs', 'eccl': 'Ecclesiastes', 'song': 'Song of Solomon', 'isa': 'Isaiah', 'jer': 'Jeremiah', 'lam': 'Lamentations', 'ezek': 'Ezekiel', 'dan': 'Daniel', 'matt': 'Matthew', '1 cor': '1 Corinthians', '2 cor': '2 Corinthians', 'gal': 'Galatians', 'eph': 'Ephesians', 'phil': 'Philippians', 'col': 'Colossians', '1 thes': '1 Thessalonians', '2 thes': '2 Thessalonians', '1 tim': '1 Timothy', '2 tim': '2 Timothy', 'philem': 'Philemon', 'heb': 'Hebrews', '1 pet': '1 Peter', '2 pet': '2 Peter', 'rev': 'Revelation', // Book of Mormon abbreviations '1 ne': '1 Nephi', '2 ne': '2 Nephi', 'wom': 'Words of Mormon', '3 ne': '3 Nephi', '4 ne': '4 Nephi', 'morm': 'Mormon', 'moro': 'Moroni', // D&C abbreviations 'dc': 'Doctrine and Covenants', 'doc': 'Doctrine and Covenants', 'covenants': 'Doctrine and Covenants', // Pearl of Great Price abbreviations 'js-m': 'Joseph Smith—Matthew', 'js-h': 'Joseph Smith—History', 'js-matthew': 'Joseph Smith—Matthew', 'js-history': 'Joseph Smith—History', 'aof': 'Articles of Faith' }; function findBestBookMatch(input: string): string | null { const normalizedInput = normalizeString(input); // Check exact alias matches first if (BOOK_ALIASES[normalizedInput]) { return BOOK_ALIASES[normalizedInput]; } // Try fuzzy matching against all books let bestMatch = null; let bestScore = 0; for (const book of SCRIPTURE_BOOKS) { const normalizedBook = normalizeString(book); // Check if input is contained in book name or vice versa if (normalizedBook.includes(normalizedInput) || normalizedInput.includes(normalizedBook)) { return book; } // Check fuzzy similarity if (fuzzyMatch(normalizedInput, normalizedBook, 0.6)) { const maxLength = Math.max(normalizedInput.length, normalizedBook.length); const distance = levenshteinDistance(normalizedInput, normalizedBook); const score = 1 - (distance / maxLength); if (score > bestScore) { bestScore = score; bestMatch = book; } } } return bestScore > 0.6 ? bestMatch : null; } export function registerAllTools(server: McpServer, access: ToolAccess) { const debug = !!process.env.GOSPEL_DEBUG; // Simple tool wrapper with better error handling const safeTool = (name: string, description: string, inputSchema: any, handler: any) => { server.registerTool(name, { title: name.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()), // Convert snake_case to Title Case description: description, inputSchema: inputSchema }, async (args: any) => { try { if (debug) console.error(`[gospel-library] tool invoke ${name}`); // Single DB initialization await access.ensureDb(); const database = access.getDB(); const res = await handler(args, database); if (debug) console.error(`[gospel-library] tool result ${name} ok`); return res; } catch (e: any) { console.error(`[gospel-library] tool error ${name}:`, e?.message || e); return { content: [{ type: 'text', text: `Error: ${e?.message || 'Tool execution failed'}` }] }; } }); }; // Enhanced scripture reference parser with fuzzy matching const parseReference = (input: string) => { if (!input?.trim()) return null; const normalized = input.replace(/[\u2012-\u2015\u2212]/g, '-').trim(); // Try multiple parsing patterns const patterns = [ // Standard format: "Book Chapter:Verse" or "Book Chapter:Verse-Verse" /^\s*([1-3]?\s?[A-Za-z&\.\s]+?)\s+(\d+):(\d+)(?:-(\d+))?\s*$/, // Shortened format: "Book Verse" (assumes chapter 1) - e.g., "Omni 7" -> "Omni 1:7" /^\s*([1-3]?\s?[A-Za-z&\.\s]+?)\s+(\d+)\s*$/, // D&C section format: "D&C 76" or "Section 76" /^\s*(?:D&C|Doctrine and Covenants|Section)\s+(\d+)(?::(\d+)(?:-(\d+))?)?\s*$/i ]; for (let i = 0; i < patterns.length; i++) { const match = normalized.match(patterns[i]); if (match) { let book: string = ''; let chapter: number = 0; let verseStart: number = 0; let verseEnd: number = 0; if (i === 0) { // Standard format book = match[1].replace(/\s+/g, ' ').trim(); chapter = parseInt(match[2]); verseStart = parseInt(match[3]); verseEnd = match[4] ? parseInt(match[4]) : verseStart; } else if (i === 1) { // Shortened format - assume chapter 1 book = match[1].replace(/\s+/g, ' ').trim(); chapter = 1; verseStart = parseInt(match[2]); verseEnd = verseStart; } else if (i === 2) { // D&C format book = 'Doctrine and Covenants'; chapter = parseInt(match[1]); verseStart = match[2] ? parseInt(match[2]) : 1; verseEnd = match[3] ? parseInt(match[3]) : verseStart; } // Apply fuzzy matching to find the best book name const bestBook = findBestBookMatch(book); if (bestBook && verseEnd >= verseStart && chapter > 0 && verseStart > 0) { return { book: bestBook, chapter, verseStart, verseEnd }; } } } return null; }; // Simplified passage fetcher const fetchPassage = async (database: any, parsed: any) => { if (parsed.verseEnd - parsed.verseStart > 50) { return { content: [{ type: "text", text: "Verse range too large (max 50 verses)" }] }; } const stmt = database.prepare(`SELECT verse, text FROM scriptures WHERE book=? AND chapter=? AND verse BETWEEN ? AND ? ORDER BY verse;`); const result = await stmt.bind(parsed.book, parsed.chapter, parsed.verseStart, parsed.verseEnd).all(); const verses = result.results || []; if (!verses.length) { return { content: [{ type: "text", text: `No verses found for ${parsed.book} ${parsed.chapter}:${parsed.verseStart}${parsed.verseEnd !== parsed.verseStart ? '-' + parsed.verseEnd : ''}` }] }; } const citation = `${parsed.book} ${parsed.chapter}:${parsed.verseStart}${parsed.verseEnd !== parsed.verseStart ? '-' + parsed.verseEnd : ''}`; const versesText = verses.map((v: any) => `${v.verse} ${v.text}`).join('\n'); return { content: [ { type: "text", text: citation }, { type: "text", text: versesText } ] }; }; // Exact scripture retrieval tool safeTool( "get_exact_scripture", "Fetch an exact LDS scripture verse or short contiguous range (Bible, Book of Mormon, D&C, Pearl of Great Price). Always call before quoting scripture wording.", { reference: z.string().describe("Required. A verse or short range: 'John 3:16', 'Alma 32:27-28', '1 Nephi 3:7'. Range limit: <=50 verses.") }, async ({ reference }: { reference: string }, database: any) => { if (!reference) { return { content: [{ type: 'text', text: 'Missing required parameter: reference' }] }; } const parsed = parseReference(reference); if (!parsed) { return { content: [{ type: 'text', text: 'Invalid scripture reference. Examples: "John 3:16", "1 Nephi 3:7", "Alma 32:27-28"' }] }; } return fetchPassage(database, parsed); }); // Scripture keyword/topic search tool safeTool( "search_scriptures_by_keyword", "Search LDS scriptures by keyword/phrase (topic discovery). Use before teaching on a topic or when user asks 'verses about X'.", { query: z.string().describe("Required. Keyword or short phrase (<100 chars), e.g. 'charity', 'plan of salvation', 'endure to the end'."), limit: z.number().min(1).max(20).optional().describe("Max number of results (default 10).") }, async ({ query, limit }: { query: string; limit?: number }, database: any) => { if (!query) { return { content: [{ type: 'text', text: 'Missing required parameter: query' }] }; } if (query.length > 100) { return { content: [{ type: 'text', text: 'Search query too long (max 100 characters)' }] }; } const lim = Math.min(limit || 10, 20); const stmt = database.prepare(`SELECT book, chapter, verse, text FROM scriptures WHERE lower(text) LIKE ? LIMIT ?;`); const result = await stmt.bind(`%${query.toLowerCase()}%`, lim).all(); const rows = result.results || []; if (!rows.length) { return { content: [{ type: 'text', text: 'No results found.' }] }; } return { content: rows.map((r: any) => ({ type: 'text', text: `${r.book} ${r.chapter}:${r.verse} - ${r.text.substring(0, 150)}${r.text.length > 150 ? '...' : ''}` })) }; }); // Random scripture tool (optional utility) safeTool( "get_random_scripture", "Return a single random scripture verse (any standard work). Useful for daily verse prompts.", {}, async (_args: {}, database: any) => { const stmt = database.prepare(`SELECT book, chapter, verse, text FROM scriptures ORDER BY RANDOM() LIMIT 1;`); const row = await stmt.first(); if (!row) { return { content: [{ type: 'text', text: 'No scriptures available.' }] }; } return { content: [ { type: 'text', text: `${row.book} ${row.chapter}:${row.verse}` }, { type: 'text', text: row.text } ] }; }); // Conference talks tool safeTool( "search_conference_talks", "General Conference talks (modern prophets/apostles). Use for quotes, sourcing, or locating talks by speaker, conference, or topic. Use 'id' for a specific talk; otherwise filter with speaker/conference/query (keep query <100 chars). Combine with scripture tool if both modern and canonical sources are requested. Always fetch before quoting.", { id: z.number().optional().describe("Specific talk ID to retrieve"), query: z.string().optional().describe("Keyword(s)/phrase to search in talk content. Keep under 100 chars."), speaker: z.string().optional().describe("Speaker name (full or partial). E.g. 'Nelson', 'Russell M. Nelson', 'Holland'."), conference: z.string().optional().describe("Conference identifier (e.g., 'April 2023', 'Oct 2022', or '2023-04')."), limit: z.number().min(1).max(20).optional().describe("Maximum number of results (default 10). Use smaller numbers for broad topics.") }, async ({ id, query, speaker, conference, limit }: { id?: number; query?: string; speaker?: string; conference?: string; limit?: number }, database: any) => { // Get specific talk by ID if (id) { const stmt = database.prepare(`SELECT speaker, title, conference, date, full_text FROM conference_talks WHERE id=?;`); const row = await stmt.bind(id).first(); if (!row) { return { content: [{ type: 'text', text: 'Talk not found.' }] }; } const text = row.full_text || ''; const truncated = text.length > 1500 ? text.substring(0, 1500) + '...\n[Text truncated - use ID to get full talk]' : text; return { content: [ { type: 'text', text: `${row.speaker} - ${row.title} (${row.conference}, ${row.date})` }, { type: 'text', text: truncated } ] }; } // Build search query with fuzzy matching support let sql = `SELECT id, speaker, title, conference, date, substr(full_text, 1, 200) as excerpt FROM conference_talks WHERE 1=1`; const binds: any[] = []; if (speaker) { // First try exact/simple matching let speakerMatched = false; // Try enhanced fuzzy matching by getting all speakers and finding the best match const allSpeakersStmt = database.prepare(`SELECT DISTINCT speaker FROM conference_talks;`); const allSpeakersResult = await allSpeakersStmt.bind().all(); const allSpeakers = (allSpeakersResult.results || []).map((row: any) => row.speaker); let bestSpeakerMatch = null; let bestScore = 0; const normalizedInput = normalizeNameForMatching(speaker); for (const dbSpeaker of allSpeakers) { const normalizedDbSpeaker = normalizeNameForMatching(dbSpeaker); // Check for exact substring matches first if (normalizedDbSpeaker.includes(normalizedInput) || normalizedInput.includes(normalizedDbSpeaker)) { bestSpeakerMatch = dbSpeaker; bestScore = 1.0; break; } // Check fuzzy similarity with a lower threshold for names if (fuzzyMatch(normalizedInput, normalizedDbSpeaker, 0.5)) { const maxLength = Math.max(normalizedInput.length, normalizedDbSpeaker.length); const distance = levenshteinDistance(normalizedInput, normalizedDbSpeaker); const score = 1 - (distance / maxLength); // Debug output for troubleshooting if (debug && normalizedDbSpeaker.includes('russell') && normalizedInput.includes('russel')) { console.error(`[DEBUG] Comparing "${normalizedInput}" vs "${normalizedDbSpeaker}": distance=${distance}, score=${score}`); } if (score > bestScore) { bestScore = score; bestSpeakerMatch = dbSpeaker; } } } if (bestSpeakerMatch && bestScore > 0.4) { sql += ` AND speaker = ?`; binds.push(bestSpeakerMatch); speakerMatched = true; } // If no good fuzzy match found, fall back to partial matching if (!speakerMatched) { sql += ` AND lower(speaker) LIKE ?`; binds.push(`%${speaker.toLowerCase()}%`); } } if (conference) { // Conference name fuzzy matching let conferenceMatched = false; // Get all conference names and try fuzzy matching const allConferencesStmt = database.prepare(`SELECT DISTINCT conference FROM conference_talks;`); const allConferencesResult = await allConferencesStmt.bind().all(); const allConferences = (allConferencesResult.results || []).map((row: any) => row.conference); let bestConferenceMatch = null; let bestScore = 0; const normalizedInput = conference.toLowerCase() .replace(/\b(oct|october)\b/g, 'october') .replace(/\b(apr|april)\b/g, 'april') .replace(/\b(gen|general)\b/g, 'general') .replace(/\b(conf|conference)\b/g, 'conference') .trim(); for (const dbConference of allConferences) { const normalizedDbConference = dbConference.toLowerCase(); // Check for exact substring matches first if (normalizedDbConference.includes(normalizedInput) || normalizedInput.includes(normalizedDbConference)) { bestConferenceMatch = dbConference; bestScore = 1.0; break; } // Check fuzzy similarity if (fuzzyMatch(normalizedInput, normalizedDbConference, 0.6)) { const maxLength = Math.max(normalizedInput.length, normalizedDbConference.length); const distance = levenshteinDistance(normalizedInput, normalizedDbConference); const score = 1 - (distance / maxLength); if (score > bestScore) { bestScore = score; bestConferenceMatch = dbConference; } } } if (bestConferenceMatch && bestScore > 0.6) { sql += ` AND conference = ?`; binds.push(bestConferenceMatch); conferenceMatched = true; } // If no good fuzzy match found, fall back to partial matching if (!conferenceMatched) { sql += ` AND lower(conference) LIKE ?`; binds.push(`%${conference.toLowerCase()}%`); } } if (query) { if (query.length > 100) { return { content: [{ type: 'text', text: 'Search query too long (max 100 characters)' }] }; } sql += ` AND lower(full_text) LIKE ?`; binds.push(`%${query.toLowerCase()}%`); } const lim = Math.min(limit || 10, 20); sql += ` ORDER BY date DESC LIMIT ?`; binds.push(lim); const stmt = database.prepare(sql); const result = await stmt.bind(...binds).all(); const rows = result.results || []; if (!rows.length) { return { content: [{ type: 'text', text: 'No talks found matching those criteria.' }] }; } // If only one result, return the full talk content if (rows.length === 1) { const talk = rows[0]; const fullTalkStmt = database.prepare(`SELECT speaker, title, conference, date, full_text FROM conference_talks WHERE id=?;`); const fullTalk = await fullTalkStmt.bind(talk.id).first(); if (fullTalk && fullTalk.full_text) { // Return the complete talk without truncation since there's only one result return { content: [ { type: 'text', text: `${fullTalk.speaker} - ${fullTalk.title} (${fullTalk.conference}, ${fullTalk.date})` }, { type: 'text', text: fullTalk.full_text } ] }; } } // Multiple results - return excerpts return { content: rows.map((r: any) => ({ type: 'text', text: `[ID: ${r.id}] ${r.speaker} - ${r.title} (${r.conference})\n${r.excerpt}...` })) }; }); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/adammharris/gospel-library-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server