commit c7fa6aeb7ff0f5baba26cd19f841b657b726d98a Author: Zhengchen Tao Date: Wed May 6 01:32:42 2026 +0800 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 (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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2f4752a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +**/.vs +**/.vscode +**/.idea +**/bin +**/obj +**/*.user +**/*.suo +.git +.gitea +.gitignore +.dockerignore +README.md +LICENSE +**/*.md +gitea-mcp.Tests/ diff --git a/.gitea/workflows/build-image.yml b/.gitea/workflows/build-image.yml new file mode 100644 index 0000000..a83c911 --- /dev/null +++ b/.gitea/workflows/build-image.yml @@ -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.2:runc 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..073b11e --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Auth/JwtBearerSetup.cs b/Auth/JwtBearerSetup.cs new file mode 100644 index 0000000..50738e6 --- /dev/null +++ b/Auth/JwtBearerSetup.cs @@ -0,0 +1,68 @@ +using GiteaMcp.Config; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using System.Text; + +namespace GiteaMcp.Auth; + +/// +/// JWT Bearer 验签配置,与 obsidian-mcp 同款 HS256 对称密钥方案。 +/// ValidIssuer = https://auth.zhengchentao.win,ValidAudience = gitea。 +/// 支持 Current + Previous 双密钥(轮换窗口)。 +/// +public static class JwtBearerSetup +{ + public static IServiceCollection AddGiteaJwtBearer( + this IServiceCollection services, + IConfiguration configuration) + { + var jwtOpts = configuration.GetSection(JwtOptions.SectionName).Get() + ?? 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; + } + + /// + /// 构建 Current + Previous 双密钥列表,供 SDK 依次尝试验签。 + /// 轮换期间两把钥匙同时有效,过期的旧 Token 会被 ValidateLifetime 自然拦截。 + /// Current 未配置直接抛错,避免容器静默以"任何 token 都不通过"的状态运行。 + /// + private static IEnumerable 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)); + } +} diff --git a/Auth/ScopePolicies.cs b/Auth/ScopePolicies.cs new file mode 100644 index 0000000..60ba005 --- /dev/null +++ b/Auth/ScopePolicies.cs @@ -0,0 +1,59 @@ +using Microsoft.AspNetCore.Authorization; + +namespace GiteaMcp.Auth; + +/// +/// 自定义 scope 授权策略:要求 JWT 的 scope claim 包含指定值。 +/// 对应 RequireScope("read:gitea") policy。 +/// +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(); + return services; + } +} + +/// +/// 授权要求:JWT scope claim 必须包含指定的 scope 字符串(空格分隔的多 scope 支持)。 +/// +public class ScopeRequirement(string scope) : IAuthorizationRequirement +{ + public string Scope { get; } = scope; +} + +/// +/// 验证 scope claim。 +/// nas-auth 签发的 JWT 里 scope 是单字符串(空格分隔),形如 "read:gitea" 或 "read:gitea write:gitea"。 +/// 兼容部分实现把多 scope 拆成多个 claim 的形式(FindAll)。 +/// OAuth 2.0 (RFC 6749 §3.3) 规定 scope 大小写敏感。 +/// +public class ScopeRequirementHandler : AuthorizationHandler +{ + 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; + } +} diff --git a/Config/GiteaOptions.cs b/Config/GiteaOptions.cs new file mode 100644 index 0000000..5cba5e4 --- /dev/null +++ b/Config/GiteaOptions.cs @@ -0,0 +1,32 @@ +namespace GiteaMcp.Config; + +/// +/// Gitea 后端连接配置,通过 env / appsettings 注入。 +/// 生产环境敏感字段(AdminPat)必须通过 .env.shared 注入,不要写进代码。 +/// +public class GiteaOptions +{ + public const string SectionName = "Gitea"; + + /// Gitea 根 URL,例如 https://git.zhengchentao.win(末尾无斜杠) + public string BaseUrl { get; set; } = "https://git.zhengchentao.win"; + + /// + /// Gitea Admin PAT(只读权限:read:repository / read:issue / read:user / read:organization / read:package)。 + /// 生产环境从 env Gitea__AdminPat 注入,本地开发用 dotnet user-secrets。 + /// 绝对不要 hardcode。 + /// + public string AdminPat { get; set; } = string.Empty; + + /// + /// 黑名单:逗号分隔的 owner/repo,格式如 "zhengchen.tao/secret-repo,org/internal"。 + /// 黑名单内的 repo 不会出现在任何 Tool 的返回值里。默认空(全开放)。 + /// + public string RepoBlacklist { get; set; } = string.Empty; + + /// list_repos 的默认 limit(不传时使用) + public int DefaultLimit { get; set; } = 50; + + /// read_file 的默认最大字节数(1MB) + public int MaxFileBytes { get; set; } = 1 * 1024 * 1024; +} diff --git a/Config/JwtOptions.cs b/Config/JwtOptions.cs new file mode 100644 index 0000000..b7c4b8a --- /dev/null +++ b/Config/JwtOptions.cs @@ -0,0 +1,22 @@ +namespace GiteaMcp.Config; + +/// +/// JWT 验签配置,与 nas-auth / obsidian-mcp 共用同款 HS256 对称密钥。 +/// ValidIssuer = auth.zhengchentao.win,ValidAudience = gitea。 +/// +public class JwtOptions +{ + public const string SectionName = "Jwt"; + + public string Issuer { get; set; } = "https://auth.zhengchentao.win"; + public string Audience { get; set; } = "gitea"; + + /// 当前签名密钥(HS256 对称密钥,base64 或原文均可,长度 >= 32 字节) + public string SigningKeyCurrent { get; set; } = string.Empty; + + /// + /// 上一轮密钥(轮换窗口内保留,允许旧 Token 继续使用)。 + /// 留空表示不存在旧密钥。 + /// + public string SigningKeyPrevious { get; set; } = string.Empty; +} diff --git a/Config/McpDiscoveryOptions.cs b/Config/McpDiscoveryOptions.cs new file mode 100644 index 0000000..ffaef1f --- /dev/null +++ b/Config/McpDiscoveryOptions.cs @@ -0,0 +1,15 @@ +namespace GiteaMcp.Config; + +/// +/// /.well-known/oauth-authorization-server 端点返回的静态元数据, +/// 字段由 Mcp:OAuthDiscovery:* 配置项驱动。 +/// +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"; +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..817f6f7 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Endpoints/DiscoveryEndpoints.cs b/Endpoints/DiscoveryEndpoints.cs new file mode 100644 index 0000000..c28d5e9 --- /dev/null +++ b/Endpoints/DiscoveryEndpoints.cs @@ -0,0 +1,33 @@ +using GiteaMcp.Config; +using Microsoft.Extensions.Options; + +namespace GiteaMcp.Endpoints; + +/// +/// OAuth 2.0 Authorization Server Metadata(RFC 8414)端点。 +/// Claude.ai 在接入 custom connector 时会先访问 /.well-known/oauth-authorization-server, +/// 根据返回的元数据找到 nas-auth 的 authorize / token / register 端点。 +/// +public static class DiscoveryEndpoints +{ + public static IEndpointRouteBuilder MapDiscovery(this IEndpointRouteBuilder app) + { + app.MapGet("/.well-known/oauth-authorization-server", (IOptions 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; + } +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..9610bf1 --- /dev/null +++ b/Program.cs @@ -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( + builder.Configuration.GetSection(GiteaOptions.SectionName)); +builder.Services.Configure( + builder.Configuration.GetSection(JwtOptions.SectionName)); +builder.Services.Configure( + builder.Configuration.GetSection(McpDiscoveryOptions.SectionName)); + +// ─── JWT Bearer + Scope Policy ───────────────────────────── +builder.Services.AddGiteaJwtBearer(builder.Configuration); +builder.Services.AddScopePolicies(); + +// ─── HTTP Context Accessor(Tool 里可选用,暂保留接口) ──────── +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 " 格式,比 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(); +builder.Services.AddScoped(); + +// ─── MCP Server ──────────────────────────────────────────── +builder.Services.AddMcpServer() + .WithHttpTransport() // Streamable HTTP(Claude.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(); diff --git a/README.md b/README.md new file mode 100644 index 0000000..2f83b5e --- /dev/null +++ b/README.md @@ -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 + ↓ + 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: +``` + +### 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=`. + +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) diff --git a/Services/GiteaApiClient.cs b/Services/GiteaApiClient.cs new file mode 100644 index 0000000..3873ada --- /dev/null +++ b/Services/GiteaApiClient.cs @@ -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; + +/// +/// Gitea REST API 封装。 +/// 使用 HttpClientFactory 注入(named client "gitea"),统一带 admin PAT header。 +/// 所有公开方法在 Gitea 返回错误时抛出结构化异常,不会静默返回空值。 +/// +public class GiteaApiClient +{ + private readonly HttpClient _http; + private readonly GiteaOptions _opts; + private readonly ILogger _logger; + + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNameCaseInsensitive = true, + }; + + public GiteaApiClient( + IHttpClientFactory factory, + IOptions opts, + ILogger logger) + { + _opts = opts.Value; + _logger = logger; + _http = factory.CreateClient("gitea"); + } + + // ───────────── Repos ───────────── + + /// + /// 搜索所有仓库。owner 为 null 时搜索全部(含 org)。 + /// visibility: "public" | "private" | "all" + /// + public async Task> 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(url, ct); + return result.Data; + } + + /// 读取单个仓库元数据 + public Task GetRepoAsync(string owner, string repo, CancellationToken ct = default) + => GetAsync($"/api/v1/repos/{owner}/{repo}", ct); + + // ───────────── Tree & File ───────────── + + /// 获取仓库文件树 + public async Task 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(url, ct); + } + + /// + /// 读取文件原始内容(字节),支持 maxBytes 截断。 + /// 返回 (content, truncated)。 + /// + 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> GetBranchesAsync( + string owner, string repo, CancellationToken ct = default) + => GetAsync>($"/api/v1/repos/{owner}/{repo}/branches", ct); + + public async Task> 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>(url, ct); + } + + public Task GetCommitAsync( + string owner, string repo, string sha, CancellationToken ct = default) + => GetAsync($"/api/v1/repos/{owner}/{repo}/git/commits/{sha}", ct); + + // ───────────── Issues ───────────── + + public Task> GetIssuesAsync( + string owner, string repo, string state, int limit, CancellationToken ct = default) + => GetAsync>( + $"/api/v1/repos/{owner}/{repo}/issues?type=issues&state={state}&limit={limit}", ct); + + public Task GetIssueAsync( + string owner, string repo, int number, CancellationToken ct = default) + => GetAsync($"/api/v1/repos/{owner}/{repo}/issues/{number}", ct); + + public Task> GetIssueCommentsAsync( + string owner, string repo, int number, CancellationToken ct = default) + => GetAsync>($"/api/v1/repos/{owner}/{repo}/issues/{number}/comments", ct); + + // ───────────── Pull Requests ───────────── + + public Task> GetPullsAsync( + string owner, string repo, string state, int limit, CancellationToken ct = default) + => GetAsync>( + $"/api/v1/repos/{owner}/{repo}/pulls?state={state}&limit={limit}", ct); + + public Task GetPullAsync( + string owner, string repo, int number, CancellationToken ct = default) + => GetAsync($"/api/v1/repos/{owner}/{repo}/pulls/{number}", ct); + + public Task> GetPullCommentsAsync( + string owner, string repo, int number, CancellationToken ct = default) + => GetAsync>($"/api/v1/repos/{owner}/{repo}/issues/{number}/comments", ct); + + public Task> GetPullFilesAsync( + string owner, string repo, int number, CancellationToken ct = default) + => GetAsync>($"/api/v1/repos/{owner}/{repo}/pulls/{number}/files", ct); + + // ───────────── Orgs ───────────── + + /// + /// 列出所有组织。优先 /orgs/search(任意 read PAT 即可); + /// 仅当带 admin scope 的 PAT 才能访问 /admin/orgs。本服务的 PAT 设计为只读, + /// 因此不走 /admin/* 端点。/orgs/search 不传 q 时返回所有可见 org。 + /// + public async Task> GetOrgsAsync(int limit, CancellationToken ct = default) + { + // /orgs/search 返回 { ok, data: [...] } + var resp = await GetAsync($"/api/v1/orgs/search?limit={limit}", ct); + return resp.Data; + } + + public Task GetOrgAsync(string name, CancellationToken ct = default) + => GetAsync($"/api/v1/orgs/{name}", ct); + + // ───────────── Packages ───────────── + + public Task> 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>(url, ct); + } + + public Task GetPackageAsync( + string owner, string type, string name, string version, CancellationToken ct = default) + => GetAsync($"/api/v1/packages/{owner}/{type}/{Uri.EscapeDataString(name)}/{Uri.EscapeDataString(version)}", ct); + + // ───────────── Actions ───────────── + + public async Task 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(url, ct); + } + + public Task GetWorkflowRunAsync( + string owner, string repo, long runId, CancellationToken ct = default) + => GetAsync($"/api/v1/repos/{owner}/{repo}/actions/runs/{runId}", ct); + + public Task GetRunJobsAsync( + string owner, string repo, long runId, CancellationToken ct = default) + => GetAsync($"/api/v1/repos/{owner}/{repo}/actions/runs/{runId}/jobs", ct); + + /// + /// 获取 run/job 日志。Gitea Actions log API 返回 ZIP 或纯文本, + /// 这里最多读 maxBytes(1MB)防爆内存。 + /// + public async Task 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 ───────────── + + /// + /// 代码搜索。需要 Gitea 启用 code indexer(app.ini [indexer] REPO_INDEXER_ENABLED = true)。 + /// indexer 未启用时 Gitea 返回 404,调用方应降级并告知 Claude(不要 swallow 到空数组)。 + /// + /// + /// Gitea API: + /// GET /api/v1/repos/search-code?q=&owner=&repo=&limit= + /// 未启用 indexer 时端点返回 404,由 EnsureSuccessAsync 抛 KeyNotFoundException, + /// 上游 SearchTools 捕获此异常返回结构化 indexer-disabled 提示。 + /// + public async Task 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(url, ct); + } + + // ───────────── Private helpers ───────────── + + private async Task GetAsync(string relativeUrl, CancellationToken ct) + { + var response = await SendAsync(relativeUrl, ct); + var json = await response.Content.ReadAsStringAsync(ct); + return JsonSerializer.Deserialize(json, JsonOpts) + ?? throw new InvalidOperationException($"Empty response from Gitea: {relativeUrl}"); + } + + private async Task SendAsync(string relativeUrl, CancellationToken ct) + { + _logger.LogDebug("Gitea GET {Url}", relativeUrl); + var response = await _http.GetAsync(relativeUrl, ct); + await EnsureSuccessAsync(response, relativeUrl); + return response; + } + + /// + /// 把 Gitea HTTP 错误码转换为语义清晰的异常。 + /// 绝不 swallow 异常——Claude 需要看到真实错误原因。 + /// + 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}") + }; + } +} diff --git a/Services/GiteaRepoFilter.cs b/Services/GiteaRepoFilter.cs new file mode 100644 index 0000000..4f2c5ac --- /dev/null +++ b/Services/GiteaRepoFilter.cs @@ -0,0 +1,45 @@ +using GiteaMcp.Config; +using GiteaMcp.Services.Models; +using Microsoft.Extensions.Options; + +namespace GiteaMcp.Services; + +/// +/// 仓库黑名单过滤器。 +/// 配置项 Gitea:RepoBlacklist 为逗号分隔的 "owner/repo" 列表,默认空(全开放)。 +/// 所有返回仓库列表的 Tool 都经过此过滤器,防止意外暴露不希望 Claude 看到的仓库。 +/// +public class GiteaRepoFilter +{ + private readonly HashSet _blacklist; + + public GiteaRepoFilter(IOptions opts) + { + var raw = opts.Value.RepoBlacklist ?? string.Empty; + + // 解析 "owner/repo,owner2/repo2" 格式,大小写不敏感 + _blacklist = raw + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(s => s.ToLowerInvariant()) + .ToHashSet(); + } + + /// + /// 判断单个仓库是否在黑名单内。 + /// full_name 格式为 "owner/repo"(Gitea API 返回的 full_name 字段)。 + /// + public bool IsBlocked(string fullName) + => _blacklist.Contains(fullName.ToLowerInvariant()); + + /// + /// 从列表中过滤掉黑名单仓库,返回新列表(不修改原列表)。 + /// + public List Filter(List repos) + { + if (_blacklist.Count == 0) return repos; + return repos.Where(r => !IsBlocked(r.FullName)).ToList(); + } + + /// 当前黑名单项数,用于日志 / 测试断言 + public int BlacklistCount => _blacklist.Count; +} diff --git a/Services/Models/GiteaCommit.cs b/Services/Models/GiteaCommit.cs new file mode 100644 index 0000000..9c2b177 --- /dev/null +++ b/Services/Models/GiteaCommit.cs @@ -0,0 +1,98 @@ +using System.Text.Json.Serialization; + +namespace GiteaMcp.Services.Models; + +/// Gitea commit 摘要(list_commits 使用) +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; } +} + +/// read_commit 使用的完整 commit(含 diff 文件列表) +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? 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; } +} diff --git a/Services/Models/GiteaIssue.cs b/Services/Models/GiteaIssue.cs new file mode 100644 index 0000000..c01de47 --- /dev/null +++ b/Services/Models/GiteaIssue.cs @@ -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? Labels { get; set; } + + [JsonPropertyName("assignees")] + public List? 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; } +} diff --git a/Services/Models/GiteaOrg.cs b/Services/Models/GiteaOrg.cs new file mode 100644 index 0000000..4ebee43 --- /dev/null +++ b/Services/Models/GiteaOrg.cs @@ -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; } +} + +/// /api/v1/orgs/search 的响应包装(顶层 { ok, data })。 +public class GiteaOrgSearchResult +{ + [JsonPropertyName("ok")] + public bool Ok { get; set; } + + [JsonPropertyName("data")] + public List Data { get; set; } = []; +} diff --git a/Services/Models/GiteaPackage.cs b/Services/Models/GiteaPackage.cs new file mode 100644 index 0000000..c58555f --- /dev/null +++ b/Services/Models/GiteaPackage.cs @@ -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; } +} diff --git a/Services/Models/GiteaPullRequest.cs b/Services/Models/GiteaPullRequest.cs new file mode 100644 index 0000000..586bf55 --- /dev/null +++ b/Services/Models/GiteaPullRequest.cs @@ -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? 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; } +} + +/// PR 变更文件(read_pull 附带返回) +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; } +} diff --git a/Services/Models/GiteaRepo.cs b/Services/Models/GiteaRepo.cs new file mode 100644 index 0000000..7c19442 --- /dev/null +++ b/Services/Models/GiteaRepo.cs @@ -0,0 +1,80 @@ +using System.Text.Json.Serialization; + +namespace GiteaMcp.Services.Models; + +/// Gitea 仓库元数据(list_repos / read_repo 使用) +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? 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; } +} + +/// 搜索仓库的 API 响应包装 +public class GiteaRepoSearchResult +{ + [JsonPropertyName("data")] + public List Data { get; set; } = []; + + [JsonPropertyName("ok")] + public bool Ok { get; set; } +} diff --git a/Services/Models/GiteaSearchHit.cs b/Services/Models/GiteaSearchHit.cs new file mode 100644 index 0000000..84b3e1e --- /dev/null +++ b/Services/Models/GiteaSearchHit.cs @@ -0,0 +1,50 @@ +using System.Text.Json.Serialization; + +namespace GiteaMcp.Services.Models; + +/// 代码搜索结果条目(search_code 使用) +public class GiteaSearchHit +{ + /// 仓库所有者 + public string Owner { get; set; } = string.Empty; + + /// 仓库名称 + public string Repo { get; set; } = string.Empty; + + /// 命中的文件路径(相对仓库根) + public string Path { get; set; } = string.Empty; + + /// 命中的行号(1-based,Gitea 索引未启用时为 0) + public int Line { get; set; } + + /// 命中行的前后上下文预览(Gitea 返回原始片段) + public string Preview { get; set; } = string.Empty; +} + +/// Gitea code search API 返回的单条结果 +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 Data { get; set; } = []; +} diff --git a/Services/Models/GiteaTreeEntry.cs b/Services/Models/GiteaTreeEntry.cs new file mode 100644 index 0000000..24846a3 --- /dev/null +++ b/Services/Models/GiteaTreeEntry.cs @@ -0,0 +1,69 @@ +using System.Text.Json.Serialization; + +namespace GiteaMcp.Services.Models; + +/// Git tree 中的单个条目(list_tree 使用) +public class GiteaTreeEntry +{ + [JsonPropertyName("path")] + public string Path { get; set; } = string.Empty; + + [JsonPropertyName("mode")] + public string? Mode { get; set; } + + /// "blob"(文件)/ "tree"(目录)/ "commit"(子模块) + [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 Tree { get; set; } = []; + + [JsonPropertyName("truncated")] + public bool Truncated { get; set; } +} + +/// 分支信息(list_branches 使用) +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; } +} diff --git a/Services/Models/GiteaWorkflowRun.cs b/Services/Models/GiteaWorkflowRun.cs new file mode 100644 index 0000000..7ae7650 --- /dev/null +++ b/Services/Models/GiteaWorkflowRun.cs @@ -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 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 WorkflowJobs { get; set; } = []; + + [JsonPropertyName("total_count")] + public int TotalCount { get; set; } +} diff --git a/Tools/ActionsTools.cs b/Tools/ActionsTools.cs new file mode 100644 index 0000000..b4c701c --- /dev/null +++ b/Tools/ActionsTools.cs @@ -0,0 +1,108 @@ +using GiteaMcp.Services; +using ModelContextProtocol.Server; +using System.ComponentModel; + +namespace GiteaMcp.Tools; + +/// Gitea Actions Tool:list_workflow_runs / read_run_log +[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 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 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, + }; + } +} diff --git a/Tools/BranchCommitTools.cs b/Tools/BranchCommitTools.cs new file mode 100644 index 0000000..da9e968 --- /dev/null +++ b/Tools/BranchCommitTools.cs @@ -0,0 +1,121 @@ +using GiteaMcp.Services; +using ModelContextProtocol.Server; +using System.ComponentModel; + +namespace GiteaMcp.Tools; + +/// 分支与 commit Tool:list_branches / list_commits / read_commit +[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 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 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 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(), + }; + } +} diff --git a/Tools/IssueTools.cs b/Tools/IssueTools.cs new file mode 100644 index 0000000..f82eed2 --- /dev/null +++ b/Tools/IssueTools.cs @@ -0,0 +1,89 @@ +using GiteaMcp.Services; +using ModelContextProtocol.Server; +using System.ComponentModel; + +namespace GiteaMcp.Tools; + +/// Issue Tool:list_issues / read_issue +[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 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 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(), + }; + } +} diff --git a/Tools/OrgTools.cs b/Tools/OrgTools.cs new file mode 100644 index 0000000..d158380 --- /dev/null +++ b/Tools/OrgTools.cs @@ -0,0 +1,56 @@ +using GiteaMcp.Services; +using ModelContextProtocol.Server; +using System.ComponentModel; + +namespace GiteaMcp.Tools; + +/// Organization Tool:list_orgs / read_org +[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= to see that org's repos.")] + public async Task 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= to see the org's repositories.")] + public async Task 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, + }; + } +} diff --git a/Tools/PackageTools.cs b/Tools/PackageTools.cs new file mode 100644 index 0000000..57da0c9 --- /dev/null +++ b/Tools/PackageTools.cs @@ -0,0 +1,61 @@ +using GiteaMcp.Services; +using ModelContextProtocol.Server; +using System.ComponentModel; + +namespace GiteaMcp.Tools; + +/// Package Registry Tool:list_packages / read_package +[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 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 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, + }; + } +} diff --git a/Tools/PullTools.cs b/Tools/PullTools.cs new file mode 100644 index 0000000..f387b66 --- /dev/null +++ b/Tools/PullTools.cs @@ -0,0 +1,110 @@ +using GiteaMcp.Services; +using ModelContextProtocol.Server; +using System.ComponentModel; + +namespace GiteaMcp.Tools; + +/// Pull Request Tool:list_pulls / read_pull +[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 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 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(), + }; + } +} diff --git a/Tools/RepoTools.cs b/Tools/RepoTools.cs new file mode 100644 index 0000000..5af4271 --- /dev/null +++ b/Tools/RepoTools.cs @@ -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; + +/// 仓库级别 Tool:list_repos / read_repo +[McpServerToolType] +public class RepoTools( + GiteaApiClient gitea, + GiteaRepoFilter filter, + IOptions 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_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 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, + }; + } +} diff --git a/Tools/SearchTools.cs b/Tools/SearchTools.cs new file mode 100644 index 0000000..ac8f303 --- /dev/null +++ b/Tools/SearchTools.cs @@ -0,0 +1,75 @@ +using GiteaMcp.Services; +using GiteaMcp.Services.Models; +using ModelContextProtocol.Server; +using System.ComponentModel; + +namespace GiteaMcp.Tools; + +/// 代码搜索 Tool:search_code +[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 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 返回 404,GiteaApiClient.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(), + }; + } + } +} diff --git a/Tools/TreeTools.cs b/Tools/TreeTools.cs new file mode 100644 index 0000000..09c4db9 --- /dev/null +++ b/Tools/TreeTools.cs @@ -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; + +/// 文件树与文件内容 Tool:list_tree / read_file +[McpServerToolType] +public class TreeTools( + GiteaApiClient gitea, + GiteaRepoFilter filter, + IOptions 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 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 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, + }; + } +} diff --git a/appsettings.Production.json b/appsettings.Production.json new file mode 100644 index 0000000..f885eb1 --- /dev/null +++ b/appsettings.Production.json @@ -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" + } + } +} diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..8b7710d --- /dev/null +++ b/appsettings.json @@ -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" + } + } +} diff --git a/gitea-mcp.Tests/GiteaRepoFilterTests.cs b/gitea-mcp.Tests/GiteaRepoFilterTests.cs new file mode 100644 index 0000000..a709e45 --- /dev/null +++ b/gitea-mcp.Tests/GiteaRepoFilterTests.cs @@ -0,0 +1,111 @@ +using GiteaMcp.Config; +using GiteaMcp.Services; +using GiteaMcp.Services.Models; +using Microsoft.Extensions.Options; +using Xunit; + +namespace GiteaMcp.Tests; + +/// +/// GiteaRepoFilter 单元测试。 +/// 验证黑名单过滤的核心行为:精确匹配、大小写不敏感、空黑名单全通过。 +/// +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 + { + 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 + { + 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 + { + 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 + { + 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")); + } +} diff --git a/gitea-mcp.Tests/gitea-mcp.Tests.csproj b/gitea-mcp.Tests/gitea-mcp.Tests.csproj new file mode 100644 index 0000000..0540d9a --- /dev/null +++ b/gitea-mcp.Tests/gitea-mcp.Tests.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + false + GiteaMcp.Tests + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/gitea-mcp.csproj b/gitea-mcp.csproj new file mode 100644 index 0000000..6b57036 --- /dev/null +++ b/gitea-mcp.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + enable + enable + GiteaMcp + gitea-mcp + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gitea-mcp.slnx b/gitea-mcp.slnx new file mode 100644 index 0000000..246281d --- /dev/null +++ b/gitea-mcp.slnx @@ -0,0 +1,4 @@ + + + +