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