gitea-mcp: 初次落地 Gitea MCP Server (.NET 10, V1 only-read)
把 Gitea (git.zhengchentao.win) 通过 MCP 暴露给 Claude.ai:列 repo、读代码、看 commits / issues / PR / orgs / packages / actions。 设计文档见 vault Coding/gitea-mcp/gitea-mcp 设计.md。 代码模板复用 obsidian-mcp(.NET 10 + ModelContextProtocol SDK + JwtBearer)。 19 个只读 Tool(全部 scope=read:gitea): Repo / 文件: - list_repos / read_repo - list_tree(max_entries=500 防爆) - read_file(max_bytes=1MB,超出 truncated=true) - search_code(走 /repos/search-code,indexer 未启用时返回结构化错误说明) 分支 / 提交: - list_branches / list_commits / read_commit(diff 文件数限 50) Issue / PR: - list_issues / read_issue(含评论) - list_pulls / read_pull(含评论 + 改动文件列表) Org / Package(用户额外授权 read:organization + read:package): - list_orgs / read_org - list_packages / read_package Gitea Actions(运维友好): - list_workflow_runs / read_run_log 技术栈: - .NET 10 + ModelContextProtocol SDK 1.0 - HttpClientFactory + Microsoft.Extensions.Http.Resilience(指数 backoff,5xx/429/网络错误重试) - JwtBearer (HS256, Current+Previous fallback, MapInboundClaims=false) - aud=gitea, scope=read:gitea, iss=https://auth.zhengchentao.win Gitea API client: - Authorization: token <PAT> (admin PAT,仅 read scope) - BaseUrl=https://git.zhengchentao.win - 错误映射:401/403 → UnauthorizedAccessException,404 → KeyNotFoundException,5xx → InvalidOperationException - RepoBlacklist 黑名单(owner/repo 精确匹配,默认空) 部署: - Dockerfile multi-stage,COPY --chown,non-root user - .gitea/workflows/build-image.yml:build + deploy 双 job,buildkit v0.13.2 - 容器内 :8080,宿主端口 9092 - 子域名 git-mcp.zhengchentao.win(区别于 Gitea 本体 git.zhengchentao.win) 测试:6/6 单测过(GiteaRepoFilter 黑名单匹配) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
**/.vs
|
||||||
|
**/.vscode
|
||||||
|
**/.idea
|
||||||
|
**/bin
|
||||||
|
**/obj
|
||||||
|
**/*.user
|
||||||
|
**/*.suo
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.gitignore
|
||||||
|
.dockerignore
|
||||||
|
README.md
|
||||||
|
LICENSE
|
||||||
|
**/*.md
|
||||||
|
gitea-mcp.Tests/
|
||||||
@@ -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
|
||||||
+36
@@ -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
|
||||||
@@ -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.win,ValidAudience = 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace GiteaMcp.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// JWT 验签配置,与 nas-auth / obsidian-mcp 共用同款 HS256 对称密钥。
|
||||||
|
/// ValidIssuer = auth.zhengchentao.win,ValidAudience = 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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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"]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using GiteaMcp.Config;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace GiteaMcp.Endpoints;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OAuth 2.0 Authorization Server Metadata(RFC 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
@@ -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 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 <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 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();
|
||||||
@@ -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)
|
||||||
@@ -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 或纯文本,
|
||||||
|
/// 这里最多读 maxBytes(1MB)防爆内存。
|
||||||
|
/// </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 indexer(app.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}")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; } = [];
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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-based,Gitea 索引未启用时为 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; } = [];
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
using GiteaMcp.Services;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
using System.ComponentModel;
|
||||||
|
|
||||||
|
namespace GiteaMcp.Tools;
|
||||||
|
|
||||||
|
/// <summary>Gitea Actions Tool:list_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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
using GiteaMcp.Services;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
using System.ComponentModel;
|
||||||
|
|
||||||
|
namespace GiteaMcp.Tools;
|
||||||
|
|
||||||
|
/// <summary>分支与 commit Tool:list_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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using GiteaMcp.Services;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
using System.ComponentModel;
|
||||||
|
|
||||||
|
namespace GiteaMcp.Tools;
|
||||||
|
|
||||||
|
/// <summary>Issue Tool:list_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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using GiteaMcp.Services;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
using System.ComponentModel;
|
||||||
|
|
||||||
|
namespace GiteaMcp.Tools;
|
||||||
|
|
||||||
|
/// <summary>Organization Tool:list_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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using GiteaMcp.Services;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
using System.ComponentModel;
|
||||||
|
|
||||||
|
namespace GiteaMcp.Tools;
|
||||||
|
|
||||||
|
/// <summary>Package Registry Tool:list_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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
using GiteaMcp.Services;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
using System.ComponentModel;
|
||||||
|
|
||||||
|
namespace GiteaMcp.Tools;
|
||||||
|
|
||||||
|
/// <summary>Pull Request Tool:list_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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>仓库级别 Tool:list_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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using GiteaMcp.Services;
|
||||||
|
using GiteaMcp.Services.Models;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
using System.ComponentModel;
|
||||||
|
|
||||||
|
namespace GiteaMcp.Tools;
|
||||||
|
|
||||||
|
/// <summary>代码搜索 Tool:search_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 返回 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<object>(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>文件树与文件内容 Tool:list_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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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 SDK:Streamable 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>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<Solution>
|
||||||
|
<Project Path="gitea-mcp.csproj" />
|
||||||
|
<Project Path="gitea-mcp.Tests/gitea-mcp.Tests.csproj" />
|
||||||
|
</Solution>
|
||||||
Reference in New Issue
Block a user