Skip to main content
Glama
wiki.ts14.6 kB
/** * Wiki Management Tools * * Wiki 管理相关的 MCP 工具实现 */ import type { GiteaClient } from '../gitea-client.js'; import type { ContextManager } from '../context-manager.js'; import type { GiteaWikiPage, GiteaWikiPageContent, GiteaWikiRevision, GiteaWikiCommitList, CreateWikiPageOptions, UpdateWikiPageOptions, } from '../types/gitea.js'; import { createLogger } from '../logger.js'; import { GiteaAPIError } from '../gitea-client.js'; const logger = createLogger('tools:wiki'); export interface WikiToolsContext { client: GiteaClient; contextManager: ContextManager; } /** * 清理 Wiki URL 中的异常后缀(如 ".-") */ function cleanWikiUrl(url: string | undefined): string | undefined { if (!url) return url; // 移除 URL 末尾的 ".-" 后缀 return url.replace(/\.-$/, ''); } /** * 清理页面名称,移除 ".-" 后缀 * 用于返回给用户的页面名秲 */ function cleanPageName(name: string | undefined): string | undefined { if (!name) return name; return name.replace(/\.-$/, ''); } /** * 检查字符串是否已被 URL 编码 */ function isUrlEncoded(str: string): boolean { return /%[0-9A-Fa-f]{2}/.test(str); } /** * 安全解码 URI 组件 */ function safeDecodeURIComponent(str: string): string { try { return decodeURIComponent(str); } catch { return str; } } /** * 获取所有页面名称的可能变体 * Gitea Wiki API 的特殊行为: * - 对于 ASCII 页面(如 "Home"),直接使用页面名即可 * - 对于非 ASCII 页面(如中文"违规处理"),**必须**添加 ".md" 后缀才能获取 */ function getPageNameVariants(pageName: string): Array<{ name: string; needsEncoding: boolean }> { const variants: Array<{ name: string; needsEncoding: boolean }> = []; let rawName = pageName; let encodedName = pageName; // 移除已有的 .md 后缀以便统一处理 const hasMdSuffix = pageName.endsWith('.md') || pageName.endsWith('.md.-'); if (hasMdSuffix) { rawName = pageName.replace(/\.md(\.-)?$/, ''); } // 处理已编码的输入 if (isUrlEncoded(rawName)) { rawName = safeDecodeURIComponent(rawName); encodedName = rawName; } encodedName = encodeURIComponent(rawName); // 检测是否为非 ASCII 名称(中文等) const hasNonAscii = /[^\x00-\x7F]/.test(rawName); if (hasNonAscii) { // 对于非 ASCII 页面,优先使用 .md 后缀(这是 Gitea 的要求) variants.push({ name: `${encodedName}.md`, needsEncoding: false }); variants.push({ name: `${rawName}.md`, needsEncoding: true }); } // 原始名称(无后缀) variants.push({ name: rawName, needsEncoding: true }); variants.push({ name: encodedName, needsEncoding: false }); // .- 后缀变体(某些旧版本可能使用) variants.push({ name: `${rawName}.-`, needsEncoding: true }); // 去重 const seen = new Set<string>(); return variants.filter(v => { const key = `${v.name}:${v.needsEncoding}`; if (seen.has(key)) return false; seen.add(key); return true; }); } /** * 解码 base64 内容为 UTF-8 字符串 */ function decodeBase64Content(base64: string | undefined): string | undefined { if (!base64) return undefined; try { return Buffer.from(base64, 'base64').toString('utf-8'); } catch (error) { logger.error({ error }, 'Failed to decode base64 content'); return undefined; } } /** * 尝试使用不同页面名称变体执行 API 调用 * 处理 Gitea Wiki 页面的编码和后缀问题 */ async function tryWithPageNameVariants<T>( ctx: WikiToolsContext, pageName: string, apiCall: (encodedPageName: string) => Promise<T> ): Promise<T> { if (!pageName) { throw new Error('Wiki page name is required'); } const variants = getPageNameVariants(pageName); let lastError: Error | null = null; for (const variant of variants) { try { const encodedPageName = variant.needsEncoding ? encodeURIComponent(variant.name) : variant.name; const result = await apiCall(encodedPageName); logger.debug({ pageName, variant: variant.name }, 'Wiki API call succeeded with variant'); return result; } catch (error) { if (error instanceof GiteaAPIError && error.status === 404) { logger.debug({ pageName, variant: variant.name }, 'Wiki page not found, trying next variant'); lastError = error; continue; } // 非 404 错误直接抛出 throw error; } } // 所有变体都失败了 throw lastError || new Error(`Wiki page "${pageName}" not found`); } /** * 列出仓库的所有 Wiki 页面 */ export async function listWikiPages( ctx: WikiToolsContext, args: { owner?: string; repo?: string; page?: number; limit?: number; token?: string; } ) { logger.debug({ args }, 'Listing wiki pages'); const { owner, repo } = ctx.contextManager.resolveOwnerRepo(args.owner, args.repo); const query: Record<string, number | undefined> = { page: args.page, limit: args.limit, }; const pages = await ctx.client.get<GiteaWikiPage[]>( `/repos/${owner}/${repo}/wiki/pages`, query, args.token ); logger.info({ owner, repo, count: pages.length }, 'Wiki pages listed'); return { success: true, pages: pages.map(p => ({ title: p.title, name: cleanPageName(p.name), html_url: cleanWikiUrl(p.html_url), sub_url: cleanWikiUrl(p.sub_url), last_commit: { sha: p.last_commit.sha, message: p.last_commit.message, author: p.last_commit.author.name, date: p.last_commit.author.date, }, })), count: pages.length, }; } /** * 获取 Wiki 页面的完整内容 */ export async function getWikiPage( ctx: WikiToolsContext, args: { owner?: string; repo?: string; pageName: string; token?: string; } ) { logger.debug({ args }, 'Getting wiki page'); const { owner, repo } = ctx.contextManager.resolveOwnerRepo(args.owner, args.repo); const page = await tryWithPageNameVariants(ctx, args.pageName, (encodedPageName) => ctx.client.get<GiteaWikiPageContent>(`/repos/${owner}/${repo}/wiki/page/${encodedPageName}`, undefined, args.token) ); logger.info({ owner, repo, pageName: args.pageName }, 'Wiki page retrieved'); // Gitea API 返回的 content 可能是 content_base64 编码��,需要解� // 优先使用已解码�� content,如果没有则从 content_base64 解� const content = page.content || decodeBase64Content(page.content_base64); return { success: true, page: { title: page.title, name: cleanPageName(page.name), content: content, html_url: cleanWikiUrl(page.html_url), sub_url: cleanWikiUrl(page.sub_url), last_commit: { sha: page.last_commit.sha, message: page.last_commit.message, author: page.last_commit.author.name, date: page.last_commit.author.date, }, }, }; } /** * 创建新的 Wiki 页面 */ export async function createWikiPage( ctx: WikiToolsContext, args: { owner?: string; repo?: string; title: string; content: string; message?: string; token?: string; } ) { logger.debug( { args: { ...args, content: `${args.content.substring(0, 100)}...` } }, 'Creating wiki page' ); const { owner, repo } = ctx.contextManager.resolveOwnerRepo(args.owner, args.repo); // Convert content to base64 const contentBase64 = Buffer.from(args.content, 'utf-8').toString('base64'); const createOptions: CreateWikiPageOptions = { title: args.title, content_base64: contentBase64, message: args.message || `Create wiki page: ${args.title}`, }; const page = await ctx.client.post<GiteaWikiPageContent>( `/repos/${owner}/${repo}/wiki/new`, createOptions, args.token ); logger.info({ owner, repo, title: args.title }, 'Wiki page created'); return { success: true, message: `Wiki page "${args.title}" has been created`, page: { title: page.title, name: cleanPageName(page.name), html_url: cleanWikiUrl(page.html_url), sub_url: cleanWikiUrl(page.sub_url), }, }; } /** * 更新现有的 Wiki 页面 */ export async function updateWikiPage( ctx: WikiToolsContext, args: { owner?: string; repo?: string; pageName: string; title?: string; content?: string; message?: string; token?: string; } ) { const contentPreview = args.content ? `${args.content.substring(0, 100)}...` : undefined; logger.debug( { args: { ...args, content: contentPreview } }, 'Updating wiki page' ); const { owner, repo } = ctx.contextManager.resolveOwnerRepo(args.owner, args.repo); // Convert content to base64 if provided const contentBase64 = args.content ? Buffer.from(args.content, 'utf-8').toString('base64') : undefined; // Only include fields that are actually provided const updateOptions: UpdateWikiPageOptions = { ...(args.title !== undefined && { title: args.title }), ...(contentBase64 !== undefined && { content_base64: contentBase64 }), message: args.message || `Update wiki page: ${args.pageName}`, }; const page = await tryWithPageNameVariants(ctx, args.pageName, (encodedPageName) => ctx.client.patch<GiteaWikiPageContent>( `/repos/${owner}/${repo}/wiki/page/${encodedPageName}`, updateOptions, args.token ) ); logger.info({ owner, repo, pageName: args.pageName }, 'Wiki page updated'); return { success: true, message: `Wiki page "${page.title || page.name}" has been updated`, page: { title: page.title, name: cleanPageName(page.name), html_url: cleanWikiUrl(page.html_url), sub_url: cleanWikiUrl(page.sub_url), }, }; } /** * 删除 Wiki 页面 */ export async function deleteWikiPage( ctx: WikiToolsContext, args: { owner?: string; repo?: string; pageName: string; token?: string; } ) { logger.debug({ args }, 'Deleting wiki page'); const { owner, repo } = ctx.contextManager.resolveOwnerRepo(args.owner, args.repo); await tryWithPageNameVariants(ctx, args.pageName, (encodedPageName) => ctx.client.delete(`/repos/${owner}/${repo}/wiki/page/${encodedPageName}`, undefined, args.token) ); logger.info({ owner, repo, pageName: args.pageName }, 'Wiki page deleted'); return { success: true, message: `Wiki page "${args.pageName}" has been deleted`, }; } /** * 获取 Wiki 页面的修订历史 */ export async function getWikiRevisions( ctx: WikiToolsContext, args: { owner?: string; repo?: string; pageName: string; page?: number; limit?: number; token?: string; } ) { logger.debug({ args }, 'Getting wiki revisions'); const { owner, repo } = ctx.contextManager.resolveOwnerRepo(args.owner, args.repo); const query: Record<string, number | undefined> = { page: args.page, limit: args.limit, }; // Gitea API 返回 WikiCommitList 对象,包含 commits 数组和 count const response = await tryWithPageNameVariants(ctx, args.pageName, (encodedPageName) => ctx.client.get<GiteaWikiCommitList>( `/repos/${owner}/${repo}/wiki/revisions/${encodedPageName}`, query, args.token ) ); // 处理可能的响应格式:丌接数组或 WikiCommitList 对象 const commits = Array.isArray(response) ? response as unknown as GiteaWikiRevision[] : (response.commits || []); logger.info( { owner, repo, pageName: args.pageName, count: commits.length }, 'Wiki revisions retrieved' ); return { success: true, revisions: commits.map(r => ({ sha: r.sha, message: r.message, author: { name: r.author.name, email: r.author.email, date: r.author.date, }, committer: { name: r.committer.name, email: r.committer.email, date: r.committer.date, }, })), count: commits.length, }; } /** * 获取 Wiki 页面特定版本的内容 */ export async function getWikiPageRevision( ctx: WikiToolsContext, args: { owner?: string; repo?: string; pageName: string; revision: string; token?: string; } ) { logger.debug({ args }, 'Getting wiki page revision'); const { owner, repo } = ctx.contextManager.resolveOwnerRepo(args.owner, args.repo); const page = await tryWithPageNameVariants(ctx, args.pageName, (encodedPageName) => ctx.client.get<GiteaWikiPageContent>( `/repos/${owner}/${repo}/wiki/page/${encodedPageName}`, { revision: args.revision }, args.token ) ); logger.info( { owner, repo, pageName: args.pageName, revision: args.revision }, 'Wiki page revision retrieved' ); // 优先使用已解码的 content,如果没有刘从 content_base64 解码 const content = page.content || decodeBase64Content(page.content_base64); return { success: true, page: { title: page.title, name: cleanPageName(page.name), content: content, revision: args.revision, html_url: cleanWikiUrl(page.html_url), }, }; } /** * 搜索 Wiki 页面(客户端过滤实现) */ export async function searchWikiPages( ctx: WikiToolsContext, args: { owner?: string; repo?: string; query: string; limit?: number; token?: string; } ) { logger.debug({ args }, 'Searching wiki pages'); const { owner, repo } = ctx.contextManager.resolveOwnerRepo(args.owner, args.repo); // 获取所有页面 const pages = await ctx.client.get<GiteaWikiPage[]>( `/repos/${owner}/${repo}/wiki/pages`, undefined, args.token ); // 客户端过滤 const searchQuery = args.query.toLowerCase(); const filteredPages = pages.filter( p => p.title.toLowerCase().includes(searchQuery) || p.name.toLowerCase().includes(searchQuery) ); // 限制结果数量 const results = args.limit ? filteredPages.slice(0, args.limit) : filteredPages; logger.info( { owner, repo, query: args.query, count: results.length }, 'Wiki pages searched' ); return { success: true, pages: results.map(p => ({ title: p.title, name: cleanPageName(p.name), html_url: cleanWikiUrl(p.html_url), sub_url: cleanWikiUrl(p.sub_url), last_commit: { sha: p.last_commit.sha, message: p.last_commit.message, date: p.last_commit.author.date, }, })), count: results.length, total: filteredPages.length, }; }

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/SupenBysz/gitea-mcp-tool'

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