Files
gitea-mcp/Tools/BranchCommitTools.cs
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

122 lines
4.9 KiB
C#
Raw Permalink 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.Services;
using ModelContextProtocol.Server;
using System.ComponentModel;
namespace GiteaMcp.Tools;
/// <summary>分支与 commit Toollist_branches / list_commits / read_commit</summary>
[McpServerToolType]
public class BranchCommitTools(
GiteaApiClient gitea,
GiteaRepoFilter filter)
{
[McpServerTool]
[Description(
"List all branches in a Gitea repository with their latest commit info. " +
"Returns: branch name, protected flag, last commit SHA, message, timestamp, and author. " +
"Use this to discover available branches before calling list_commits or read_file with a specific ref.")]
public async Task<object> list_branches(
[Description("Repository owner.")] string owner,
[Description("Repository name.")] string repo,
CancellationToken ct = default)
{
if (filter.IsBlocked($"{owner}/{repo}"))
throw new UnauthorizedAccessException($"Repo {owner}/{repo} is on the access blocklist.");
var branches = await gitea.GetBranchesAsync(owner, repo, ct);
return branches.Select(b => new
{
name = b.Name,
@protected = b.Protected,
last_commit = b.Commit == null ? null : new
{
sha = b.Commit.Id,
message = b.Commit.Message,
timestamp = b.Commit.Timestamp,
author = b.Commit.Author?.Name,
},
}).ToList();
}
[McpServerTool]
[Description(
"List recent commits in a Gitea repository. " +
"Returns: SHA (short+full), commit message, author, and timestamp. " +
"Use ref to specify a branch or tag; use since (ISO 8601 date) to filter by date. " +
"Default returns up to 30 commits. Good for 'what changed recently' questions.")]
public async Task<object> list_commits(
[Description("Repository owner.")] string owner,
[Description("Repository name.")] string repo,
[Description("Branch, tag, or SHA. Defaults to the default branch.")] string? @ref = null,
[Description("Only commits after this date (ISO 8601, e.g. '2026-04-01T00:00:00Z'). Optional.")] string? since = null,
[Description("Max commits to return. Default 30.")] int? limit = null,
CancellationToken ct = default)
{
if (filter.IsBlocked($"{owner}/{repo}"))
throw new UnauthorizedAccessException($"Repo {owner}/{repo} is on the access blocklist.");
var lim = Math.Min(limit ?? 30, 100);
var commits = await gitea.GetCommitsAsync(owner, repo, @ref, since, lim, ct);
return commits.Select(c => new
{
sha = c.Sha,
sha_short = c.Sha.Length >= 8 ? c.Sha[..8] : c.Sha,
message = c.Commit?.Message,
author = c.Commit?.Author?.Name,
author_email = c.Commit?.Author?.Email,
timestamp = c.Commit?.Author?.Date ?? c.Created,
}).ToList();
}
[McpServerTool]
[Description(
"Get full details of a single commit: message, author, stats, and per-file diff. " +
"Files are limited to max_files=50; when truncated=true, some files are omitted. " +
"Patch (diff text) is included per file — useful for code review or understanding specific changes. " +
"Use list_commits first to obtain a SHA.")]
public async Task<object> read_commit(
[Description("Repository owner.")] string owner,
[Description("Repository name.")] string repo,
[Description("Full or abbreviated commit SHA.")] string sha,
CancellationToken ct = default)
{
if (filter.IsBlocked($"{owner}/{repo}"))
throw new UnauthorizedAccessException($"Repo {owner}/{repo} is on the access blocklist.");
const int MaxFiles = 50;
var commit = await gitea.GetCommitAsync(owner, repo, sha, ct);
var files = commit.Files ?? [];
bool truncated = files.Count > MaxFiles;
if (truncated) files = files.Take(MaxFiles).ToList();
return new
{
sha = commit.Sha,
message = commit.Commit?.Message,
author = commit.Commit?.Author?.Name,
author_email = commit.Commit?.Author?.Email,
authored_at = commit.Commit?.Author?.Date,
committer = commit.Commit?.Committer?.Name,
committed_at = commit.Commit?.Committer?.Date ?? commit.Created,
stats = commit.Stats == null ? null : new
{
total = commit.Stats.Total,
additions = commit.Stats.Additions,
deletions = commit.Stats.Deletions,
},
files_truncated = truncated,
files = files.Select(f => new
{
filename = f.Filename,
status = f.Status,
additions = f.Additions,
deletions = f.Deletions,
changes = f.Changes,
patch = f.Patch,
}).ToList(),
};
}
}