Skip to main content
Glama

Obsidian GitHub MCP

client.ts33.1 kB
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { Octokit } from "@octokit/rest"; import { z } from "zod"; import type { GithubConfig } from "./types.js"; export class GithubClient { private octokit: Octokit; constructor(private config: GithubConfig) { this.octokit = new Octokit({ auth: config.githubToken, }); } // Check if configuration is complete private checkConfig(): void { if (!this.config.githubToken || !this.config.owner || !this.config.repo) { throw new Error( `GitHub configuration incomplete. Please set GITHUB_TOKEN, GITHUB_OWNER, and GITHUB_REPO environment variables. Current config: token=${this.config.githubToken ? "set" : "missing"}, owner=${this.config.owner || "missing"}, repo=${this.config.repo || "missing"}` ); } } // Simplified error handler for Octokit requests private async handleRequest<T>( request: () => Promise<{ data: T }> ): Promise<T> { this.checkConfig(); // Ensure config is complete before making requests try { const { data } = await request(); return data; } catch (error: unknown) { if (error instanceof Error) { throw new Error(`GitHub API error: ${error.message}`); } throw new Error(`GitHub API error: ${String(error)}`); } } // Run diagnostics when search returns no results private async runSearchDiagnostics(_originalQuery: string): Promise<{ repoSize?: number; repoSizeGB?: number; isPrivate?: boolean; defaultBranch?: string; basicSearchWorks?: boolean; filesFound?: number; repoIndexed?: boolean; isLarge?: boolean; diagnosticError?: string; }> { try { // Test 1: Repository accessibility const repoInfo = await this.handleRequest(async () => { return this.octokit.repos.get({ owner: this.config.owner, repo: this.config.repo, }); }); // Test 2: Basic search functionality with simple query let basicSearchWorks = false; let basicSearchCount = 0; try { const basicTest = await this.handleRequest(async () => { return this.octokit.search.code({ q: `repo:${this.config.owner}/${this.config.repo} extension:md`, per_page: 1, }); }); basicSearchWorks = true; basicSearchCount = basicTest.total_count; } catch (_error) { basicSearchWorks = false; } const repoSizeKB = repoInfo.size; const repoSizeGB = repoSizeKB / (1024 * 1024); const isLarge = repoSizeGB > 50; return { repoSize: repoSizeKB, repoSizeGB: repoSizeGB, isPrivate: repoInfo.private, defaultBranch: repoInfo.default_branch, basicSearchWorks, filesFound: basicSearchCount, repoIndexed: basicSearchWorks && basicSearchCount > 0, isLarge, }; } catch (error) { return { diagnosticError: error instanceof Error ? error.message : String(error), }; } } // Format response when no search results are found private formatNoResultsResponse( _searchResults: { total_count: number }, diagnostics: { diagnosticError?: string; repoIndexed?: boolean; isLarge?: boolean; repoSizeGB?: number; isPrivate?: boolean; defaultBranch?: string; filesFound?: number; }, query: string, searchIn: string ): { content: Array<{ type: "text"; text: string }> } { let resultText = `Found 0 files matching "${query}"`; if (searchIn !== "all") { resultText += ` in ${searchIn}`; } resultText += "\n\n"; if (diagnostics.diagnosticError) { resultText += `⚠️ **Search System Issue**: ${diagnostics.diagnosticError}\n\n`; } else if (!diagnostics.repoIndexed) { resultText += "⚠️ **Repository May Not Be Indexed**: GitHub might not have indexed this repository for search.\n"; resultText += "This can happen with:\n"; resultText += "- New repositories (indexing takes time)\n"; if (diagnostics.isLarge) { resultText += `- Large repositories (${diagnostics.repoSizeGB?.toFixed( 2 )} GB exceeds 50 GB limit)\n`; } if (diagnostics.isPrivate) { resultText += "- Private repositories with indexing issues\n"; } resultText += "\n**Try**:\n"; resultText += "- Search directly on GitHub.com to confirm\n"; resultText += "- Use the diagnoseSearch tool for detailed diagnostics\n\n"; } else { resultText += "📊 **Search Debug Info**:\n"; resultText += `- Repository: ${ diagnostics.isPrivate ? "Private" : "Public" } (${diagnostics.repoSizeGB?.toFixed(3)} GB)\n`; resultText += `- Default branch: ${diagnostics.defaultBranch} (only branch searchable)\n`; resultText += `- Files in repo: ${diagnostics.filesFound} found\n`; resultText += `- Search query used: \`${query}\`\n\n`; resultText += "**Possible reasons for no results**:\n"; resultText += "- The search term doesn't exist in the repository\n"; resultText += "- Content might be in non-default branches (not searchable)\n"; resultText += "- Files might be larger than 384 KB (not indexed)\n\n"; } // Add search tips resultText += "💡 **Search Tips:**\n"; resultText += `- Try \`searchIn: "filename"\` to search only filenames\n`; resultText += `- Try \`searchIn: "path"\` to search file paths\n`; resultText += `- Try \`searchIn: "content"\` to search file contents\n`; resultText += `- Use quotes for exact phrases: "exact phrase"\n`; resultText += "- Use wildcards: `*.md` for markdown files\n"; resultText += "- Try simpler or partial search terms"; return { content: [ { type: "text" as const, text: resultText, }, ], }; } registerGithubResources(server: McpServer) { // Resource for repository information server.resource( "repo-info", `obsidian://vault/${this.config.owner}/${this.config.repo}/info`, { description: "Basic information about your Obsidian vault repository", mimeType: "application/json", }, async (uri) => { try { const repoInfo = await this.handleRequest(async () => { return this.octokit.repos.get({ owner: this.config.owner, repo: this.config.repo, }); }); const info = { name: repoInfo.name, fullName: repoInfo.full_name, description: repoInfo.description, isPrivate: repoInfo.private, defaultBranch: repoInfo.default_branch, size: `${(repoInfo.size / 1024).toFixed(2)} MB`, createdAt: repoInfo.created_at, updatedAt: repoInfo.updated_at, language: repoInfo.language, topics: repoInfo.topics || [], htmlUrl: repoInfo.html_url, }; return { contents: [ { uri: uri.toString(), mimeType: "application/json", text: JSON.stringify(info, null, 2), }, ], }; } catch (error) { return { contents: [ { uri: uri.toString(), mimeType: "text/plain", text: `Error fetching repository information: ${ error instanceof Error ? error.message : String(error) }`, }, ], }; } } ); // Resource for recent activity summary server.resource( "recent-activity", `obsidian://vault/${this.config.owner}/${this.config.repo}/activity`, { description: "Summary of recent commits and changes to your vault", mimeType: "text/markdown", }, async (uri) => { try { const since = new Date(); since.setDate(since.getDate() - 7); // Last 7 days const commits = await this.handleRequest(async () => { return this.octokit.repos.listCommits({ owner: this.config.owner, repo: this.config.repo, since: since.toISOString(), per_page: 10, }); }); let markdown = `# Recent Activity - ${this.config.owner}/${this.config.repo}\n\n`; markdown += "Last 7 days of commits:\n\n"; if (commits.length === 0) { markdown += "_No commits in the last 7 days_\n"; } else { for (const commit of commits) { const date = new Date( commit.commit.author?.date || "" ).toLocaleDateString(); const message = commit.commit.message.split("\n")[0]; const author = commit.commit.author?.name || "Unknown"; markdown += `- **${date}**: ${message} _(by ${author})_\n`; } } return { contents: [ { uri: uri.toString(), mimeType: "text/markdown", text: markdown, }, ], }; } catch (error) { return { contents: [ { uri: uri.toString(), mimeType: "text/plain", text: `Error fetching recent activity: ${ error instanceof Error ? error.message : String(error) }`, }, ], }; } } ); } registerGithubPrompts(server: McpServer) { // Prompt to explore vault structure and recent activity server.prompt( "explore-vault", "Explore your Obsidian vault structure, recent changes, and key content", {}, async () => { return { description: "Comprehensive exploration of your Obsidian vault", messages: [ { role: "user" as const, content: { type: "text" as const, text: `Please help me explore my Obsidian vault in the GitHub repository ${this.config.owner}/${this.config.repo}. I'd like to understand: 1. What types of notes and content are in my vault 2. Recent changes and updates (last 7 days) 3. Key topics or themes in my notes 4. Overall structure and organization Please use the available tools to: - Search for markdown files to understand the content types - Check recent commit history to see what's been updated - Suggest ways to better organize or explore my knowledge base`, }, }, ], }; } ); // Prompt to search for specific topics server.prompt( "find-notes-about", "Search your vault for notes related to a specific topic", { topic: z.string().describe("The topic or keyword to search for"), }, async ({ topic }) => { return { description: `Search vault for notes about: ${topic}`, messages: [ { role: "user" as const, content: { type: "text" as const, text: `Please search my Obsidian vault (${this.config.owner}/${this.config.repo}) for notes related to "${topic}". Use the searchFiles tool to: 1. Find files with "${topic}" in the filename 2. Search for "${topic}" in file contents 3. Look for related terms or concepts Then provide: - A summary of what you found - Key insights from the most relevant notes - Suggestions for related topics to explore`, }, }, ], }; } ); // Prompt to analyze knowledge base evolution server.prompt( "analyze-recent-changes", "Analyze how your knowledge base has evolved recently", { days: z .string() .optional() .describe("Number of days to look back (default: 7)"), }, async ({ days }) => { const daysNum = days ? Number.parseInt(days, 10) : 7; return { description: `Analyze vault changes over the last ${daysNum} days`, messages: [ { role: "user" as const, content: { type: "text" as const, text: `Please analyze how my Obsidian vault (${this.config.owner}/${this.config.repo}) has evolved over the last ${daysNum} days. Use the getCommitHistory tool to: 1. Review all commits from the last ${daysNum} days 2. Identify which notes were created, modified, or deleted 3. Analyze the types of changes (new topics, updates to existing notes, etc.) Then provide: - A summary of the main themes or topics you've been working on - Patterns in your note-taking or knowledge development - Suggestions for areas that might need more attention or organization`, }, }, ], }; } ); } registerGithubTools(server: McpServer) { server.tool( "getFileContents", `Retrieve the contents of a specific note, document, or file from your Obsidian vault stored in GitHub (${this.config.owner}/${this.config.repo}). Perfect for accessing your knowledge base content.`, { filePath: z .string() .describe("Path to the file within the repository."), }, { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, async ({ filePath }) => { const fileContent = await this.handleRequest(async () => { return this.octokit.repos.getContent({ owner: this.config.owner, repo: this.config.repo, path: filePath, // Request raw content to avoid base64 decoding complexities for now mediaType: { format: "raw", }, }); }); // The raw format returns the content directly as a string if (typeof fileContent !== "string") { throw new Error( "Received unexpected content format from GitHub API." ); } return { content: [{ type: "text" as const, text: fileContent }], }; } ); // Enhanced searchFiles tool with filename and content search server.tool( "searchFiles", `Search for notes, documents, and files within your Obsidian vault on GitHub (${this.config.owner}/${this.config.repo}). Find specific knowledge base content using GitHub's powerful search syntax. Supports searching in filenames, paths, and content.`, { query: z .string() .describe( "Search query - can be a simple term or use GitHub search qualifiers" ), searchIn: z .enum(["filename", "path", "content", "all"]) .optional() .default("all") .describe( "Where to search: 'filename' (exact filename match), 'path' (anywhere in file path), 'content' (file contents), or 'all' (comprehensive search)" ), page: z .number() .optional() .default(0) .describe("Page number to retrieve (0-indexed)"), perPage: z .number() .optional() .default(100) .describe("Number of results per page"), }, { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, async ({ query, searchIn = "all", page = 0, perPage = 100 }) => { // Empty query is allowed - useful for listing files const repoQualifier = `repo:${this.config.owner}/${this.config.repo}`; // Build search query based on searchIn parameter let qualifiedQuery: string; if (searchIn === "filename") { // Search for exact filename matches qualifiedQuery = `filename:${ query.includes(" ") ? `"${query}"` : query } ${repoQualifier}`; } else if (searchIn === "path") { // Search anywhere in the file path. The `in:path` qualifier searches for the // query term within the file path. qualifiedQuery = `${query} in:path ${repoQualifier}`; } else if (searchIn === "content") { // Search only in file contents. This is the default behavior without qualifiers. qualifiedQuery = `${query} ${repoQualifier}`; } else { // "all" - comprehensive search. The GitHub search API (legacy) does not // support OR operators. The best we can do in a single query is to search // in file content and file path. The `in:file,path` qualifier does this. // This will match the query term if it appears in the content or anywhere // in the full path of a file, which includes the filename. qualifiedQuery = `${query} in:file,path ${repoQualifier}`; } let searchResults: { items: Array<{ name: string; path: string }>; total_count: number; }; try { searchResults = await this.handleRequest(async () => { return this.octokit.search.code({ q: qualifiedQuery, page, per_page: perPage, }); }); } catch (error) { // Enhanced error messages with specific GitHub search issues const errorMessage = error instanceof Error ? error.message : String(error); if (errorMessage.includes("validation failed")) { throw new Error( `GitHub search query invalid: "${qualifiedQuery}". Try simpler terms or check syntax.` ); } if (errorMessage.includes("rate limit")) { throw new Error( "GitHub code search rate limit exceeded. Wait a moment and try again." ); } if ( errorMessage.includes("Forbidden") || errorMessage.includes("401") ) { throw new Error( "GitHub API access denied. Check that your token has 'repo' scope for private repositories." ); } throw error; // Re-throw other errors } // Enhanced formatting with file sizes and relevance indicators const formattedResults = searchResults.items .map((item) => { const fileName = item.name; const filePath = item.path; // const score = item.score || 0; // Could be used for relevance ranking in future // Determine why this file matched let matchReason = ""; if (searchIn === "filename") { matchReason = "📝 filename match"; } else if (searchIn === "path") { matchReason = "📁 path match"; } else if (searchIn === "content") { matchReason = "📄 content match"; } else { // searchIn is 'all', so we deduce the reason if (fileName.toLowerCase().includes(query.toLowerCase())) { matchReason = "📝 filename match"; } else if (filePath.toLowerCase().includes(query.toLowerCase())) { matchReason = "📁 path match"; } else { matchReason = "📄 content match"; } } return `- **${fileName}** (${filePath}) ${matchReason}`; }) .join("\n"); let resultText = `Found ${searchResults.total_count} files`; if (searchIn !== "all") { resultText += ` searching in ${searchIn}`; } resultText += `:\n\n${formattedResults}`; // If no results, run diagnostics and provide enhanced response if (searchResults.total_count === 0) { const diagnostics = await this.runSearchDiagnostics(query); return this.formatNoResultsResponse( searchResults, diagnostics, query, searchIn ); } return { content: [ { type: "text" as const, text: resultText, }, ], }; } ); // Placeholder for searchIssues tool server.tool( "searchIssues", `Search for issues and discussions in your Obsidian vault repository (${this.config.owner}/${this.config.repo}). Great for tracking tasks, project management, and collaborative knowledge work.`, { query: z .string() .describe("Search query (uses GitHub Issue Search syntax)"), }, { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, async ({ query }) => { const repoQualifier = `repo:${this.config.owner}/${this.config.repo}`; const qualifiedQuery = `${query} is:issue ${repoQualifier}`; const searchResults = await this.handleRequest(async () => { return this.octokit.search.issuesAndPullRequests({ q: qualifiedQuery, }); }); // Format results as a markdown list const formattedResults = searchResults.items .map((item) => `- #${item.number} ${item.title} (${item.html_url})`) .join("\n"); return { // Return formatted text instead of raw JSON string content: [ { type: "text" as const, text: `Found ${searchResults.total_count} issues:\n${formattedResults}`, }, ], }; } ); // getCommitHistory tool - focuses on file changes and diffs server.tool( "getCommitHistory", `Track the evolution of your Obsidian vault knowledge base by retrieving commit history from GitHub (${this.config.owner}/${this.config.repo}). See how your notes and ideas have developed over time with detailed diffs.`, { days: z .number() .min(1) .max(365) .describe("Number of days to look back for commits"), includeDiffs: z .boolean() .optional() .default(true) .describe( "Whether to include actual file changes/diffs (default: true)" ), author: z .string() .optional() .describe("Filter commits by author username"), maxCommits: z .number() .min(1) .max(50) .optional() .default(25) .describe("Maximum number of commits to return"), page: z .number() .optional() .default(0) .describe("Page number for pagination (0-indexed)"), }, { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, async ({ days, includeDiffs = true, author, maxCommits = 25, page = 0, }) => { // Calculate date range const since = new Date(); since.setDate(since.getDate() - days); const sinceISO = since.toISOString(); console.error("sinceISO", sinceISO); // Fetch commits list const commits = await this.handleRequest(async () => { return this.octokit.repos.listCommits({ owner: this.config.owner, repo: this.config.repo, since: sinceISO, // author: author, page: page, per_page: maxCommits, }); }); if (commits.length === 0) { return { content: [ { type: "text" as const, text: `No commits found in the last ${days} days since ${sinceISO}${ author ? ` from author ${author}` : "" }.`, }, ], }; } let formattedOutput = `Found ${ commits.length } commits in the last ${days} days${ author ? ` from ${author}` : "" }:\n\n`; if (includeDiffs) { // Fetch detailed commit information with diffs const detailedCommits = await Promise.all( commits.slice(0, maxCommits).map(async (commit) => { const detailed = await this.handleRequest(async () => { return this.octokit.repos.getCommit({ owner: this.config.owner, repo: this.config.repo, ref: commit.sha, }); }); return detailed; }) ); // Format results with diffs for (const commit of detailedCommits) { const shortSha = commit.sha.substring(0, 7); const commitUrl = `https://github.com/${this.config.owner}/${this.config.repo}/commit/${commit.sha}`; formattedOutput += `## Commit ${shortSha} (${commit.sha})\n`; formattedOutput += `**${commit.commit.message.split("\n")[0]}**\n`; formattedOutput += `Author: ${commit.commit.author?.name} <${commit.commit.author?.email}>\n`; formattedOutput += `Date: ${commit.commit.author?.date}\n`; formattedOutput += `URL: ${commitUrl}\n\n`; if (commit.files && commit.files.length > 0) { formattedOutput += `### Files Changed (${commit.files.length}):\n`; for (const file of commit.files) { const additions = file.additions || 0; const deletions = file.deletions || 0; formattedOutput += `- ${file.filename} (+${additions}, -${deletions})\n`; } formattedOutput += "\n### File Changes:\n\n"; for (const file of commit.files) { formattedOutput += `#### ${file.filename}\n`; if (file.patch) { // Truncate large diffs for readability let patch = file.patch; const maxPatchLength = 8000; // Essay-length for note-taking if (patch.length > maxPatchLength) { patch = `${patch.substring( 0, maxPatchLength )}\n\n... (diff truncated for readability) ...`; } formattedOutput += `\`\`\`diff\n${patch}\n\`\`\`\n\n`; } else { formattedOutput += "_No diff available (binary file or no changes to display)_\n\n"; } } } else { formattedOutput += "No file changes detected.\n\n"; } formattedOutput += "---\n\n"; } } else { // Just show commit metadata without diffs for (const commit of commits) { const shortSha = commit.sha.substring(0, 7); const commitUrl = `https://github.com/${this.config.owner}/${this.config.repo}/commit/${commit.sha}`; formattedOutput += `## Commit ${shortSha}\n`; formattedOutput += `**${commit.commit.message.split("\n")[0]}**\n`; formattedOutput += `Author: ${commit.commit.author?.name} <${commit.commit.author?.email}>\n`; formattedOutput += `Date: ${commit.commit.author?.date}\n`; formattedOutput += `URL: ${commitUrl}\n\n`; } } return { content: [ { type: "text" as const, text: formattedOutput, }, ], }; } ); // diagnoseSearch tool for repository diagnostics server.tool( "diagnoseSearch", `Diagnose search functionality and repository configuration for your Obsidian vault on GitHub (${this.config.owner}/${this.config.repo}). Verifies repository connectivity, search capabilities, and checks if repository size is within GitHub's indexing limits.`, {}, { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, async () => { try { // Get repository information const repoInfo = await this.handleRequest(async () => { return this.octokit.repos.get({ owner: this.config.owner, repo: this.config.repo, }); }); // Test search functionality with a simple query let searchWorks = false; let searchError: string | null = null; let searchResultCount = 0; try { const testSearch = await this.handleRequest(async () => { return this.octokit.search.code({ q: `repo:${this.config.owner}/${this.config.repo} extension:md`, per_page: 1, }); }); searchWorks = true; searchResultCount = testSearch.total_count; } catch (error) { searchWorks = false; searchError = error instanceof Error ? error.message : String(error); } // GitHub's code search limitations const MAX_INDEXED_SIZE_GB = 50; // GitHub doesn't index repos > ~50GB const repoSizeGB = repoInfo.size / (1024 * 1024); // Convert KB to GB const isWithinSizeLimit = repoSizeGB <= MAX_INDEXED_SIZE_GB; // Format diagnostic output let diagnosticOutput = "# Repository Search Diagnostics\n\n"; diagnosticOutput += "## Repository Information\n"; diagnosticOutput += `- **Repository**: ${this.config.owner}/${this.config.repo}\n`; diagnosticOutput += `- **Visibility**: ${ repoInfo.private ? "Private" : "Public" }\n`; diagnosticOutput += `- **Size**: ${repoSizeGB.toFixed(3)} GB (${( repoInfo.size / 1024 ).toFixed(2)} MB)\n`; diagnosticOutput += `- **Default Branch**: ${repoInfo.default_branch}\n\n`; diagnosticOutput += "## Search Capabilities\n"; diagnosticOutput += `- **Search API Access**: ${ searchWorks ? "✅ Working" : "❌ Failed" }\n`; diagnosticOutput += `- **Indexed Branch**: Only '${repoInfo.default_branch}' branch is searchable\n`; if (searchWorks) { diagnosticOutput += `- **Markdown Files Found**: ${searchResultCount}\n`; } else if (searchError) { diagnosticOutput += `- **Error**: ${searchError}\n`; } diagnosticOutput += `- **Within Size Limit**: ${ isWithinSizeLimit ? `✅ Yes (${repoSizeGB.toFixed( 3 )} GB < ${MAX_INDEXED_SIZE_GB} GB)` : `⚠️ No (${repoSizeGB.toFixed(3)} GB > ${MAX_INDEXED_SIZE_GB} GB)` }\n\n`; // Add recommendations diagnosticOutput += "## Recommendations\n"; if (!searchWorks && repoInfo.private) { diagnosticOutput += `- ⚠️ **Private Repository**: Ensure your GitHub token has the 'repo' scope for full access to private repositories.\n`; } if (!isWithinSizeLimit) { diagnosticOutput += `- ⚠️ **Large Repository**: GitHub's code search doesn't index repositories larger than ~${MAX_INDEXED_SIZE_GB} GB. Consider:\n`; diagnosticOutput += " - Using file path navigation instead of search for specific files\n"; diagnosticOutput += " - Splitting your vault into multiple repositories\n"; diagnosticOutput += " - Using getFileContents tool with known paths\n"; diagnosticOutput += " - Note: Individual files must be < 384 KB to be searchable\n"; } if (searchWorks && searchResultCount === 0) { diagnosticOutput += "- ℹ️ **No Markdown Files**: No .md files found in the default branch. Your vault might be empty, use different file extensions, or have content in other branches.\n"; } if (searchWorks && isWithinSizeLimit) { diagnosticOutput += "- ✅ **All Systems Operational**: Repository is properly configured and searchable!\n"; } return { content: [ { type: "text" as const, text: diagnosticOutput, }, ], }; } catch (error) { return { content: [ { type: "text" as const, text: `Failed to diagnose repository: ${ error instanceof Error ? error.message : String(error) }\n\nPlease check your GITHUB_TOKEN, GITHUB_OWNER, and GITHUB_REPO configuration.`, }, ], }; } } ); } }

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/Hint-Services/obsidian-github-mcp'

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