Files
gitea-mcp/Tools/TreeTools.cs
T
zhengchen.tao c7fa6aeb7f
Build Docker Image / build (push) Failing after 5m41s
Build Docker Image / deploy (push) Has been skipped
gitea-mcp: 初次落地 Gitea MCP Server (.NET 10, V1 only-read)
把 Gitea (git.zhengchentao.win) 通过 MCP 暴露给 Claude.ai:列 repo、读代码、看 commits / issues / PR / orgs / packages / actions。
设计文档见 vault Coding/gitea-mcp/gitea-mcp 设计.md。
代码模板复用 obsidian-mcp(.NET 10 + ModelContextProtocol SDK + JwtBearer)。

19 个只读 Tool(全部 scope=read:gitea):

Repo / 文件:
- list_repos / read_repo
- list_tree(max_entries=500 防爆)
- read_file(max_bytes=1MB,超出 truncated=true)
- search_code(走 /repos/search-code,indexer 未启用时返回结构化错误说明)

分支 / 提交:
- list_branches / list_commits / read_commit(diff 文件数限 50)

Issue / PR:
- list_issues / read_issue(含评论)
- list_pulls / read_pull(含评论 + 改动文件列表)

Org / Package(用户额外授权 read:organization + read:package):
- list_orgs / read_org
- list_packages / read_package

Gitea Actions(运维友好):
- list_workflow_runs / read_run_log

技术栈:
- .NET 10 + ModelContextProtocol SDK 1.0
- HttpClientFactory + Microsoft.Extensions.Http.Resilience(指数 backoff,5xx/429/网络错误重试)
- JwtBearer (HS256, Current+Previous fallback, MapInboundClaims=false)
- aud=gitea, scope=read:gitea, iss=https://auth.zhengchentao.win

Gitea API client:
- Authorization: token <PAT> (admin PAT,仅 read scope)
- BaseUrl=https://git.zhengchentao.win
- 错误映射:401/403 → UnauthorizedAccessException,404 → KeyNotFoundException,5xx → InvalidOperationException
- RepoBlacklist 黑名单(owner/repo 精确匹配,默认空)

部署:
- Dockerfile multi-stage,COPY --chown,non-root user
- .gitea/workflows/build-image.yml:build + deploy 双 job,buildkit v0.13.2
- 容器内 :8080,宿主端口 9092
- 子域名 git-mcp.zhengchentao.win(区别于 Gitea 本体 git.zhengchentao.win)

测试:6/6 单测过(GiteaRepoFilter 黑名单匹配)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 01:32:42 +08:00

105 lines
4.0 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using GiteaMcp.Config;
using GiteaMcp.Services;
using GiteaMcp.Services.Models;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Server;
using System.ComponentModel;
namespace GiteaMcp.Tools;
/// <summary>文件树与文件内容 Toollist_tree / read_file</summary>
[McpServerToolType]
public class TreeTools(
GiteaApiClient gitea,
GiteaRepoFilter filter,
IOptions<GiteaOptions> opts)
{
private readonly GiteaOptions _opts = opts.Value;
[McpServerTool]
[Description(
"List the file tree of a Gitea repository at a given ref (branch/tag/SHA). " +
"When recursive=false (default), returns only top-level entries. " +
"When recursive=true, returns all files up to max_entries=500 — use this to map repo structure. " +
"Returns: path, type ('blob'=file, 'tree'=directory), size (bytes), sha. " +
"For very large repos (>500 files), truncated=true will be set; narrow down by adjusting paths manually.")]
public async Task<object> list_tree(
[Description("Repository owner.")] string owner,
[Description("Repository name.")] string repo,
[Description("Branch name, tag, or commit SHA. Defaults to the repo's default branch.")] string? @ref = null,
[Description("Recursively include all files in subdirectories. Default false.")] bool recursive = false,
CancellationToken ct = default)
{
if (filter.IsBlocked($"{owner}/{repo}"))
throw new UnauthorizedAccessException($"Repo {owner}/{repo} is on the access blocklist.");
// ref 未提供时,先拿 default_branch
if (string.IsNullOrWhiteSpace(@ref))
{
var repoMeta = await gitea.GetRepoAsync(owner, repo, ct);
@ref = repoMeta.DefaultBranch;
}
var tree = await gitea.GetTreeAsync(owner, repo, @ref, recursive, ct);
const int MaxEntries = 500;
var entries = tree.Tree;
bool truncated = tree.Truncated || entries.Count > MaxEntries;
if (entries.Count > MaxEntries)
entries = entries.Take(MaxEntries).ToList();
return new
{
owner,
repo,
@ref,
truncated,
entry_count = entries.Count,
entries = entries.Select(e => new
{
path = e.Path,
type = e.Type,
size = e.Size,
sha = e.Sha,
}),
};
}
[McpServerTool]
[Description(
"Read the raw text content of a file from a Gitea repository. " +
"Returns the file as UTF-8 text. Binary files will appear garbled — check file extension first. " +
"Truncated to max_bytes (default 1MB); when truncated=true, the content is cut off. " +
"Use list_tree first to discover file paths.")]
public async Task<object> read_file(
[Description("Repository owner.")] string owner,
[Description("Repository name.")] string repo,
[Description("File path relative to repo root, e.g. 'src/Main.cs' or 'README.md'.")] string path,
[Description("Branch, tag, or SHA. Defaults to repo's default branch.")] string? @ref = null,
[Description("Max bytes to read. Default 1048576 (1MB). Reduce for large binary-adjacent files.")] int? max_bytes = null,
CancellationToken ct = default)
{
if (filter.IsBlocked($"{owner}/{repo}"))
throw new UnauthorizedAccessException($"Repo {owner}/{repo} is on the access blocklist.");
if (string.IsNullOrWhiteSpace(@ref))
{
var repoMeta = await gitea.GetRepoAsync(owner, repo, ct);
@ref = repoMeta.DefaultBranch;
}
var maxB = Math.Min(max_bytes ?? _opts.MaxFileBytes, _opts.MaxFileBytes);
var (content, truncated) = await gitea.GetRawFileAsync(owner, repo, @ref, path, maxB, ct);
return new
{
owner,
repo,
@ref,
path,
truncated,
content,
};
}
}