gitea-mcp: 初次落地 Gitea MCP Server (.NET 10, V1 only-read)
Build Docker Image / build (push) Failing after 5m41s
Build Docker Image / deploy (push) Has been skipped

把 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>
This commit is contained in:
2026-05-06 01:32:42 +08:00
commit c7fa6aeb7f
38 changed files with 2675 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
**/.vs
**/.vscode
**/.idea
**/bin
**/obj
**/*.user
**/*.suo
.git
.gitea
.gitignore
.dockerignore
README.md
LICENSE
**/*.md
gitea-mcp.Tests/
+125
View File
@@ -0,0 +1,125 @@
name: Build Docker Image
on:
push:
branches: [main]
paths-ignore:
- '**.md'
- 'LICENSE'
- '.gitignore'
- '.dockerignore'
- '.gitea/workflows/sync-upstream.yml'
workflow_dispatch:
inputs:
branch:
description: '要打包的分支(仅手动触发生效)'
required: true
default: 'main'
tag:
description: '镜像 tag(留空则用 commit short hash'
required: false
default: ''
# 同一分支连续 push 只跑最新一个,旧 in-progress run 一起取消
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout target branch
uses: actions/checkout@v4
with:
ref: ${{ inputs.branch || github.ref_name }}
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
# 钉 v0.13.2runc 1.1.x,兼容 DSM 4.4.x 内核(不支持 openat2/fsmount
driver-opts: |
image=moby/buildkit:v0.13.2
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: git.zhengchentao.win
username: ${{ gitea.actor }}
password: ${{ secrets.PACKAGES_TOKEN }}
- name: Determine image tag
id: meta
run: |
if [ -n "${{ inputs.tag }}" ]; then
IMAGE_TAG="${{ inputs.tag }}"
else
IMAGE_TAG="$(git rev-parse --short HEAD)"
fi
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
echo "full_sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "==> Image tag: $IMAGE_TAG"
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
labels: |
org.opencontainers.image.source=https://git.zhengchentao.win/zhengchen.tao/gitea-mcp
org.opencontainers.image.revision=${{ steps.meta.outputs.full_sha }}
tags: |
git.zhengchentao.win/zhengchen.tao/gitea-mcp:${{ steps.meta.outputs.image_tag }}
git.zhengchentao.win/zhengchen.tao/gitea-mcp:latest
- name: Build summary
if: always()
run: |
{
echo "## Build Summary"
echo ""
echo "| 项 | 值 |"
echo "|---|---|"
echo "| 触发方式 | \`${{ github.event_name }}\` |"
echo "| 源分支 | \`${{ github.ref_name }}\` |"
echo "| Commit (full) | \`${{ steps.meta.outputs.full_sha }}\` |"
echo "| 镜像 tag | \`git.zhengchentao.win/zhengchen.tao/gitea-mcp:${{ steps.meta.outputs.image_tag }}\` + \`:latest\` |"
} >> "$GITHUB_STEP_SUMMARY"
deploy:
needs: build
runs-on: ubuntu-latest
steps:
# deploy job 是独立 runner,凭据不跨 job 继承,必须再 login 一次
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: git.zhengchentao.win
username: ${{ gitea.actor }}
password: ${{ secrets.PACKAGES_TOKEN }}
- name: Pull and restart gitea-mcp
env:
NAS_INFRA_TOKEN: ${{ secrets.NAS_INFRA_TOKEN }}
run: |
set -e
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
if [ -n "$NAS_INFRA_TOKEN" ]; then
CLONE_URL="https://x-access-token:${NAS_INFRA_TOKEN}@git.zhengchentao.win/dev/nas-infra.git"
else
CLONE_URL="https://git.zhengchentao.win/dev/nas-infra.git"
fi
git clone --depth 1 "$CLONE_URL" "$TMPDIR/nas-infra"
cd "$TMPDIR/nas-infra/gitea-mcp"
docker compose pull
docker compose up -d
sleep 3
docker compose ps
docker compose logs --tail=30 gitea-mcp
+36
View File
@@ -0,0 +1,36 @@
## .gitignore for .NET 10 project
# Build outputs
bin/
obj/
# User-specific files
*.user
*.suo
.vs/
# dotnet user-jwts (development JWT store)
Properties/launchSettings.json
# macOS
.DS_Store
# Environment files (never commit secrets)
.env
.env.*
!.env.example
# Logs
logs/
*.log
# NuGet
*.nupkg
.nuget/
# Rider / JetBrains
.idea/
# VS Code (allow settings but not per-user state)
.vscode/launch.json
.vscode/*.code-workspace
+68
View File
@@ -0,0 +1,68 @@
using GiteaMcp.Config;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
namespace GiteaMcp.Auth;
/// <summary>
/// JWT Bearer 验签配置,与 obsidian-mcp 同款 HS256 对称密钥方案。
/// ValidIssuer = https://auth.zhengchentao.winValidAudience = gitea。
/// 支持 Current + Previous 双密钥(轮换窗口)。
/// </summary>
public static class JwtBearerSetup
{
public static IServiceCollection AddGiteaJwtBearer(
this IServiceCollection services,
IConfiguration configuration)
{
var jwtOpts = configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>()
?? new JwtOptions();
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
// 关闭默认的入站 claim type 映射,否则 "sub"/"scope" 会被改写成
// ClaimTypes.NameIdentifier 之类的长 URI,下游 FindFirst("scope") 取不到。
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = jwtOpts.Issuer,
ValidateAudience = true,
ValidAudience = jwtOpts.Audience,
ValidateIssuerSigningKey = true,
// ToList 物化一次,避免每次验签重建 SymmetricSecurityKey。
IssuerSigningKeys = BuildSigningKeys(jwtOpts).ToList(),
ValidateLifetime = true,
// nas-auth 设计里允许 2 分钟时钟偏差
ClockSkew = TimeSpan.FromMinutes(2),
};
});
return services;
}
/// <summary>
/// 构建 Current + Previous 双密钥列表,供 SDK 依次尝试验签。
/// 轮换期间两把钥匙同时有效,过期的旧 Token 会被 ValidateLifetime 自然拦截。
/// Current 未配置直接抛错,避免容器静默以"任何 token 都不通过"的状态运行。
/// </summary>
private static IEnumerable<SecurityKey> BuildSigningKeys(JwtOptions opts)
{
if (string.IsNullOrWhiteSpace(opts.SigningKeyCurrent))
throw new InvalidOperationException(
"Jwt:SigningKeyCurrent 未配置,gitea-mcp 无法启动。" +
"请在 .env.shared 设置 JWT_SIGNING_KEY_CURRENT 与 nas-auth 共用。");
yield return new SymmetricSecurityKey(Encoding.UTF8.GetBytes(opts.SigningKeyCurrent));
if (!string.IsNullOrWhiteSpace(opts.SigningKeyPrevious))
yield return new SymmetricSecurityKey(Encoding.UTF8.GetBytes(opts.SigningKeyPrevious));
}
}
+59
View File
@@ -0,0 +1,59 @@
using Microsoft.AspNetCore.Authorization;
namespace GiteaMcp.Auth;
/// <summary>
/// 自定义 scope 授权策略:要求 JWT 的 scope claim 包含指定值。
/// 对应 RequireScope("read:gitea") policy。
/// </summary>
public static class ScopePolicies
{
public const string ReadGitea = "read:gitea";
public static IServiceCollection AddScopePolicies(this IServiceCollection services)
{
services.AddAuthorizationBuilder()
.AddPolicy(ReadGitea, policy =>
{
policy.RequireAuthenticatedUser();
policy.AddRequirements(new ScopeRequirement(ReadGitea));
});
services.AddSingleton<IAuthorizationHandler, ScopeRequirementHandler>();
return services;
}
}
/// <summary>
/// 授权要求:JWT scope claim 必须包含指定的 scope 字符串(空格分隔的多 scope 支持)。
/// </summary>
public class ScopeRequirement(string scope) : IAuthorizationRequirement
{
public string Scope { get; } = scope;
}
/// <summary>
/// 验证 scope claim。
/// nas-auth 签发的 JWT 里 scope 是单字符串(空格分隔),形如 "read:gitea" 或 "read:gitea write:gitea"。
/// 兼容部分实现把多 scope 拆成多个 claim 的形式(FindAll)。
/// OAuth 2.0 (RFC 6749 §3.3) 规定 scope 大小写敏感。
/// </summary>
public class ScopeRequirementHandler : AuthorizationHandler<ScopeRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ScopeRequirement requirement)
{
var scopes = context.User
.FindAll("scope")
.SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries))
.ToHashSet(StringComparer.Ordinal);
if (scopes.Contains(requirement.Scope))
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
+32
View File
@@ -0,0 +1,32 @@
namespace GiteaMcp.Config;
/// <summary>
/// Gitea 后端连接配置,通过 env / appsettings 注入。
/// 生产环境敏感字段(AdminPat)必须通过 .env.shared 注入,不要写进代码。
/// </summary>
public class GiteaOptions
{
public const string SectionName = "Gitea";
/// <summary>Gitea 根 URL,例如 https://git.zhengchentao.win(末尾无斜杠)</summary>
public string BaseUrl { get; set; } = "https://git.zhengchentao.win";
/// <summary>
/// Gitea Admin PAT(只读权限:read:repository / read:issue / read:user / read:organization / read:package)。
/// 生产环境从 env Gitea__AdminPat 注入,本地开发用 dotnet user-secrets。
/// 绝对不要 hardcode。
/// </summary>
public string AdminPat { get; set; } = string.Empty;
/// <summary>
/// 黑名单:逗号分隔的 owner/repo,格式如 "zhengchen.tao/secret-repo,org/internal"。
/// 黑名单内的 repo 不会出现在任何 Tool 的返回值里。默认空(全开放)。
/// </summary>
public string RepoBlacklist { get; set; } = string.Empty;
/// <summary>list_repos 的默认 limit(不传时使用)</summary>
public int DefaultLimit { get; set; } = 50;
/// <summary>read_file 的默认最大字节数(1MB</summary>
public int MaxFileBytes { get; set; } = 1 * 1024 * 1024;
}
+22
View File
@@ -0,0 +1,22 @@
namespace GiteaMcp.Config;
/// <summary>
/// JWT 验签配置,与 nas-auth / obsidian-mcp 共用同款 HS256 对称密钥。
/// ValidIssuer = auth.zhengchentao.winValidAudience = gitea。
/// </summary>
public class JwtOptions
{
public const string SectionName = "Jwt";
public string Issuer { get; set; } = "https://auth.zhengchentao.win";
public string Audience { get; set; } = "gitea";
/// <summary>当前签名密钥(HS256 对称密钥,base64 或原文均可,长度 >= 32 字节)</summary>
public string SigningKeyCurrent { get; set; } = string.Empty;
/// <summary>
/// 上一轮密钥(轮换窗口内保留,允许旧 Token 继续使用)。
/// 留空表示不存在旧密钥。
/// </summary>
public string SigningKeyPrevious { get; set; } = string.Empty;
}
+15
View File
@@ -0,0 +1,15 @@
namespace GiteaMcp.Config;
/// <summary>
/// /.well-known/oauth-authorization-server 端点返回的静态元数据,
/// 字段由 Mcp:OAuthDiscovery:* 配置项驱动。
/// </summary>
public class McpDiscoveryOptions
{
public const string SectionName = "Mcp:OAuthDiscovery";
public string Issuer { get; set; } = "https://auth.zhengchentao.win";
public string AuthorizationEndpoint { get; set; } = "https://auth.zhengchentao.win/authorize";
public string TokenEndpoint { get; set; } = "https://auth.zhengchentao.win/token";
public string RegistrationEndpoint { get; set; } = "https://auth.zhengchentao.win/register";
}
+40
View File
@@ -0,0 +1,40 @@
# ── Stage 1: build ──────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS builder
WORKDIR /src
# 先复制 csproj,单独 restore(利用层缓存)
COPY gitea-mcp.csproj .
RUN dotnet restore gitea-mcp.csproj
# 复制剩余源码并发布
COPY . .
RUN dotnet publish gitea-mcp.csproj \
-c Release \
-o /app/publish \
--no-restore
# ── Stage 2: runtime ────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
# OCI 标签(CI 会在 build-push 时注入 revision
LABEL org.opencontainers.image.title="gitea-mcp"
LABEL org.opencontainers.image.description="MCP server exposing Gitea REST API to Claude via nas-auth JWT"
LABEL org.opencontainers.image.source="https://git.zhengchentao.win/zhengchen.tao/gitea-mcp"
LABEL org.opencontainers.image.licenses="MIT"
WORKDIR /app
# 非 root 用户运行(最小权限)。
# 先建用户、再 COPY --chown,确保拷进来的文件归属正确(不能依赖默认 644 让 appuser 兜底读)。
RUN adduser --disabled-password --gecos "" appuser
COPY --from=builder --chown=appuser:appuser /app/publish .
USER appuser
# 容器内监听 0.0.0.0:8080,宿主机映射到 9092
ENV ASPNETCORE_URLS=http://0.0.0.0:8080
ENV ASPNETCORE_ENVIRONMENT=Production
EXPOSE 8080
ENTRYPOINT ["dotnet", "gitea-mcp.dll"]
+33
View File
@@ -0,0 +1,33 @@
using GiteaMcp.Config;
using Microsoft.Extensions.Options;
namespace GiteaMcp.Endpoints;
/// <summary>
/// OAuth 2.0 Authorization Server MetadataRFC 8414)端点。
/// Claude.ai 在接入 custom connector 时会先访问 /.well-known/oauth-authorization-server
/// 根据返回的元数据找到 nas-auth 的 authorize / token / register 端点。
/// </summary>
public static class DiscoveryEndpoints
{
public static IEndpointRouteBuilder MapDiscovery(this IEndpointRouteBuilder app)
{
app.MapGet("/.well-known/oauth-authorization-server", (IOptions<McpDiscoveryOptions> opts) =>
{
var o = opts.Value;
return Results.Ok(new
{
issuer = o.Issuer,
authorization_endpoint = o.AuthorizationEndpoint,
token_endpoint = o.TokenEndpoint,
registration_endpoint = o.RegistrationEndpoint,
response_types_supported = new[] { "code" },
grant_types_supported = new[] { "authorization_code", "refresh_token" },
code_challenge_methods_supported = new[] { "S256" },
scopes_supported = new[] { "read:gitea" },
});
});
return app;
}
}
+85
View File
@@ -0,0 +1,85 @@
using GiteaMcp.Auth;
using GiteaMcp.Config;
using GiteaMcp.Endpoints;
using GiteaMcp.Services;
using Microsoft.Extensions.Http.Resilience;
using System.Net;
using System.Net.Http.Headers;
var builder = WebApplication.CreateBuilder(args);
// ─── 配置绑定 ───────────────────────────────────────────────
builder.Services.Configure<GiteaOptions>(
builder.Configuration.GetSection(GiteaOptions.SectionName));
builder.Services.Configure<JwtOptions>(
builder.Configuration.GetSection(JwtOptions.SectionName));
builder.Services.Configure<McpDiscoveryOptions>(
builder.Configuration.GetSection(McpDiscoveryOptions.SectionName));
// ─── JWT Bearer + Scope Policy ─────────────────────────────
builder.Services.AddGiteaJwtBearer(builder.Configuration);
builder.Services.AddScopePolicies();
// ─── HTTP Context AccessorTool 里可选用,暂保留接口) ────────
builder.Services.AddHttpContextAccessor();
// ─── Gitea HTTP Client ─────────────────────────────────────
var giteaBaseUrl = builder.Configuration["Gitea:BaseUrl"]
?? "https://git.zhengchentao.win";
var giteaPat = builder.Configuration["Gitea:AdminPat"] ?? string.Empty;
builder.Services.AddHttpClient("gitea", client =>
{
// 确保 BaseAddress 末尾有斜杠(HttpClient 的规范)
var url = giteaBaseUrl.TrimEnd('/') + "/";
client.BaseAddress = new Uri(url);
// Gitea 推荐 "token <PAT>" 格式,比 Bearer 更稳
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("token", giteaPat);
client.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
// 单请求超时 30s
client.Timeout = TimeSpan.FromSeconds(30);
})
.AddStandardResilienceHandler(options =>
{
// 3 次重试,指数退避(Microsoft.Extensions.Http.Resilience 标准配置)
options.Retry.MaxRetryAttempts = 3;
options.Retry.BackoffType = Polly.DelayBackoffType.Exponential;
options.Retry.Delay = TimeSpan.FromSeconds(1);
options.Retry.UseJitter = true;
// 仅对 5xx / 429 / 网络错误重试;4xx 由 ShouldHandle 默认配置自动跳过
options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(90);
options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(30);
});
// ─── 业务服务 ──────────────────────────────────────────────
builder.Services.AddSingleton<GiteaRepoFilter>();
builder.Services.AddScoped<GiteaApiClient>();
// ─── MCP Server ────────────────────────────────────────────
builder.Services.AddMcpServer()
.WithHttpTransport() // Streamable HTTPClaude.ai custom connector 走这个)
.WithToolsFromAssembly(); // 自动扫描 [McpServerToolType]
// ─── Build ─────────────────────────────────────────────────
var app = builder.Build();
// ─── Middleware 顺序:认证 → 授权 → 路由 ────────────────────
app.UseAuthentication();
app.UseAuthorization();
// ─── Endpoints ─────────────────────────────────────────────
app.MapDiscovery();
// MCP 端点:要求通过 JWT 认证 + read:gitea scope
app.MapMcp("/mcp")
.RequireAuthorization(ScopePolicies.ReadGitea);
// 健康检查(Kubernetes / Docker healthcheck 用)
app.MapGet("/healthz", () => Results.Ok(new { status = "ok", timestamp = DateTimeOffset.UtcNow }));
app.Run();
+161
View File
@@ -0,0 +1,161 @@
# gitea-mcp
Read access to all Gitea repos (public + private, personal + org) via MCP.
OAuth via nas-auth (DCR + PKCE + JWT HS256), admin PAT held internally — Claude never touches the PAT.
## Architecture
```
Claude.ai (MCP client)
│ ① GET /.well-known/oauth-authorization-server
git-mcp.zhengchentao.win ←── this service
│ ② DCR + PKCE auth flow redirect
auth.zhengchentao.win ←── nas-auth
│ ③ JWT (aud=gitea, scope=read:gitea)
Claude.ai → Bearer JWT
│ ④ POST /mcp (MCP Streamable HTTP)
git-mcp.zhengchentao.win
│ Bearer <GITEA_ADMIN_PAT>
git.zhengchentao.win ←── Gitea
```
See [gitea-mcp 设计.md](../Coding/gitea-mcp/gitea-mcp%20设计.md) for full design rationale.
## Tools
| Tool | Description |
|------|-------------|
| `list_repos` | List all repos (personal + org, public + private) |
| `read_repo` | Repo metadata: topics, stars, default branch, mirror flag |
| `list_tree` | File tree at a ref (recursive optional, max 500 entries) |
| `read_file` | Raw file content (UTF-8, truncated at 1MB) |
| `search_code` | Code search via Gitea indexer (requires indexer enabled) |
| `list_branches` | Branch list + last commit per branch |
| `list_commits` | Recent commits with author + message |
| `read_commit` | Full commit details + per-file diff (max 50 files) |
| `list_issues` | Issues filtered by state (open/closed/all) |
| `read_issue` | Issue body + all comments |
| `list_pulls` | Pull requests filtered by state |
| `read_pull` | PR body + review comments + changed files |
| `list_orgs` | All organizations visible to admin token |
| `read_org` | Org metadata |
| `list_packages` | Packages in registry by owner (container/generic/npm/...) |
| `read_package` | Package version metadata |
| `list_workflow_runs` | Gitea Actions workflow run history |
| `read_run_log` | Run details + job list + log (truncated at 1MB) |
All tools require a valid JWT with `scope=read:gitea` issued by nas-auth.
## Configuration (env vars)
| Variable | Default | Description |
|----------|---------|-------------|
| `Gitea__BaseUrl` | `https://git.zhengchentao.win` | Gitea backend URL |
| `Gitea__AdminPat` | *(required)* | Gitea read-only PAT — see PAT Setup below |
| `Gitea__RepoBlacklist` | *(empty)* | Comma-separated `owner/repo` pairs to hide from Claude |
| `Gitea__DefaultLimit` | `50` | Default page size for list operations |
| `Gitea__MaxFileBytes` | `1048576` | Max file read size in bytes (1MB) |
| `Jwt__Issuer` | `https://auth.zhengchentao.win` | Expected JWT issuer |
| `Jwt__Audience` | `gitea` | Expected JWT audience |
| `Jwt__SigningKeyCurrent` | *(required)* | HS256 signing key (shared with nas-auth) |
| `Jwt__SigningKeyPrevious` | *(empty)* | Previous key for rotation window |
| `ASPNETCORE_ENVIRONMENT` | `Production` | Use `Development` locally |
All secrets come from `/volume1/docker/compose/.env.shared` on NAS — never hardcode them.
## Local Development
### 1. Restore and run
```bash
dotnet restore
dotnet run
# Listens on http://localhost:5000
```
### 2. Generate a dev JWT
Use `dotnet user-jwts` to sign a token without running nas-auth:
```bash
dotnet user-jwts create \
--issuer https://auth.zhengchentao.win \
--audience gitea \
--name tao \
--claim sub=tao \
--claim scope=read:gitea
```
Or use [jwt.io](https://jwt.io) with alg=HS256 and the key from `Jwt:SigningKeyCurrent`.
### 3. Test with MCP Inspector
```bash
npx @modelcontextprotocol/inspector
# Transport: Streamable HTTP
# URL: http://localhost:5000/mcp
# Bearer Token: <token from step 2>
```
### 4. Run unit tests
```bash
dotnet test gitea-mcp.Tests/
```
## PAT Setup (Gitea → Settings → Applications)
Generate a token with **only these scopes** (principle of least privilege):
- `read:repository`
- `read:organization`
- `read:package`
- `read:issue`
- `read:user`
Do NOT grant `write:*` or `admin:*` scopes.
Store the generated token in `.env.shared` as `GITEA_MCP_PAT=<token>`.
If the PAT is compromised: revoke in Gitea → generate new → update `.env.shared``docker compose up -d gitea-mcp`.
## Docker Compose (NAS deployment)
```yaml
# /volume1/docker/compose/gitea-mcp/docker-compose.yml
services:
gitea-mcp:
image: git.zhengchentao.win/zhengchen.tao/gitea-mcp:latest
container_name: gitea-mcp
restart: unless-stopped
ports:
- "9092:8080"
volumes:
- /volume1/docker/gitea-mcp/logs:/app/logs
environment:
- ASPNETCORE_ENVIRONMENT=Production
- Gitea__BaseUrl=https://git.zhengchentao.win
- Gitea__AdminPat=${GITEA_MCP_PAT}
- Gitea__RepoBlacklist=
- Jwt__Issuer=https://auth.zhengchentao.win
- Jwt__Audience=gitea
- Jwt__SigningKeyCurrent=${JWT_SIGNING_KEY_CURRENT}
- TZ=Asia/Shanghai
env_file:
- ../.env.shared
```
## Related Documents
- [gitea-mcp 设计](../Obsidian%20Vault/Coding/gitea-mcp/gitea-mcp%20设计.md)
- [MCP 实现指南](../Obsidian%20Vault/Coding/obsidian-mcp/MCP%20实现指南.md)
- [nas-auth 设计](../Obsidian%20Vault/Coding/nas-auth/nas-auth%20设计.md)
- [Gitea Actions Build-Deploy 模板](../Obsidian%20Vault/Coding/Gitea%20Actions%20Build-Deploy%20模板.md)
+295
View File
@@ -0,0 +1,295 @@
using GiteaMcp.Config;
using GiteaMcp.Services.Models;
using Microsoft.Extensions.Options;
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
namespace GiteaMcp.Services;
/// <summary>
/// Gitea REST API 封装。
/// 使用 HttpClientFactory 注入(named client "gitea"),统一带 admin PAT header。
/// 所有公开方法在 Gitea 返回错误时抛出结构化异常,不会静默返回空值。
/// </summary>
public class GiteaApiClient
{
private readonly HttpClient _http;
private readonly GiteaOptions _opts;
private readonly ILogger<GiteaApiClient> _logger;
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNameCaseInsensitive = true,
};
public GiteaApiClient(
IHttpClientFactory factory,
IOptions<GiteaOptions> opts,
ILogger<GiteaApiClient> logger)
{
_opts = opts.Value;
_logger = logger;
_http = factory.CreateClient("gitea");
}
// ───────────── Repos ─────────────
/// <summary>
/// 搜索所有仓库。owner 为 null 时搜索全部(含 org)。
/// visibility: "public" | "private" | "all"
/// </summary>
public async Task<List<GiteaRepo>> SearchReposAsync(
string? owner, string visibility, int limit, CancellationToken ct = default)
{
// Gitea /repos/search 支持 limit / private / uid / topic 等参数
var url = $"/api/v1/repos/search?limit={limit}&include_desc=true";
if (!string.IsNullOrWhiteSpace(owner))
url += $"&q={Uri.EscapeDataString(owner)}";
if (visibility == "private")
url += "&private=true";
else if (visibility == "public")
url += "&private=false";
// "all" 不加 private 参数,admin PAT 能看到所有
var result = await GetAsync<GiteaRepoSearchResult>(url, ct);
return result.Data;
}
/// <summary>读取单个仓库元数据</summary>
public Task<GiteaRepo> GetRepoAsync(string owner, string repo, CancellationToken ct = default)
=> GetAsync<GiteaRepo>($"/api/v1/repos/{owner}/{repo}", ct);
// ───────────── Tree & File ─────────────
/// <summary>获取仓库文件树</summary>
public async Task<GiteaTree> GetTreeAsync(
string owner, string repo, string @ref, bool recursive, CancellationToken ct = default)
{
var url = $"/api/v1/repos/{owner}/{repo}/git/trees/{Uri.EscapeDataString(@ref)}";
if (recursive) url += "?recursive=true";
return await GetAsync<GiteaTree>(url, ct);
}
/// <summary>
/// 读取文件原始内容(字节),支持 maxBytes 截断。
/// 返回 (content, truncated)。
/// </summary>
public async Task<(string Content, bool Truncated)> GetRawFileAsync(
string owner, string repo, string @ref, string path, int maxBytes, CancellationToken ct = default)
{
var url = $"/api/v1/repos/{owner}/{repo}/raw/{Uri.EscapeDataString(@ref)}/{path.TrimStart('/')}";
var response = await SendAsync(url, ct);
// 读最多 maxBytes + 1 字节,用来判断是否截断
using var stream = await response.Content.ReadAsStreamAsync(ct);
var buffer = new byte[maxBytes + 1];
var bytesRead = await stream.ReadAsync(buffer.AsMemory(), ct);
bool truncated = bytesRead > maxBytes;
var actual = truncated ? buffer[..maxBytes] : buffer[..bytesRead];
return (System.Text.Encoding.UTF8.GetString(actual), truncated);
}
// ───────────── Branches & Commits ─────────────
public Task<List<GiteaBranch>> GetBranchesAsync(
string owner, string repo, CancellationToken ct = default)
=> GetAsync<List<GiteaBranch>>($"/api/v1/repos/{owner}/{repo}/branches", ct);
public async Task<List<GiteaCommitSummary>> GetCommitsAsync(
string owner, string repo, string? @ref, string? since, int limit, CancellationToken ct = default)
{
var url = $"/api/v1/repos/{owner}/{repo}/commits?limit={limit}&stat=false&files=false&verification=false";
if (!string.IsNullOrWhiteSpace(@ref)) url += $"&sha={Uri.EscapeDataString(@ref)}";
if (!string.IsNullOrWhiteSpace(since)) url += $"&since={Uri.EscapeDataString(since)}";
return await GetAsync<List<GiteaCommitSummary>>(url, ct);
}
public Task<GiteaCommitFull> GetCommitAsync(
string owner, string repo, string sha, CancellationToken ct = default)
=> GetAsync<GiteaCommitFull>($"/api/v1/repos/{owner}/{repo}/git/commits/{sha}", ct);
// ───────────── Issues ─────────────
public Task<List<GiteaIssue>> GetIssuesAsync(
string owner, string repo, string state, int limit, CancellationToken ct = default)
=> GetAsync<List<GiteaIssue>>(
$"/api/v1/repos/{owner}/{repo}/issues?type=issues&state={state}&limit={limit}", ct);
public Task<GiteaIssue> GetIssueAsync(
string owner, string repo, int number, CancellationToken ct = default)
=> GetAsync<GiteaIssue>($"/api/v1/repos/{owner}/{repo}/issues/{number}", ct);
public Task<List<GiteaComment>> GetIssueCommentsAsync(
string owner, string repo, int number, CancellationToken ct = default)
=> GetAsync<List<GiteaComment>>($"/api/v1/repos/{owner}/{repo}/issues/{number}/comments", ct);
// ───────────── Pull Requests ─────────────
public Task<List<GiteaPullRequest>> GetPullsAsync(
string owner, string repo, string state, int limit, CancellationToken ct = default)
=> GetAsync<List<GiteaPullRequest>>(
$"/api/v1/repos/{owner}/{repo}/pulls?state={state}&limit={limit}", ct);
public Task<GiteaPullRequest> GetPullAsync(
string owner, string repo, int number, CancellationToken ct = default)
=> GetAsync<GiteaPullRequest>($"/api/v1/repos/{owner}/{repo}/pulls/{number}", ct);
public Task<List<GiteaComment>> GetPullCommentsAsync(
string owner, string repo, int number, CancellationToken ct = default)
=> GetAsync<List<GiteaComment>>($"/api/v1/repos/{owner}/{repo}/issues/{number}/comments", ct);
public Task<List<GiteaPrFile>> GetPullFilesAsync(
string owner, string repo, int number, CancellationToken ct = default)
=> GetAsync<List<GiteaPrFile>>($"/api/v1/repos/{owner}/{repo}/pulls/{number}/files", ct);
// ───────────── Orgs ─────────────
/// <summary>
/// 列出所有组织。优先 /orgs/search(任意 read PAT 即可);
/// 仅当带 admin scope 的 PAT 才能访问 /admin/orgs。本服务的 PAT 设计为只读,
/// 因此不走 /admin/* 端点。/orgs/search 不传 q 时返回所有可见 org。
/// </summary>
public async Task<List<GiteaOrg>> GetOrgsAsync(int limit, CancellationToken ct = default)
{
// /orgs/search 返回 { ok, data: [...] }
var resp = await GetAsync<GiteaOrgSearchResult>($"/api/v1/orgs/search?limit={limit}", ct);
return resp.Data;
}
public Task<GiteaOrg> GetOrgAsync(string name, CancellationToken ct = default)
=> GetAsync<GiteaOrg>($"/api/v1/orgs/{name}", ct);
// ───────────── Packages ─────────────
public Task<List<GiteaPackage>> GetPackagesAsync(
string owner, string? type, int limit, CancellationToken ct = default)
{
var url = $"/api/v1/packages/{owner}?limit={limit}";
if (!string.IsNullOrWhiteSpace(type)) url += $"&type={type}";
return GetAsync<List<GiteaPackage>>(url, ct);
}
public Task<GiteaPackage> GetPackageAsync(
string owner, string type, string name, string version, CancellationToken ct = default)
=> GetAsync<GiteaPackage>($"/api/v1/packages/{owner}/{type}/{Uri.EscapeDataString(name)}/{Uri.EscapeDataString(version)}", ct);
// ───────────── Actions ─────────────
public async Task<GiteaWorkflowRunList> GetWorkflowRunsAsync(
string owner, string repo, string? branch, string? status, int limit, CancellationToken ct = default)
{
var url = $"/api/v1/repos/{owner}/{repo}/actions/runs?limit={limit}";
if (!string.IsNullOrWhiteSpace(branch)) url += $"&branch={Uri.EscapeDataString(branch)}";
if (!string.IsNullOrWhiteSpace(status)) url += $"&status={status}";
return await GetAsync<GiteaWorkflowRunList>(url, ct);
}
public Task<GiteaWorkflowRun> GetWorkflowRunAsync(
string owner, string repo, long runId, CancellationToken ct = default)
=> GetAsync<GiteaWorkflowRun>($"/api/v1/repos/{owner}/{repo}/actions/runs/{runId}", ct);
public Task<GiteaWorkflowJobList> GetRunJobsAsync(
string owner, string repo, long runId, CancellationToken ct = default)
=> GetAsync<GiteaWorkflowJobList>($"/api/v1/repos/{owner}/{repo}/actions/runs/{runId}/jobs", ct);
/// <summary>
/// 获取 run/job 日志。Gitea Actions log API 返回 ZIP 或纯文本,
/// 这里最多读 maxBytes1MB)防爆内存。
/// </summary>
public async Task<string> GetRunLogAsync(
string owner, string repo, long runId, long? jobId, int maxBytes = 1024 * 1024,
CancellationToken ct = default)
{
var url = jobId.HasValue
? $"/api/v1/repos/{owner}/{repo}/actions/runs/{runId}/jobs/{jobId}/logs"
: $"/api/v1/repos/{owner}/{repo}/actions/runs/{runId}/logs";
try
{
var response = await SendAsync(url, ct);
using var stream = await response.Content.ReadAsStreamAsync(ct);
var buffer = new byte[maxBytes];
var bytesRead = await stream.ReadAsync(buffer.AsMemory(), ct);
var text = System.Text.Encoding.UTF8.GetString(buffer, 0, bytesRead);
return bytesRead == maxBytes
? text + "\n[...log truncated at 1MB...]"
: text;
}
catch (KeyNotFoundException)
{
// Actions log 可能还未生成(run 刚开始)
return "[Log not yet available]";
}
}
// ───────────── Code search ─────────────
/// <summary>
/// 代码搜索。需要 Gitea 启用 code indexerapp.ini [indexer] REPO_INDEXER_ENABLED = true)。
/// indexer 未启用时 Gitea 返回 404,调用方应降级并告知 Claude(不要 swallow 到空数组)。
/// </summary>
/// <remarks>
/// Gitea API
/// GET /api/v1/repos/search-code?q=&owner=&repo=&limit=
/// 未启用 indexer 时端点返回 404,由 EnsureSuccessAsync 抛 KeyNotFoundException
/// 上游 SearchTools 捕获此异常返回结构化 indexer-disabled 提示。
/// </remarks>
public async Task<GiteaCodeSearchResponse> SearchCodeAsync(
string query, string? owner, string? repo, int limit, CancellationToken ct = default)
{
var url = $"/api/v1/repos/search-code?q={Uri.EscapeDataString(query)}&limit={limit}";
if (!string.IsNullOrWhiteSpace(owner)) url += $"&owner={Uri.EscapeDataString(owner)}";
if (!string.IsNullOrWhiteSpace(repo)) url += $"&repo={Uri.EscapeDataString(repo)}";
return await GetAsync<GiteaCodeSearchResponse>(url, ct);
}
// ───────────── Private helpers ─────────────
private async Task<T> GetAsync<T>(string relativeUrl, CancellationToken ct)
{
var response = await SendAsync(relativeUrl, ct);
var json = await response.Content.ReadAsStringAsync(ct);
return JsonSerializer.Deserialize<T>(json, JsonOpts)
?? throw new InvalidOperationException($"Empty response from Gitea: {relativeUrl}");
}
private async Task<HttpResponseMessage> SendAsync(string relativeUrl, CancellationToken ct)
{
_logger.LogDebug("Gitea GET {Url}", relativeUrl);
var response = await _http.GetAsync(relativeUrl, ct);
await EnsureSuccessAsync(response, relativeUrl);
return response;
}
/// <summary>
/// 把 Gitea HTTP 错误码转换为语义清晰的异常。
/// 绝不 swallow 异常——Claude 需要看到真实错误原因。
/// </summary>
private static async Task EnsureSuccessAsync(HttpResponseMessage response, string url)
{
if (response.IsSuccessStatusCode) return;
var body = string.Empty;
try { body = await response.Content.ReadAsStringAsync(); } catch { }
throw response.StatusCode switch
{
HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden
=> new UnauthorizedAccessException(
$"Gitea PAT invalid or insufficient scope. URL={url}, Status={response.StatusCode}, Body={body}"),
HttpStatusCode.NotFound
=> new KeyNotFoundException(
$"Repo/Resource not found: {url}. Body={body}"),
>= HttpStatusCode.InternalServerError
=> new InvalidOperationException(
$"Gitea backend error ({(int)response.StatusCode}). URL={url}, Body={body}"),
_ => new HttpRequestException(
$"Gitea returned {(int)response.StatusCode}. URL={url}, Body={body}")
};
}
}
+45
View File
@@ -0,0 +1,45 @@
using GiteaMcp.Config;
using GiteaMcp.Services.Models;
using Microsoft.Extensions.Options;
namespace GiteaMcp.Services;
/// <summary>
/// 仓库黑名单过滤器。
/// 配置项 Gitea:RepoBlacklist 为逗号分隔的 "owner/repo" 列表,默认空(全开放)。
/// 所有返回仓库列表的 Tool 都经过此过滤器,防止意外暴露不希望 Claude 看到的仓库。
/// </summary>
public class GiteaRepoFilter
{
private readonly HashSet<string> _blacklist;
public GiteaRepoFilter(IOptions<GiteaOptions> opts)
{
var raw = opts.Value.RepoBlacklist ?? string.Empty;
// 解析 "owner/repo,owner2/repo2" 格式,大小写不敏感
_blacklist = raw
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => s.ToLowerInvariant())
.ToHashSet();
}
/// <summary>
/// 判断单个仓库是否在黑名单内。
/// full_name 格式为 "owner/repo"Gitea API 返回的 full_name 字段)。
/// </summary>
public bool IsBlocked(string fullName)
=> _blacklist.Contains(fullName.ToLowerInvariant());
/// <summary>
/// 从列表中过滤掉黑名单仓库,返回新列表(不修改原列表)。
/// </summary>
public List<GiteaRepo> Filter(List<GiteaRepo> repos)
{
if (_blacklist.Count == 0) return repos;
return repos.Where(r => !IsBlocked(r.FullName)).ToList();
}
/// <summary>当前黑名单项数,用于日志 / 测试断言</summary>
public int BlacklistCount => _blacklist.Count;
}
+98
View File
@@ -0,0 +1,98 @@
using System.Text.Json.Serialization;
namespace GiteaMcp.Services.Models;
/// <summary>Gitea commit 摘要(list_commits 使用)</summary>
public class GiteaCommitSummary
{
[JsonPropertyName("sha")]
public string Sha { get; set; } = string.Empty;
[JsonPropertyName("created")]
public DateTimeOffset Created { get; set; }
[JsonPropertyName("commit")]
public GiteaCommitDetail? Commit { get; set; }
[JsonPropertyName("author")]
public GiteaUser? Author { get; set; }
}
public class GiteaCommitDetail
{
[JsonPropertyName("message")]
public string Message { get; set; } = string.Empty;
[JsonPropertyName("author")]
public GiteaCommitSignature? Author { get; set; }
[JsonPropertyName("committer")]
public GiteaCommitSignature? Committer { get; set; }
}
public class GiteaCommitSignature
{
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("email")]
public string Email { get; set; } = string.Empty;
[JsonPropertyName("date")]
public DateTimeOffset Date { get; set; }
}
/// <summary>read_commit 使用的完整 commit(含 diff 文件列表)</summary>
public class GiteaCommitFull
{
[JsonPropertyName("sha")]
public string Sha { get; set; } = string.Empty;
[JsonPropertyName("created")]
public DateTimeOffset Created { get; set; }
[JsonPropertyName("commit")]
public GiteaCommitDetail? Commit { get; set; }
[JsonPropertyName("author")]
public GiteaUser? Author { get; set; }
[JsonPropertyName("files")]
public List<GiteaCommitFile>? Files { get; set; }
[JsonPropertyName("stats")]
public GiteaCommitStats? Stats { get; set; }
}
public class GiteaCommitFile
{
[JsonPropertyName("filename")]
public string Filename { get; set; } = string.Empty;
[JsonPropertyName("status")]
public string Status { get; set; } = string.Empty;
[JsonPropertyName("additions")]
public int Additions { get; set; }
[JsonPropertyName("deletions")]
public int Deletions { get; set; }
[JsonPropertyName("changes")]
public int Changes { get; set; }
[JsonPropertyName("patch")]
public string? Patch { get; set; }
}
public class GiteaCommitStats
{
[JsonPropertyName("total")]
public int Total { get; set; }
[JsonPropertyName("additions")]
public int Additions { get; set; }
[JsonPropertyName("deletions")]
public int Deletions { get; set; }
}
+69
View File
@@ -0,0 +1,69 @@
using System.Text.Json.Serialization;
namespace GiteaMcp.Services.Models;
public class GiteaIssue
{
[JsonPropertyName("number")]
public int Number { get; set; }
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
[JsonPropertyName("body")]
public string? Body { get; set; }
[JsonPropertyName("state")]
public string State { get; set; } = string.Empty;
[JsonPropertyName("html_url")]
public string HtmlUrl { get; set; } = string.Empty;
[JsonPropertyName("user")]
public GiteaUser? User { get; set; }
[JsonPropertyName("labels")]
public List<GiteaLabel>? Labels { get; set; }
[JsonPropertyName("assignees")]
public List<GiteaUser>? Assignees { get; set; }
[JsonPropertyName("created_at")]
public DateTimeOffset CreatedAt { get; set; }
[JsonPropertyName("updated_at")]
public DateTimeOffset UpdatedAt { get; set; }
[JsonPropertyName("closed_at")]
public DateTimeOffset? ClosedAt { get; set; }
[JsonPropertyName("comments")]
public int Comments { get; set; }
}
public class GiteaLabel
{
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("color")]
public string Color { get; set; } = string.Empty;
}
public class GiteaComment
{
[JsonPropertyName("id")]
public long Id { get; set; }
[JsonPropertyName("body")]
public string? Body { get; set; }
[JsonPropertyName("user")]
public GiteaUser? User { get; set; }
[JsonPropertyName("created_at")]
public DateTimeOffset CreatedAt { get; set; }
[JsonPropertyName("updated_at")]
public DateTimeOffset UpdatedAt { get; set; }
}
+43
View File
@@ -0,0 +1,43 @@
using System.Text.Json.Serialization;
namespace GiteaMcp.Services.Models;
public class GiteaOrg
{
[JsonPropertyName("id")]
public long Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("full_name")]
public string? FullName { get; set; }
[JsonPropertyName("description")]
public string? Description { get; set; }
[JsonPropertyName("website")]
public string? Website { get; set; }
[JsonPropertyName("location")]
public string? Location { get; set; }
[JsonPropertyName("avatar_url")]
public string? AvatarUrl { get; set; }
[JsonPropertyName("repo_admin_change_team_access")]
public bool RepoAdminChangeTeamAccess { get; set; }
[JsonPropertyName("visibility")]
public string? Visibility { get; set; }
}
/// <summary>/api/v1/orgs/search 的响应包装(顶层 { ok, data })。</summary>
public class GiteaOrgSearchResult
{
[JsonPropertyName("ok")]
public bool Ok { get; set; }
[JsonPropertyName("data")]
public List<GiteaOrg> Data { get; set; } = [];
}
+27
View File
@@ -0,0 +1,27 @@
using System.Text.Json.Serialization;
namespace GiteaMcp.Services.Models;
public class GiteaPackage
{
[JsonPropertyName("id")]
public long Id { get; set; }
[JsonPropertyName("owner")]
public GiteaUser? Owner { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
[JsonPropertyName("version")]
public string Version { get; set; } = string.Empty;
[JsonPropertyName("creator")]
public GiteaUser? Creator { get; set; }
[JsonPropertyName("created")]
public DateTimeOffset Created { get; set; }
}
+85
View File
@@ -0,0 +1,85 @@
using System.Text.Json.Serialization;
namespace GiteaMcp.Services.Models;
public class GiteaPullRequest
{
[JsonPropertyName("number")]
public int Number { get; set; }
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
[JsonPropertyName("body")]
public string? Body { get; set; }
[JsonPropertyName("state")]
public string State { get; set; } = string.Empty;
[JsonPropertyName("html_url")]
public string HtmlUrl { get; set; } = string.Empty;
[JsonPropertyName("user")]
public GiteaUser? User { get; set; }
[JsonPropertyName("head")]
public GiteaPrRef? Head { get; set; }
[JsonPropertyName("base")]
public GiteaPrRef? Base { get; set; }
[JsonPropertyName("merged")]
public bool Merged { get; set; }
[JsonPropertyName("mergeable")]
public bool? Mergeable { get; set; }
[JsonPropertyName("created_at")]
public DateTimeOffset CreatedAt { get; set; }
[JsonPropertyName("updated_at")]
public DateTimeOffset UpdatedAt { get; set; }
[JsonPropertyName("closed_at")]
public DateTimeOffset? ClosedAt { get; set; }
[JsonPropertyName("merged_at")]
public DateTimeOffset? MergedAt { get; set; }
[JsonPropertyName("labels")]
public List<GiteaLabel>? Labels { get; set; }
}
public class GiteaPrRef
{
[JsonPropertyName("label")]
public string? Label { get; set; }
[JsonPropertyName("ref")]
public string Ref { get; set; } = string.Empty;
[JsonPropertyName("sha")]
public string Sha { get; set; } = string.Empty;
[JsonPropertyName("repo")]
public GiteaRepo? Repo { get; set; }
}
/// <summary>PR 变更文件(read_pull 附带返回)</summary>
public class GiteaPrFile
{
[JsonPropertyName("filename")]
public string Filename { get; set; } = string.Empty;
[JsonPropertyName("status")]
public string Status { get; set; } = string.Empty;
[JsonPropertyName("additions")]
public int Additions { get; set; }
[JsonPropertyName("deletions")]
public int Deletions { get; set; }
[JsonPropertyName("changes")]
public int Changes { get; set; }
}
+80
View File
@@ -0,0 +1,80 @@
using System.Text.Json.Serialization;
namespace GiteaMcp.Services.Models;
/// <summary>Gitea 仓库元数据(list_repos / read_repo 使用)</summary>
public class GiteaRepo
{
[JsonPropertyName("id")]
public long Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("full_name")]
public string FullName { get; set; } = string.Empty;
[JsonPropertyName("description")]
public string? Description { get; set; }
[JsonPropertyName("private")]
public bool Private { get; set; }
[JsonPropertyName("fork")]
public bool Fork { get; set; }
[JsonPropertyName("mirror")]
public bool Mirror { get; set; }
[JsonPropertyName("archived")]
public bool Archived { get; set; }
[JsonPropertyName("html_url")]
public string HtmlUrl { get; set; } = string.Empty;
[JsonPropertyName("clone_url")]
public string CloneUrl { get; set; } = string.Empty;
[JsonPropertyName("default_branch")]
public string DefaultBranch { get; set; } = "main";
[JsonPropertyName("stars_count")]
public int StarsCount { get; set; }
[JsonPropertyName("forks_count")]
public int ForksCount { get; set; }
[JsonPropertyName("size")]
public long Size { get; set; }
[JsonPropertyName("website")]
public string? Website { get; set; }
[JsonPropertyName("topics")]
public List<string>? Topics { get; set; }
[JsonPropertyName("updated_at")]
public DateTimeOffset UpdatedAt { get; set; }
[JsonPropertyName("owner")]
public GiteaUser? Owner { get; set; }
}
public class GiteaUser
{
[JsonPropertyName("login")]
public string Login { get; set; } = string.Empty;
[JsonPropertyName("full_name")]
public string? FullName { get; set; }
}
/// <summary>搜索仓库的 API 响应包装</summary>
public class GiteaRepoSearchResult
{
[JsonPropertyName("data")]
public List<GiteaRepo> Data { get; set; } = [];
[JsonPropertyName("ok")]
public bool Ok { get; set; }
}
+50
View File
@@ -0,0 +1,50 @@
using System.Text.Json.Serialization;
namespace GiteaMcp.Services.Models;
/// <summary>代码搜索结果条目(search_code 使用)</summary>
public class GiteaSearchHit
{
/// <summary>仓库所有者</summary>
public string Owner { get; set; } = string.Empty;
/// <summary>仓库名称</summary>
public string Repo { get; set; } = string.Empty;
/// <summary>命中的文件路径(相对仓库根)</summary>
public string Path { get; set; } = string.Empty;
/// <summary>命中的行号(1-basedGitea 索引未启用时为 0</summary>
public int Line { get; set; }
/// <summary>命中行的前后上下文预览(Gitea 返回原始片段)</summary>
public string Preview { get; set; } = string.Empty;
}
/// <summary>Gitea code search API 返回的单条结果</summary>
public class GiteaCodeSearchResult
{
[JsonPropertyName("filename")]
public string Filename { get; set; } = string.Empty;
[JsonPropertyName("language")]
public string? Language { get; set; }
[JsonPropertyName("content")]
public string? Content { get; set; }
[JsonPropertyName("commit_id")]
public string? CommitId { get; set; }
[JsonPropertyName("repo")]
public GiteaRepo? Repo { get; set; }
}
public class GiteaCodeSearchResponse
{
[JsonPropertyName("ok")]
public bool Ok { get; set; }
[JsonPropertyName("data")]
public List<GiteaCodeSearchResult> Data { get; set; } = [];
}
+69
View File
@@ -0,0 +1,69 @@
using System.Text.Json.Serialization;
namespace GiteaMcp.Services.Models;
/// <summary>Git tree 中的单个条目(list_tree 使用)</summary>
public class GiteaTreeEntry
{
[JsonPropertyName("path")]
public string Path { get; set; } = string.Empty;
[JsonPropertyName("mode")]
public string? Mode { get; set; }
/// <summary>"blob"(文件)/ "tree"(目录)/ "commit"(子模块)</summary>
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
[JsonPropertyName("size")]
public long? Size { get; set; }
[JsonPropertyName("sha")]
public string? Sha { get; set; }
[JsonPropertyName("url")]
public string? Url { get; set; }
}
public class GiteaTree
{
[JsonPropertyName("sha")]
public string Sha { get; set; } = string.Empty;
[JsonPropertyName("url")]
public string? Url { get; set; }
[JsonPropertyName("tree")]
public List<GiteaTreeEntry> Tree { get; set; } = [];
[JsonPropertyName("truncated")]
public bool Truncated { get; set; }
}
/// <summary>分支信息(list_branches 使用)</summary>
public class GiteaBranch
{
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("commit")]
public GiteaBranchCommit? Commit { get; set; }
[JsonPropertyName("protected")]
public bool Protected { get; set; }
}
public class GiteaBranchCommit
{
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("message")]
public string? Message { get; set; }
[JsonPropertyName("timestamp")]
public DateTimeOffset Timestamp { get; set; }
[JsonPropertyName("author")]
public GiteaCommitSignature? Author { get; set; }
}
+84
View File
@@ -0,0 +1,84 @@
using System.Text.Json.Serialization;
namespace GiteaMcp.Services.Models;
public class GiteaWorkflowRun
{
[JsonPropertyName("id")]
public long Id { get; set; }
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("head_branch")]
public string? HeadBranch { get; set; }
[JsonPropertyName("head_sha")]
public string? HeadSha { get; set; }
[JsonPropertyName("event")]
public string? Event { get; set; }
[JsonPropertyName("status")]
public string? Status { get; set; }
[JsonPropertyName("conclusion")]
public string? Conclusion { get; set; }
[JsonPropertyName("workflow_id")]
public long WorkflowId { get; set; }
[JsonPropertyName("url")]
public string? Url { get; set; }
[JsonPropertyName("html_url")]
public string? HtmlUrl { get; set; }
[JsonPropertyName("created_at")]
public DateTimeOffset CreatedAt { get; set; }
[JsonPropertyName("updated_at")]
public DateTimeOffset UpdatedAt { get; set; }
[JsonPropertyName("actor")]
public GiteaUser? Actor { get; set; }
}
public class GiteaWorkflowRunList
{
[JsonPropertyName("workflow_runs")]
public List<GiteaWorkflowRun> WorkflowRuns { get; set; } = [];
[JsonPropertyName("total_count")]
public int TotalCount { get; set; }
}
public class GiteaWorkflowJob
{
[JsonPropertyName("id")]
public long Id { get; set; }
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("status")]
public string? Status { get; set; }
[JsonPropertyName("conclusion")]
public string? Conclusion { get; set; }
[JsonPropertyName("started_at")]
public DateTimeOffset? StartedAt { get; set; }
[JsonPropertyName("completed_at")]
public DateTimeOffset? CompletedAt { get; set; }
}
public class GiteaWorkflowJobList
{
[JsonPropertyName("workflow_jobs")]
public List<GiteaWorkflowJob> WorkflowJobs { get; set; } = [];
[JsonPropertyName("total_count")]
public int TotalCount { get; set; }
}
+108
View File
@@ -0,0 +1,108 @@
using GiteaMcp.Services;
using ModelContextProtocol.Server;
using System.ComponentModel;
namespace GiteaMcp.Tools;
/// <summary>Gitea Actions Toollist_workflow_runs / read_run_log</summary>
[McpServerToolType]
public class ActionsTools(
GiteaApiClient gitea,
GiteaRepoFilter filter)
{
[McpServerTool]
[Description(
"List recent Gitea Actions workflow runs for a repository. " +
"Filter by branch name or run status (queued/in_progress/success/failure/cancelled/skipped). " +
"Returns: run ID, workflow name, triggering event, branch, status, conclusion, actor, and timestamps. " +
"Use read_run_log to get the full log output of a specific run or job.")]
public async Task<object> list_workflow_runs(
[Description("Repository owner.")] string owner,
[Description("Repository name.")] string repo,
[Description("Filter by branch name. Optional.")] string? branch = null,
[Description("Filter by status: 'queued', 'in_progress', 'success', 'failure', 'cancelled', 'skipped'. Optional.")] string? status = null,
[Description("Max runs 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 result = await gitea.GetWorkflowRunsAsync(owner, repo, branch, status, lim, ct);
return new
{
total = result.TotalCount,
runs = result.WorkflowRuns.Select(r => new
{
id = r.Id,
name = r.Name,
@event = r.Event,
branch = r.HeadBranch,
sha = r.HeadSha,
status = r.Status,
conclusion = r.Conclusion,
actor = r.Actor?.Login,
html_url = r.HtmlUrl,
created_at = r.CreatedAt,
updated_at = r.UpdatedAt,
}).ToList(),
};
}
[McpServerTool]
[Description(
"Get detailed info and log output for a specific Gitea Actions workflow run. " +
"Returns: run overview (status, conclusion, timing) + all jobs with their status. " +
"Log output is truncated to 1MB; long logs will have '[...log truncated...]' appended. " +
"When job_id is provided, fetches that specific job's log; otherwise fetches the run-level log. " +
"Use list_workflow_runs to find the run_id.")]
public async Task<object> read_run_log(
[Description("Repository owner.")] string owner,
[Description("Repository name.")] string repo,
[Description("Workflow run ID (from list_workflow_runs).")] long run_id,
[Description("Specific job ID to fetch logs for. Omit to get the run-level log.")] long? job_id = null,
CancellationToken ct = default)
{
if (filter.IsBlocked($"{owner}/{repo}"))
throw new UnauthorizedAccessException($"Repo {owner}/{repo} is on the access blocklist.");
var runTask = gitea.GetWorkflowRunAsync(owner, repo, run_id, ct);
var jobsTask = gitea.GetRunJobsAsync(owner, repo, run_id, ct);
var logTask = gitea.GetRunLogAsync(owner, repo, run_id, job_id, ct: ct);
await Task.WhenAll(runTask, jobsTask, logTask);
var run = await runTask;
var jobList = await jobsTask;
var log = await logTask;
return new
{
run = new
{
id = run.Id,
name = run.Name,
@event = run.Event,
branch = run.HeadBranch,
sha = run.HeadSha,
status = run.Status,
conclusion = run.Conclusion,
actor = run.Actor?.Login,
html_url = run.HtmlUrl,
created_at = run.CreatedAt,
updated_at = run.UpdatedAt,
},
jobs = jobList.WorkflowJobs.Select(j => new
{
id = j.Id,
name = j.Name,
status = j.Status,
conclusion = j.Conclusion,
started_at = j.StartedAt,
completed_at = j.CompletedAt,
}).ToList(),
log,
};
}
}
+121
View File
@@ -0,0 +1,121 @@
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(),
};
}
}
+89
View File
@@ -0,0 +1,89 @@
using GiteaMcp.Services;
using ModelContextProtocol.Server;
using System.ComponentModel;
namespace GiteaMcp.Tools;
/// <summary>Issue Toollist_issues / read_issue</summary>
[McpServerToolType]
public class IssueTools(
GiteaApiClient gitea,
GiteaRepoFilter filter)
{
[McpServerTool]
[Description(
"List issues in a Gitea repository. " +
"state can be 'open' (default), 'closed', or 'all'. " +
"Returns: issue number, title, state, labels, assignees, created_at, comment count, and URL. " +
"Use read_issue to get the full body and comments of a specific issue.")]
public async Task<object> list_issues(
[Description("Repository owner.")] string owner,
[Description("Repository name.")] string repo,
[Description("Filter by state: 'open', 'closed', or 'all'. Default 'open'.")] string? state = null,
[Description("Max issues 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 st = state ?? "open";
var lim = Math.Min(limit ?? 30, 50);
var issues = await gitea.GetIssuesAsync(owner, repo, st, lim, ct);
return issues.Select(i => new
{
number = i.Number,
title = i.Title,
state = i.State,
html_url = i.HtmlUrl,
author = i.User?.Login,
labels = i.Labels?.Select(l => l.Name).ToList() ?? [],
assignees = i.Assignees?.Select(a => a.Login).ToList() ?? [],
comments = i.Comments,
created_at = i.CreatedAt,
updated_at = i.UpdatedAt,
closed_at = i.ClosedAt,
}).ToList();
}
[McpServerTool]
[Description(
"Get the full body and all comments of a specific Gitea issue. " +
"Use list_issues first to find the issue number. " +
"Returns: title, body (Markdown), state, labels, and all comment texts with authors and timestamps.")]
public async Task<object> read_issue(
[Description("Repository owner.")] string owner,
[Description("Repository name.")] string repo,
[Description("Issue number (integer, e.g. 42).")] int number,
CancellationToken ct = default)
{
if (filter.IsBlocked($"{owner}/{repo}"))
throw new UnauthorizedAccessException($"Repo {owner}/{repo} is on the access blocklist.");
var issue = await gitea.GetIssueAsync(owner, repo, number, ct);
var comments = await gitea.GetIssueCommentsAsync(owner, repo, number, ct);
return new
{
number = issue.Number,
title = issue.Title,
body = issue.Body,
state = issue.State,
html_url = issue.HtmlUrl,
author = issue.User?.Login,
labels = issue.Labels?.Select(l => l.Name).ToList() ?? [],
assignees = issue.Assignees?.Select(a => a.Login).ToList() ?? [],
created_at = issue.CreatedAt,
updated_at = issue.UpdatedAt,
closed_at = issue.ClosedAt,
comments = comments.Select(c => new
{
id = c.Id,
author = c.User?.Login,
body = c.Body,
created_at = c.CreatedAt,
updated_at = c.UpdatedAt,
}).ToList(),
};
}
}
+56
View File
@@ -0,0 +1,56 @@
using GiteaMcp.Services;
using ModelContextProtocol.Server;
using System.ComponentModel;
namespace GiteaMcp.Tools;
/// <summary>Organization Toollist_orgs / read_org</summary>
[McpServerToolType]
public class OrgTools(GiteaApiClient gitea)
{
[McpServerTool]
[Description(
"List all Gitea organizations visible to the admin token. " +
"Returns: org name, full name, description, visibility, and website. " +
"Use read_org to get repo/member counts for a specific org. " +
"Tip: after listing orgs, call list_repos with owner=<org_name> to see that org's repos.")]
public async Task<object> list_orgs(
[Description("Max orgs to return. Default 50.")] int? limit = null,
CancellationToken ct = default)
{
var lim = Math.Min(limit ?? 50, 50);
var orgs = await gitea.GetOrgsAsync(lim, ct);
return orgs.Select(o => new
{
name = o.Name,
full_name = o.FullName,
description = o.Description,
website = o.Website,
location = o.Location,
visibility = o.Visibility,
}).ToList();
}
[McpServerTool]
[Description(
"Get detailed information about a specific Gitea organization: " +
"description, website, location, and visibility setting. " +
"Use list_repos with owner=<name> to see the org's repositories.")]
public async Task<object> read_org(
[Description("Organization name (login).")] string name,
CancellationToken ct = default)
{
var org = await gitea.GetOrgAsync(name, ct);
return new
{
name = org.Name,
full_name = org.FullName,
description = org.Description,
website = org.Website,
location = org.Location,
visibility = org.Visibility,
};
}
}
+61
View File
@@ -0,0 +1,61 @@
using GiteaMcp.Services;
using ModelContextProtocol.Server;
using System.ComponentModel;
namespace GiteaMcp.Tools;
/// <summary>Package Registry Toollist_packages / read_package</summary>
[McpServerToolType]
public class PackageTools(GiteaApiClient gitea)
{
[McpServerTool]
[Description(
"List packages in Gitea's built-in package registry for a given owner (user or org). " +
"Supports all package types: container (Docker/OCI), generic, npm, pypi, maven, nuget, etc. " +
"Returns: package name, type, version, creator, and creation date. " +
"Use read_package to get detailed metadata (e.g. OCI manifest digest for container images).")]
public async Task<object> list_packages(
[Description("Owner (user login or org name) whose packages to list.")] string owner,
[Description("Package type filter: 'container', 'generic', 'npm', 'pypi', 'maven', 'nuget', etc. Omit for all types.")] string? type = null,
[Description("Max packages to return. Default 50.")] int? limit = null,
CancellationToken ct = default)
{
var lim = Math.Min(limit ?? 50, 50);
var packages = await gitea.GetPackagesAsync(owner, type, lim, ct);
return packages.Select(p => new
{
owner = p.Owner?.Login,
name = p.Name,
type = p.Type,
version = p.Version,
creator = p.Creator?.Login,
created = p.Created,
}).ToList();
}
[McpServerTool]
[Description(
"Get metadata for a specific package version in Gitea's package registry. " +
"For container images, this includes the OCI manifest digest and image details. " +
"Use list_packages to discover available packages and their versions.")]
public async Task<object> read_package(
[Description("Owner (user login or org name).")] string owner,
[Description("Package type: 'container', 'generic', 'npm', etc.")] string type,
[Description("Package name.")] string name,
[Description("Package version string (e.g. 'latest', '1.0.0', 'main').")] string version,
CancellationToken ct = default)
{
var pkg = await gitea.GetPackageAsync(owner, type, name, version, ct);
return new
{
owner = pkg.Owner?.Login,
name = pkg.Name,
type = pkg.Type,
version = pkg.Version,
creator = pkg.Creator?.Login,
created = pkg.Created,
};
}
}
+110
View File
@@ -0,0 +1,110 @@
using GiteaMcp.Services;
using ModelContextProtocol.Server;
using System.ComponentModel;
namespace GiteaMcp.Tools;
/// <summary>Pull Request Toollist_pulls / read_pull</summary>
[McpServerToolType]
public class PullTools(
GiteaApiClient gitea,
GiteaRepoFilter filter)
{
[McpServerTool]
[Description(
"List pull requests in a Gitea repository. " +
"state: 'open' (default), 'closed', or 'all'. " +
"Returns: PR number, title, state, head/base branches, merged status, labels, and URL. " +
"Use read_pull to get the full body, review comments, and changed files list.")]
public async Task<object> list_pulls(
[Description("Repository owner.")] string owner,
[Description("Repository name.")] string repo,
[Description("Filter by state: 'open', 'closed', or 'all'. Default 'open'.")] string? state = null,
[Description("Max PRs 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 st = state ?? "open";
var lim = Math.Min(limit ?? 30, 50);
var pulls = await gitea.GetPullsAsync(owner, repo, st, lim, ct);
return pulls.Select(p => new
{
number = p.Number,
title = p.Title,
state = p.State,
html_url = p.HtmlUrl,
author = p.User?.Login,
head = p.Head?.Ref,
base_branch = p.Base?.Ref,
merged = p.Merged,
labels = p.Labels?.Select(l => l.Name).ToList() ?? [],
created_at = p.CreatedAt,
updated_at = p.UpdatedAt,
merged_at = p.MergedAt,
}).ToList();
}
[McpServerTool]
[Description(
"Get full details of a specific pull request: body, review comments, and list of changed files. " +
"Changed files include filename, status (added/modified/removed), and line counts. " +
"Use list_pulls first to find the PR number.")]
public async Task<object> read_pull(
[Description("Repository owner.")] string owner,
[Description("Repository name.")] string repo,
[Description("Pull request number (integer).")] int number,
CancellationToken ct = default)
{
if (filter.IsBlocked($"{owner}/{repo}"))
throw new UnauthorizedAccessException($"Repo {owner}/{repo} is on the access blocklist.");
// 并行拉取 PR 主体、评论、变更文件
var pullTask = gitea.GetPullAsync(owner, repo, number, ct);
var commentsTask = gitea.GetPullCommentsAsync(owner, repo, number, ct);
var filesTask = gitea.GetPullFilesAsync(owner, repo, number, ct);
await Task.WhenAll(pullTask, commentsTask, filesTask);
var pull = await pullTask;
var comments = await commentsTask;
var files = await filesTask;
return new
{
number = pull.Number,
title = pull.Title,
body = pull.Body,
state = pull.State,
html_url = pull.HtmlUrl,
author = pull.User?.Login,
head = pull.Head?.Ref,
head_sha = pull.Head?.Sha,
base_branch = pull.Base?.Ref,
merged = pull.Merged,
mergeable = pull.Mergeable,
labels = pull.Labels?.Select(l => l.Name).ToList() ?? [],
created_at = pull.CreatedAt,
updated_at = pull.UpdatedAt,
closed_at = pull.ClosedAt,
merged_at = pull.MergedAt,
comments = comments.Select(c => new
{
id = c.Id,
author = c.User?.Login,
body = c.Body,
created_at = c.CreatedAt,
}).ToList(),
changed_files = files.Select(f => new
{
filename = f.Filename,
status = f.Status,
additions = f.Additions,
deletions = f.Deletions,
changes = f.Changes,
}).ToList(),
};
}
}
+89
View File
@@ -0,0 +1,89 @@
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_repos / read_repo</summary>
[McpServerToolType]
public class RepoTools(
GiteaApiClient gitea,
GiteaRepoFilter filter,
IOptions<GiteaOptions> opts)
{
private readonly GiteaOptions _opts = opts.Value;
[McpServerTool]
[Description(
"List Gitea repositories accessible to the admin token. " +
"When owner is omitted, returns ALL repos across personal account and all orgs (up to limit). " +
"Use visibility='private' to show only private repos, 'public' for public-only. " +
"Returns: owner, repo name, description, default_branch, private flag, size (KB), updated_at, html_url. " +
"Tip: call this first to discover available repos before using read_repo or read_file.")]
public async Task<List<object>> list_repos(
[Description("Filter by owner login (user or org name). Omit to list everything.")] string? owner = null,
[Description("Visibility filter: 'public', 'private', or 'all' (default 'all').")] string? visibility = null,
[Description("Max number of repos to return. Default 50, max 50.")] int? limit = null,
CancellationToken ct = default)
{
var vis = visibility ?? "all";
var lim = Math.Min(limit ?? _opts.DefaultLimit, 50);
var repos = await gitea.SearchReposAsync(owner, vis, lim, ct);
repos = filter.Filter(repos);
return repos.Select(r => (object)new
{
owner = r.Owner?.Login ?? r.FullName.Split('/').FirstOrDefault() ?? "",
repo = r.Name,
full_name = r.FullName,
description = r.Description,
default_branch = r.DefaultBranch,
@private = r.Private,
mirror = r.Mirror,
archived = r.Archived,
size_kb = r.Size,
updated_at = r.UpdatedAt,
html_url = r.HtmlUrl,
}).ToList();
}
[McpServerTool]
[Description(
"Get detailed metadata for a single Gitea repository: topics, homepage, default branch, " +
"stars, forks, archived status, mirror flag, size, and description. " +
"Use this after list_repos to inspect a specific repo before reading files or commits.")]
public async Task<object> read_repo(
[Description("Repository owner (user login or org name).")] string owner,
[Description("Repository name (without owner prefix).")] string repo,
CancellationToken ct = default)
{
if (filter.IsBlocked($"{owner}/{repo}"))
throw new UnauthorizedAccessException($"Repo {owner}/{repo} is on the access blocklist.");
var r = await gitea.GetRepoAsync(owner, repo, ct);
return new
{
owner = r.Owner?.Login ?? owner,
repo = r.Name,
full_name = r.FullName,
description = r.Description,
default_branch = r.DefaultBranch,
@private = r.Private,
fork = r.Fork,
mirror = r.Mirror,
archived = r.Archived,
stars = r.StarsCount,
forks = r.ForksCount,
size_kb = r.Size,
website = r.Website,
topics = r.Topics ?? [],
html_url = r.HtmlUrl,
clone_url = r.CloneUrl,
updated_at = r.UpdatedAt,
};
}
}
+75
View File
@@ -0,0 +1,75 @@
using GiteaMcp.Services;
using GiteaMcp.Services.Models;
using ModelContextProtocol.Server;
using System.ComponentModel;
namespace GiteaMcp.Tools;
/// <summary>代码搜索 Toolsearch_code</summary>
[McpServerToolType]
public class SearchTools(
GiteaApiClient gitea,
GiteaRepoFilter filter)
{
[McpServerTool]
[Description(
"Search code across Gitea repositories using Gitea's built-in code indexer. " +
"Requires Gitea to have REPO_INDEXER_ENABLED=true in app.ini. " +
"If the indexer is not enabled, returns an explanatory error instead of silently returning empty. " +
"Scope: when owner+repo are provided, searches only that repo; otherwise searches all accessible repos. " +
"Returns: owner, repo, file path, line number, and a preview snippet of the matching line. " +
"Limit: max 50 results. For large codebases, narrow with owner or repo.")]
public async Task<object> search_code(
[Description("Search query string. Supports keywords; no regex.")] string query,
[Description("Restrict search to this owner (user login or org name). Optional.")] string? owner = null,
[Description("Restrict search to this repo name (requires owner). Optional.")] string? repo = null,
[Description("Max number of results. Default 50.")] int? limit = null,
CancellationToken ct = default)
{
var lim = Math.Min(limit ?? 50, 50);
// 检查是否需要过滤黑名单
if (!string.IsNullOrWhiteSpace(owner) && !string.IsNullOrWhiteSpace(repo)
&& filter.IsBlocked($"{owner}/{repo}"))
{
throw new UnauthorizedAccessException($"Repo {owner}/{repo} is on the access blocklist.");
}
// 调 Gitea code search 端点(仅在 [indexer] REPO_INDEXER_ENABLED=true 时可用)。
// indexer 未启用 → API 返回 404GiteaApiClient.EnsureSuccessAsync 抛 KeyNotFoundException
// 这里捕获后返回结构化降级提示,避免 swallow 到空数组让 Claude 误以为"搜不到"。
try
{
var codeResult = await gitea.SearchCodeAsync(query, owner, repo, lim, ct);
var hits = codeResult.Data
.Where(d => d.Repo == null || !filter.IsBlocked(d.Repo.FullName))
.Take(lim)
.Select(d => (object)new
{
owner = d.Repo?.Owner?.Login ?? "",
repo = d.Repo?.Name ?? "",
path = d.Filename,
// Gitea code search 不返回精确行号,前端可在 preview 里自行定位
line = 0,
preview = d.Content?.Length > 200 ? d.Content[..200] + "..." : d.Content ?? "",
})
.ToList();
return new { ok = true, results = hits };
}
catch (KeyNotFoundException)
{
// indexer 未启用 → 404;返回结构化提示,不要 swallow 成空 results
return new
{
ok = false,
error = "indexer_disabled",
notice = "Gitea code search endpoint returned 404. " +
"Enable the code indexer by setting [indexer] REPO_INDEXER_ENABLED=true in app.ini and restart Gitea. " +
"Workaround: use list_tree + read_file to navigate files manually.",
results = Array.Empty<object>(),
};
}
}
}
+104
View File
@@ -0,0 +1,104 @@
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,
};
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft.AspNetCore": "Warning",
"GiteaMcp": "Information"
}
},
"Gitea": {
"BaseUrl": "https://git.zhengchentao.win"
},
"Jwt": {
"Issuer": "https://auth.zhengchentao.win",
"Audience": "gitea"
},
"Mcp": {
"OAuthDiscovery": {
"Issuer": "https://auth.zhengchentao.win",
"AuthorizationEndpoint": "https://auth.zhengchentao.win/authorize",
"TokenEndpoint": "https://auth.zhengchentao.win/token",
"RegistrationEndpoint": "https://auth.zhengchentao.win/register"
}
}
}
+31
View File
@@ -0,0 +1,31 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"GiteaMcp": "Debug"
}
},
"AllowedHosts": "*",
"Gitea": {
"BaseUrl": "https://git.zhengchentao.win",
"AdminPat": "",
"RepoBlacklist": "",
"DefaultLimit": 50,
"MaxFileBytes": 1048576
},
"Jwt": {
"Issuer": "https://auth.zhengchentao.win",
"Audience": "gitea",
"SigningKeyCurrent": "",
"SigningKeyPrevious": ""
},
"Mcp": {
"OAuthDiscovery": {
"Issuer": "https://auth.zhengchentao.win",
"AuthorizationEndpoint": "https://auth.zhengchentao.win/authorize",
"TokenEndpoint": "https://auth.zhengchentao.win/token",
"RegistrationEndpoint": "https://auth.zhengchentao.win/register"
}
}
}
+111
View File
@@ -0,0 +1,111 @@
using GiteaMcp.Config;
using GiteaMcp.Services;
using GiteaMcp.Services.Models;
using Microsoft.Extensions.Options;
using Xunit;
namespace GiteaMcp.Tests;
/// <summary>
/// GiteaRepoFilter 单元测试。
/// 验证黑名单过滤的核心行为:精确匹配、大小写不敏感、空黑名单全通过。
/// </summary>
public class GiteaRepoFilterTests
{
private static GiteaRepoFilter CreateFilter(string blacklist) =>
new(Options.Create(new GiteaOptions { RepoBlacklist = blacklist }));
private static GiteaRepo MakeRepo(string owner, string name) => new()
{
Name = name,
FullName = $"{owner}/{name}",
Owner = new GiteaUser { Login = owner },
};
[Fact]
public void EmptyBlacklist_AllowsEverything()
{
var filter = CreateFilter("");
var repos = new List<GiteaRepo>
{
MakeRepo("alice", "repo-a"),
MakeRepo("org", "private-repo"),
};
var result = filter.Filter(repos);
Assert.Equal(2, result.Count);
Assert.Equal(0, filter.BlacklistCount);
}
[Fact]
public void SingleEntry_BlocksExactMatch()
{
var filter = CreateFilter("alice/secret-repo");
var repos = new List<GiteaRepo>
{
MakeRepo("alice", "secret-repo"),
MakeRepo("alice", "public-repo"),
};
var result = filter.Filter(repos);
Assert.Single(result);
Assert.Equal("public-repo", result[0].Name);
}
[Fact]
public void MultipleEntries_CommaSeparated()
{
var filter = CreateFilter("alice/repo-a, org/internal , bob/secret");
var repos = new List<GiteaRepo>
{
MakeRepo("alice", "repo-a"),
MakeRepo("org", "internal"),
MakeRepo("bob", "secret"),
MakeRepo("bob", "public"),
};
var result = filter.Filter(repos);
Assert.Single(result);
Assert.Equal("public", result[0].Name);
}
[Fact]
public void IsBlocked_CaseInsensitive()
{
var filter = CreateFilter("Alice/Secret-Repo");
// 大写 owner / 混合大小写 repo 都应该被屏蔽
Assert.True(filter.IsBlocked("alice/secret-repo"));
Assert.True(filter.IsBlocked("ALICE/SECRET-REPO"));
Assert.False(filter.IsBlocked("alice/other-repo"));
}
[Fact]
public void Filter_DoesNotMutateOriginalList()
{
var filter = CreateFilter("alice/repo-a");
var original = new List<GiteaRepo>
{
MakeRepo("alice", "repo-a"),
MakeRepo("alice", "repo-b"),
};
var originalCount = original.Count;
var filtered = filter.Filter(original);
// 原列表不应被修改
Assert.Equal(originalCount, original.Count);
Assert.Single(filtered);
}
[Fact]
public void IsBlocked_ReturnsFalse_ForNonBlockedRepo()
{
var filter = CreateFilter("alice/secret");
Assert.False(filter.IsBlocked("alice/public"));
Assert.False(filter.IsBlocked("bob/anything"));
}
}
+24
View File
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<RootNamespace>GiteaMcp.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
<PackageReference Include="xunit" Version="2.*" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.*">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\gitea-mcp.csproj" />
</ItemGroup>
</Project>
+32
View File
@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>GiteaMcp</RootNamespace>
<AssemblyName>gitea-mcp</AssemblyName>
</PropertyGroup>
<!-- 排除 Tests 子目录,避免 SDK.Web 的通配符 glob 把测试文件包进主项目 -->
<ItemGroup>
<Compile Remove="gitea-mcp.Tests/**" />
<Content Remove="gitea-mcp.Tests/**" />
<EmbeddedResource Remove="gitea-mcp.Tests/**" />
<None Remove="gitea-mcp.Tests/**" />
</ItemGroup>
<ItemGroup>
<!-- MCP SDKStreamable HTTP transport + ASP.NET Core 集成 -->
<PackageReference Include="ModelContextProtocol" Version="0.*" />
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="0.*" />
<!-- JWT Bearer 认证(与 nas-auth 同款 HS256 -->
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.*" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.*" />
<!-- HttpClient 弹性策略(retry + backoff -->
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.*" />
</ItemGroup>
</Project>
+4
View File
@@ -0,0 +1,4 @@
<Solution>
<Project Path="gitea-mcp.csproj" />
<Project Path="gitea-mcp.Tests/gitea-mcp.Tests.csproj" />
</Solution>