ASP.NET 手搓 MCP 服务(四):刷新令牌的轮换与重放检测

访问令牌一小时就过期,用户每次都得重登?可把令牌签长又怕泄漏。本篇用刷新令牌破这局:短命访问令牌 + 可轮换的刷新令牌,登录一次、长期免登。

先补 30 秒上下文(没看前面也能懂)

我们已经有两个进程:LocalOAuthServer:7031 签发 JWT,TaskMcpServer:7030 只认合法令牌。目前访问令牌有效期 1 小时,过期后用户必须重新走一遍登录授权------这就是本篇要解决的体验问题。本篇的改动全部落在授权服务器上:给它增量加上刷新令牌能力,MCP 服务侧只做两处配合。

没读过前文也没关系,你只需知道:现在"令牌会过期、过期要重登",本篇让过期这件事对用户透明。

读完本篇你能拿到什么

  • 一套短访问令牌 + 可轮换刷新令牌机制:登录一次,之后长期免登,暴露面始终压在短令牌上;
  • 规范里两条极易踩坑的硬要求:refresh_token 必须轮换 (OAuth 2.1 对公共客户端的强制项)、以及客户端凭什么知道可以刷新(元数据两个字段缺一就不刷);
  • 一个刷新令牌家族链模型:每次刷新签发新令牌、旧令牌立即失效,为"重放检测"打底;
  • 知道为什么"把令牌签长一点"是错的------公共客户端存不住秘密,长令牌 = 大泄漏窗口。

(撤销 / revoke 相关内容统一放在第五篇,本篇不涉及。)

一、为什么需要刷新令牌

第三篇留下的问题很直接:访问令牌过期即重新登录。但解法不能是"把令牌签长一点"------WorkBuddy 是公共客户端(Native App,不持有 client_secret),令牌存在客户端本地,生命周期越长,泄漏后的影响窗口越大。OAuth 2.1 §7.1 对此有明确要求:授权服务器应签发短生命周期访问令牌。

矛盾因此清晰:安全要求令牌短,体验要求不用反复登录。标准答案是把一件事拆成两个令牌:

  • 访问令牌:短 TTL(本篇演示用 1 小时,联调时压到 30 秒),泄漏了也只影响一小段时间;
  • 刷新令牌:长 TTL(30 天),只用来在访问令牌过期后换发新令牌,不直接接触资源服务器。

这样用户登录一次,之后长期免登录,而暴露面始终被压缩在短访问令牌上。本篇的全部改动都在授权服务器上,资源服务器只做两处配合性调整。

二、规范的两条硬性要求

补刷新令牌最值得记的不是代码,而是两条只在规范里写明、却极易被忽略的约束。

2.1 公共客户端必须轮换刷新令牌

Authorization servers SHOULD issue short-lived access tokens to reduce the impact of leaked tokens. For public clients, authorization servers MUST rotate refresh tokens as described in OAuth 2.1 Section 4.3.1 "Token Endpoint Extension".

即:每次刷新都必须签发新的 refresh_token 并让旧的立即失效,只换 access_token 是不够的。这一点与很多人的第一直觉相反------觉得刷新令牌长期复用才省事------却是 OAuth 2.1 对公共客户端的强制项。原因同样落在"公共客户端存不住秘密"上:刷新令牌一旦泄漏,攻击者可以无限续杯;轮换之后,攻击者与合法客户端必然有一方用到旧令牌,服务端就能察觉(见第四章的重放检测)。

2.2 客户端凭什么知道可以刷新

这是本次最容易漏的一步:即便 /token 的响应里已经返回了 refresh_token,客户端也未必会去用它。

按 SEP-2207(OIDC-Flavored Refresh Token Guidance),MCP 客户端的行为取决于授权服务器的元数据:只有 grant_types_supported 含 refresh_token、且 scopes_supported 含 offline_access,客户端才会在授权请求中追加 offline_access 去索要刷新令牌。两个字段缺一,客户端拿到刷新令牌也会当没看见,过期后照旧弹重新登录。

因此元数据必须补齐。对比第二篇的版本:

csharp 复制代码
// 第二篇
["grant_types_supported"] = new[] { "authorization_code" },
["scopes_supported"] = new[] { "mcp:tools" },

// 本篇
// 必须列出 refresh_token:MCP 客户端靠这个字段决定要不要走刷新流程(SEP-2207)
["grant_types_supported"] = new[] { "authorization_code", "refresh_token" },
// offline_access 是客户端请求刷新令牌的约定 scope:
// 客户端只有在 scopes_supported 里看到它,才会把 offline_access 追加到授权请求的 scope 上。
["scopes_supported"] = new[] { "mcp:tools", "offline_access" },

同理,/register 的 grant type 白名单也要放行 refresh_token(回显不认识的枚举值会让客户端 Zod 校验直接失败,这是第二篇踩过的坑):

csharp 复制代码
var SupportedGrantTypes = new HashSet<string>(StringComparer.Ordinal)
    { "authorization_code", "refresh_token" }; // 第二篇仅有 authorization_code

三、授权服务器改动

3.1 令牌链数据结构

刷新令牌是不透明随机串(不可猜测、不承载信息,便于服务端随时令其失效),内存存储,每条记录挂在一条"令牌链"(family)上:

csharp 复制代码
// 刷新令牌内存存储:token -> 记录。
// 轮换模型(OAuth 2.1 §4.14.2):一次授权产生一条"令牌链"(family),每次刷新都签发新令牌,
// 并把旧令牌标记为 Rotated 而【不是】直接删除 ------ 只有保留旧记录,旧令牌被重放时才能识别出来。
var refreshTokens = new System.Collections.Concurrent.ConcurrentDictionary<string, RefreshTokenRecord>();

// FamilyId 把一次授权后产生的所有刷新令牌串成一条链:
// 重放检测命中时,撤销的是整条链而不是单个令牌。
sealed class RefreshTokenRecord
{
    public required string Token { get; init; }
    public required string FamilyId { get; init; }
    public required string ClientId { get; init; }
    public required TokenSubject Subject { get; init; }  // 第二篇已有:UserId/Username/DisplayName
    public required string Scope { get; init; }
    public required DateTime CreatedAt { get; init; }
    public required DateTime ExpiresAt { get; init; }
    public RefreshTokenStatus Status { get; set; } = RefreshTokenStatus.Active;
}

// 刷新令牌状态机:Active --(使用一次)--> Rotated --(再次出现)--> 整条链 Revoked
enum RefreshTokenStatus { Active, Rotated, Revoked }

Subject 复用第二篇的 TokenSubject------访问令牌与刷新令牌共用同一个归属主体,保证刷新后 sub 不漂移,否则用户数据会串号。

3.2 签发时机

公共客户端无法安全保管长期凭据,只能靠"短访问令牌 + 可轮换的刷新令牌"维持会话,所以在 authorization_code grant 里一律签发刷新令牌,新起一条令牌链:

csharp 复制代码
// authorization_code grant 内,PKCE 等四道校验通过之后:
var refresh = IssueRefreshToken(familyId: Guid.NewGuid().ToString("N"), clientId, subject, scope);
var accessToken = IssueAccessToken(subject, scope, clientId);

return Results.Json(new Dictionary<string, object?>
{
    ["access_token"] = accessToken,
    ["token_type"] = "Bearer",
    ["expires_in"] = accessTokenLifetimeSeconds,
    ["scope"] = scope,
    ["refresh_token"] = refresh.Token,   // 第二篇的响应没有这一项
});

3.3 三档 TTL

令牌 有效期 配置方式
access_token 3600s(1 小时) 常量 DefaultAccessTokenLifetimeSeconds,运行时用 --access-token-lifetime-seconds 覆盖
authorization_code 60s 常量 AuthorizationCodeLifetimeSeconds
refresh_token 2592000s(30 天) 常量 RefreshTokenLifetimeSeconds

访问令牌 TTL 做成命令行可覆盖,是为了联调时不用等满 1 小时(见第六章)。刷新令牌的安全性不靠短有效期,而靠"轮换 + 重放检测"(下一章),所以可以放心签 30 天。

四、轮换与重放检测

4.1 旧令牌保留,而不是删除

轮换实现里有一个反直觉的设计:用过的旧刷新令牌要保留记录,标记为 Rotated,而不是从字典里删掉。

  • 若直接删除,服务端之后再收到它时只能判断"这个令牌不存在",无法区分"令牌泄漏后被攻击者重放"与"客户端发了个无效串";
  • 保留记录后,旧令牌再次出现即可确定是重放。

4.2 状态机与整链撤销

scss 复制代码
Active ──(使用一次)──> Rotated ──(再次出现)──> 整条 family 全部 Revoked

重放命中时,不能只拒绝这一次请求 ------必须撤销整条令牌链。道理是:旧令牌被用了两次,说明它同时握在两个人手里,合法客户端与攻击者必有一个是假的,但服务端无法分辨是谁。唯一安全的选择是整条链作废,逼双方重新登录。client_id 不匹配(令牌被另一个客户端使用)同样触发整链撤销:

csharp 复制代码
// 重放检测(OAuth 2.1 §4.14.2):已轮换掉的旧令牌再次出现,说明它可能已泄漏。
if (rec.Status != RefreshTokenStatus.Active)
{
    RevokeFamily(rec.FamilyId, "refresh token replay detected");
    Console.WriteLine($"    [token] !!! REPLAY DETECTED family={Short(rec.FamilyId)} user={rec.Subject.Username} --- 整条令牌链已撤销");
    return Results.BadRequest(new { error = "invalid_grant", error_description = "refresh token reuse detected" });
}

if (rec.ClientId != clientId)
{
    RevokeFamily(rec.FamilyId, "client mismatch on refresh");
    return Results.BadRequest(new { error = "invalid_grant", error_description = "refresh token was issued to another client" });
}

4.3 刷新处理流程

/token 端点按 grant_type 分流,refresh_token 分支的完整校验顺序:

csharp 复制代码
// 1. 令牌存在(不存在可能是瞎发的,也可能是已被清理的过期令牌)
if (!refreshTokens.TryGetValue(refreshToken, out var rec))
    return Results.BadRequest(new { error = "invalid_grant", error_description = "refresh token invalid or revoked" });

// 2. 重放检测 + 3. client_id 归属(见 4.2,命中即整链撤销)

// 4. 有效期
if (DateTime.UtcNow >= rec.ExpiresAt)
{
    refreshTokens.TryRemove(refreshToken, out _);
    return Results.BadRequest(new { error = "invalid_grant", error_description = "refresh token expired" });
}

// 5. 刷新只能收窄 scope,不能放大(RFC 6749 §6)
var scope = rec.Scope;
if (!string.IsNullOrEmpty(requestedScope))
{
    if (!IsSubsetScope(requestedScope, rec.Scope))
        return Results.BadRequest(new { error = "invalid_scope", error_description = "cannot widen scope on refresh" });
    scope = requestedScope;
}

// 6. 轮换:旧令牌立即失效;新令牌沿用同一个 family,日后旧令牌被重放时可追溯到整条链
rec.Status = RefreshTokenStatus.Rotated;
var rotated = IssueRefreshToken(rec.FamilyId, clientId, rec.Subject, scope);
var accessToken = IssueAccessToken(rec.Subject, scope, clientId);

响应同时返回新 access_token 与新 refresh_token,客户端用新对旧,无缝续期。

五、资源服务器侧的配合

刷新流程本身与资源服务器无关(令牌交换只发生在客户端与授权服务器之间),但联调时有一个默认值会让现象失真。

5.1 ClockSkew

JwtBearer 的 ClockSkew 默认是 5 分钟,即访问令牌过期后仍会被判为有效 5 分钟。联调时表现为:令牌明明已经过期,请求却照常返回 200,看上去就像客户端从未去刷新。收紧到 5 秒后,过期才立即表现为 401:

csharp 复制代码
// 第三篇的 TokenValidationParameters 内新增一行:
// 默认 ClockSkew 是 5 分钟,会让已过期的令牌继续被接受,
// 造成"令牌过期了却还是 200"的假象。同机部署,5 秒足够吸收时钟抖动。
ClockSkew = TimeSpan.FromSeconds(5)

5.2 暴露令牌过期时间

把 exp 换算后暴露到 get_current_user 的返回值里------刷新成功后再调用,过期时间会整体后移一个访问令牌生命周期,这是判断刷新是否生效最直接的凭据:

csharp 复制代码
// CurrentUser 增加两个字段(第三篇版本只有 UserId/Username/DisplayName)
public string TokenExpiresAt { get; set; } = "";   // UTC,ISO8601
public double TokenExpiresInSeconds { get; set; }  // 剩余秒数,便于观察刷新是否发生

// 读 exp claim(unix 秒),换算为 UTC 时间与剩余秒数
internal static (string? ExpiresAt, double? ExpiresInSeconds) ReadExpiry(ClaimsPrincipal? user)
{
    var raw = user?.FindFirst("exp")?.Value;
    if (string.IsNullOrEmpty(raw) || !long.TryParse(raw, out var seconds)) return (null, null);
    var exp = DateTimeOffset.FromUnixTimeSeconds(seconds);
    return (exp.UtcDateTime.ToString("o"), (exp - DateTimeOffset.UtcNow).TotalSeconds);
}

资源服务器的改动到此为止,其余与第三篇完全一致。

六、联调验证

6.1 时序补全

在第三篇的时序图后面接上刷新的尾巴,完整闭环如下:

sequenceDiagram participant WB as WorkBuddy participant MCP as TaskMcpServer:7030 participant AUTH as LocalOAuthServer:7031 WB->>MCP: POST /(无 token) MCP-->>WB: 401 + WWW-Authenticate WB->>AUTH: 发现 → 注册 → /authorize(PKCE) AUTH-->>WB: 302 重定向 ?code=xxx&state=yyy WB->>AUTH: POST /token(code + code_verifier) AUTH-->>WB: access_token + refresh_token(新 family) WB->>MCP: 携带 Bearer token 的请求 MCP-->>WB: 200 工具结果(按 sub 隔离) Note over WB,AUTH: access_token 到期 WB->>MCP: 携带过期 token 的请求 MCP-->>WB: 401 invalid_token WB->>AUTH: POST /token(grant_type=refresh_token) AUTH-->>WB: 新 access_token + 新 refresh_token(旧刷新令牌标记 Rotated) WB->>MCP: 携带新 Bearer token 重试 MCP-->>WB: 200

6.2 压缩观测窗口

把访问令牌生命周期调到 30 秒,否则要等满 1 小时才能看到一次刷新:

bash 复制代码
dotnet run --access-token-lifetime-seconds 30

启动时控制台会打印实际生效的配置:

ini 复制代码
[config] access_token=30s, authorization_code=60s, refresh_token=2592000s (30d)

在 WorkBuddy 中 disconnect 再 reconnect(清掉旧令牌缓存),用 alice / Pass@123 重新授权。之后正常调用工具,等待 30 秒后再调一次 get_current_user:客户端会先用过期令牌吃到 401,自动走刷新,重试成功------返回的 TokenExpiresAt 比上一次调用整体后移,证明令牌确实换过了。

6.3 日志闭环

一次完整的刷新闭环,在授权服务器日志中长这样:

ini 复制代码
[token] authorization_code -> user=alice (sub=u-1001) scope=mcp:tools offline_access access_token_expires_in=30s refresh_family=62c925f555...
[token] refresh_token -> user=alice (sub=u-1001) scope=mcp:tools offline_access 旧令牌=rt_rqpZJ_i... 已轮换为=rt_Zg1ShzB...
[token] !!! REPLAY DETECTED family=62c925f555... user=alice --- 整条令牌链已撤销

第三行需要手动构造:把上一次刷新前的旧 refresh_token 再发一次(curl 即可),服务端识别重放并撤销整条链------合法客户端下一次刷新会失败,被迫重新登录。这正是 2.1 那条"必须轮换"规范想要的效果:泄漏的令牌一旦使用,全家陪葬。

七、完整源码

本篇只改授权服务器,以下给出本篇版本的 LocalOAuthServer/Program.cs 全文(相对第二篇的增量:grant 白名单与元数据、refreshTokens 存储、/token 的 refresh_token 分支、IssueRefreshToken / RevokeFamily / SweepExpired 及配套类型;撤销端点 /revoke 属第五篇,本篇不含)。资源服务器仅按第五章加两行,不重复列出。

csharp 复制代码
// OAuth 2.0 + PKCE 授权服务器(本篇补齐刷新令牌:轮换 + 重放检测)
// 相对第二篇的增量:grant_type=refresh_token、令牌链(family)模型、三档 TTL、离线 scope 声明
// 签发被 TaskMcpServer:7030 的 JwtBearer 认可的 RS256 JWT:
//   iss = http://localhost:7031
//   aud = http://localhost:7030/
// 用户体系:本地实验用固定用户(明文密码仅限本地 demo,真实部署应改为数据库 + 密码哈希)
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;

var builder = WebApplication.CreateBuilder(new[] { "--urls", "http://localhost:7031" });
var app = builder.Build();

const string Issuer = "http://localhost:7031";
const string McpResourceAudience = "http://localhost:7030/";

// ---- 令牌生命周期 ----
// 访问令牌默认 1 小时。联调刷新流程时建议临时调小,否则要等满 1 小时才会触发 refresh:
//   dotnet run --access-token-lifetime-seconds 30
// OAuth 2.1 §7.1 要求授权服务器签发短生命周期访问令牌,令牌泄漏后的影响窗口才可控;
// 这正是需要 refresh_token 的原因 ------ 短令牌 + 长刷新令牌,而不是直接发长令牌。
const int DefaultAccessTokenLifetimeSeconds = 3600;
int accessTokenLifetimeSeconds = ResolveIntOption(args, "access-token-lifetime-seconds", DefaultAccessTokenLifetimeSeconds);

// 授权码 TTL:RFC 6749 §4.1.2 建议不超过 10 分钟
const int AuthorizationCodeLifetimeSeconds = 60;

// 刷新令牌 TTL:远长于访问令牌,安全性由"轮换 + 重放检测"而非短有效期保证
const int RefreshTokenLifetimeSeconds = 30 * 24 * 60 * 60;

// ---- 固定用户(本地实验用)。真实部署请改为数据库 + 密码哈希存储 ----
var users = new Dictionary<string, UserInfo>(StringComparer.OrdinalIgnoreCase)
{
    ["alice"] = new("u-1001", "alice", "Pass@123", "Alice Wang"),
    ["bob"]   = new("u-1002", "bob",   "Pass@123", "Bob Li"),
    ["carol"] = new("u-1003", "carol", "Pass@123", "Carol Chen"),
};

// 启动时加载 RSA 密钥:优先从 oauth_key.json 读取(持久化),否则生成并保存。
// 关键:keyId 必须固定,否则授权服务器重启后 kid 变化,而资源服务器 JwtBearer 缓存旧 JWKS
// → 新 token 验签失败 → 客户端反复重新授权,形成死循环。
const string KeyFile = "oauth_key.json";
RSA rsa;
string keyId;
if (File.Exists(KeyFile))
{
    var k = JsonDocument.Parse(File.ReadAllText(KeyFile)).RootElement;
    keyId = k.GetProperty("kid").GetString()!;
    rsa = RSA.Create();
    rsa.ImportParameters(new RSAParameters
    {
        Modulus = Base64UrlDecode(k.GetProperty("n").GetString()!),
        Exponent = Base64UrlDecode(k.GetProperty("e").GetString()!),
        D = Base64UrlDecode(k.GetProperty("d").GetString()!),
        DP = Base64UrlDecode(k.GetProperty("dp").GetString()!),
        DQ = Base64UrlDecode(k.GetProperty("dq").GetString()!),
        InverseQ = Base64UrlDecode(k.GetProperty("qi").GetString()!),
        P = Base64UrlDecode(k.GetProperty("p").GetString()!),
        Q = Base64UrlDecode(k.GetProperty("q").GetString()!),
    });
    Console.WriteLine($"[key] loaded persisted RSA key kid={keyId}");
}
else
{
    rsa = RSA.Create(2048);
    keyId = Guid.NewGuid().ToString("N");
    var p = rsa.ExportParameters(true);
    var obj = new Dictionary<string, string?>
    {
        ["kid"] = keyId,
        ["n"] = Base64UrlEncode(p.Modulus!),
        ["e"] = Base64UrlEncode(p.Exponent!),
        ["d"] = Base64UrlEncode(p.D!),
        ["dp"] = Base64UrlEncode(p.DP!),
        ["dq"] = Base64UrlEncode(p.DQ!),
        ["qi"] = Base64UrlEncode(p.InverseQ!),
        ["p"] = Base64UrlEncode(p.P!),
        ["q"] = Base64UrlEncode(p.Q!),
    };
    File.WriteAllText(KeyFile, JsonSerializer.Serialize(obj, new JsonSerializerOptions { WriteIndented = true }));
    Console.WriteLine($"[key] generated + persisted RSA key kid={keyId}");
}
var signingKey = new RsaSecurityKey(rsa) { KeyId = keyId };

Console.WriteLine($"[config] access_token={accessTokenLifetimeSeconds}s, " +
                  $"authorization_code={AuthorizationCodeLifetimeSeconds}s, " +
                  $"refresh_token={RefreshTokenLifetimeSeconds}s ({RefreshTokenLifetimeSeconds / 86400}d)");

// 授权码内存存储:code -> 记录(绑定 code_challenge 实现 PKCE 校验 + 绑定用户)。一次性消费 + TTL。
var codes = new System.Collections.Concurrent.ConcurrentDictionary<string, AuthCodeRecord>();

// 刷新令牌内存存储:token -> 记录。
// 轮换模型(OAuth 2.1 §4.14.2):一次授权产生一条"令牌链"(family),每次刷新都签发新令牌,
// 并把旧令牌标记为 Rotated 而【不是】直接删除 ------ 只有保留旧记录,旧令牌被重放时才能识别出来,
// 识别到即撤销整条令牌链。
var refreshTokens = new System.Collections.Concurrent.ConcurrentDictionary<string, RefreshTokenRecord>();

// 动态注册的客户端信息(RFC 7591):client_id -> 注册信息
var clients = new System.Collections.Concurrent.ConcurrentDictionary<string, RegisteredClient>();

// 服务端实际支持的 grant type 白名单:注册响应只回显白名单内的值
var SupportedGrantTypes = new HashSet<string>(StringComparer.Ordinal) { "authorization_code", "refresh_token" };

// 观测日志中间件
app.Use(async (ctx, next) =>
{
    var ts = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff");
    Console.WriteLine($"[{ts}] >>> {ctx.Request.Method} {ctx.Request.Path}{ctx.Request.QueryString}");
    await next(ctx);
});

// ---- 授权服务器元数据 (RFC 8414) ----
app.MapGet("/.well-known/oauth-authorization-server", () => Results.Json(new Dictionary<string, object?>
{
    ["issuer"] = Issuer,
    ["authorization_endpoint"] = $"{Issuer}/authorize",
    ["token_endpoint"] = $"{Issuer}/token",
    ["registration_endpoint"] = $"{Issuer}/register",
    ["jwks_uri"] = $"{Issuer}/.well-known/jwks",
    ["response_types_supported"] = new[] { "code" },
    // 必须列出 refresh_token:MCP 客户端靠这个字段决定要不要走刷新流程(SEP-2207)
    ["grant_types_supported"] = new[] { "authorization_code", "refresh_token" },
    ["code_challenge_methods_supported"] = new[] { "S256" },
    // offline_access 是客户端请求刷新令牌的约定 scope(SEP-2207):
    // 客户端只有在 scopes_supported 里看到它,才会把 offline_access 追加到授权请求的 scope 上。
    ["scopes_supported"] = new[] { "mcp:tools", "offline_access" },
    ["token_endpoint_auth_methods_supported"] = new[] { "none" },
}));

// ---- OIDC 发现文档(JwtBearer 通过 Authority 取此文档,缺 jwks_uri 会令资源服务器报 500)----
app.MapGet("/.well-known/openid-configuration", () => Results.Json(new Dictionary<string, object?>
{
    ["issuer"] = Issuer,
    ["authorization_endpoint"] = $"{Issuer}/authorize",
    ["token_endpoint"] = $"{Issuer}/token",
    ["registration_endpoint"] = $"{Issuer}/register",
    ["jwks_uri"] = $"{Issuer}/.well-known/jwks",
    ["response_types_supported"] = new[] { "code" },
    ["grant_types_supported"] = new[] { "authorization_code", "refresh_token" },
    ["code_challenge_methods_supported"] = new[] { "S256" },
    ["scopes_supported"] = new[] { "mcp:tools", "offline_access" },
    ["token_endpoint_auth_methods_supported"] = new[] { "none" },
}));

// ---- JWKS:暴露 RSA 公钥,JwtBearer 按 kid 取它验证签名 ----
app.MapGet("/.well-known/jwks", () =>
{
    var p = rsa.ExportParameters(false);
    var jwk = new Dictionary<string, object?>
    {
        ["kty"] = "RSA",
        ["use"] = "sig",
        ["alg"] = "RS256",
        ["kid"] = keyId,
        ["n"] = Base64UrlEncode(p.Modulus!),
        ["e"] = Base64UrlEncode(p.Exponent!),
    };
    return Results.Json(new { keys = new[] { jwk } });
});

// ---- 动态客户端注册 (RFC 7591) ----
// 注册【响应】必须回显 redirect_uris 且为数组,否则客户端 Zod 校验会因
// redirect_uris 缺失/类型不符而报 invalid_type,表现为 connect failed。
app.MapPost("/register", async (HttpContext ctx) =>
{
    string? clientName = null;
    string? clientUri = null;
    var redirectUris = new List<string>();
    var grantTypes = new List<string>();
    string? scope = null;
    try
    {
        using var doc = await JsonDocument.ParseAsync(ctx.Request.Body);
        if (doc.RootElement.TryGetProperty("client_name", out var cn)) clientName = cn.GetString();
        if (doc.RootElement.TryGetProperty("client_uri", out var cu)) clientUri = cu.GetString();
        if (doc.RootElement.TryGetProperty("scope", out var sc)) scope = sc.GetString();
        if (doc.RootElement.TryGetProperty("redirect_uris", out var ru) &&
            ru.ValueKind == JsonValueKind.Array)
        {
            foreach (var item in ru.EnumerateArray())
            {
                var v = item.GetString();
                if (!string.IsNullOrEmpty(v)) redirectUris.Add(v!);
            }
        }
        if (doc.RootElement.TryGetProperty("grant_types", out var gt) &&
            gt.ValueKind == JsonValueKind.Array)
        {
            foreach (var item in gt.EnumerateArray())
            {
                var v = item.GetString();
                if (!string.IsNullOrEmpty(v)) grantTypes.Add(v!);
            }
        }
    }
    catch { /* 忽略解析失败,仍签发 client_id */ }

    // 只回显服务端确实支持的 grant type:客户端侧有 Zod 校验,
    // 回显它不认识的枚举值会直接让注册响应校验失败、连接中断。
    var effectiveGrantTypes = grantTypes.Where(g => SupportedGrantTypes.Contains(g)).ToList();
    if (effectiveGrantTypes.Count == 0)
        effectiveGrantTypes = new List<string> { "authorization_code", "refresh_token" };

    var clientId = Guid.NewGuid().ToString("N");
    clients[clientId] = new RegisteredClient(
        ClientId: clientId,
        ClientName: clientName,
        RedirectUris: redirectUris.ToArray(),
        GrantTypes: effectiveGrantTypes.ToArray(),
        Scope: scope,
        TokenEndpointAuthMethod: "none");
    Console.WriteLine($"    [register] issued client_id={clientId} name={clientName} " +
                      $"grant_types=[{string.Join(",", effectiveGrantTypes)}]");

    var resp = new Dictionary<string, object?>
    {
        ["client_id"] = clientId,
        ["client_id_issued_at"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
        ["token_endpoint_auth_method"] = "none",
        ["redirect_uris"] = redirectUris.ToArray(),   // 必回显,类型 string[]
        ["grant_types"] = effectiveGrantTypes.ToArray(),
    };
    if (clientName != null) resp["client_name"] = clientName;
    if (clientUri != null) resp["client_uri"] = clientUri;
    if (scope != null) resp["scope"] = scope;
    return Results.Json(resp);
});

// ---- 授权端点 GET:渲染登录表单(用户名 + 密码)----
app.MapGet("/authorize", (HttpContext ctx) =>
{
    var clientId = ctx.Request.Query["client_id"].ToString();
    var redirectUri = ctx.Request.Query["redirect_uri"].ToString();
    var state = ctx.Request.Query["state"].ToString();
    var codeChallenge = ctx.Request.Query["code_challenge"].ToString();
    var scope = ctx.Request.Query["scope"].ToString();
    Console.WriteLine($"    [authorize] client_id={clientId} scope={scope}");
    return Results.Content(
        RenderLoginForm(clientId, redirectUri, state, codeChallenge, scope, username: "", error: ""),
        "text/html; charset=utf-8");
});

// ---- 同意提交:校验用户名/密码,成功则签发授权码 ----
app.MapPost("/authorize/consent", async (HttpContext ctx) =>
{
    var form = await ctx.Request.ReadFormAsync();
    var clientId = form["client_id"].ToString();
    var redirectUri = form["redirect_uri"].ToString();
    var state = form["state"].ToString();
    var codeChallenge = form["code_challenge"].ToString();
    var scope = form["scope"].ToString();
    var username = form["username"].ToString();
    var password = form["password"].ToString();

    users.TryGetValue(username ?? "", out var user);
    if (user is null || user.Password != password)
    {
        Console.WriteLine($"    [consent] login failed username={username}");
        return Results.Content(
            RenderLoginForm(clientId, redirectUri, state, codeChallenge, scope, username, "用户名或密码错误"),
            "text/html; charset=utf-8");
    }

    if (!IsAllowedRedirect(redirectUri))
    {
        Console.WriteLine($"    [consent] rejected unsafe redirect_uri={redirectUri}");
        return Results.BadRequest("unsafe redirect_uri");
    }

    var code = Guid.NewGuid().ToString("N");
    codes[code] = new AuthCodeRecord(
        CodeChallenge: codeChallenge,
        RedirectUri: redirectUri,
        State: state,
        Scope: string.IsNullOrEmpty(scope) ? "mcp:tools" : scope,
        ClientId: clientId,
        CreatedAt: DateTime.UtcNow,
        UserId: user.UserId,
        Username: user.Username,
        DisplayName: user.DisplayName);
    Console.WriteLine($"    [consent] login ok user={user.Username} (sub={user.UserId}) issued code={code}");

    var sep = redirectUri.Contains('?') ? '&' : '?';
    var location = $"{redirectUri}{sep}code={code}&state={Uri.EscapeDataString(state)}";
    return Results.Redirect(location);
});

// ---- Token 端点:授权码换取令牌 / 刷新令牌换新令牌 ----
app.MapPost("/token", async (HttpContext ctx) =>
{
    var form = await ctx.Request.ReadFormAsync();
    var grantType = form["grant_type"].ToString();
    var clientId = form["client_id"].ToString();

    if (string.IsNullOrEmpty(clientId))
        return Results.BadRequest(new { error = "invalid_request", error_description = "client_id is required" });

    return grantType switch
    {
        "authorization_code" => await HandleAuthorizationCodeGrant(form, clientId),
        "refresh_token" => HandleRefreshTokenGrant(form, clientId),
        _ => Results.BadRequest(new { error = "unsupported_grant_type" }),
    };
});

app.Run();

// ---- 授权码换取令牌:校验 code + PKCE + TTL,签发访问令牌 + 刷新令牌 ----
async Task<IResult> HandleAuthorizationCodeGrant(IFormCollection form, string clientId)
{
    var code = form["code"].ToString();
    var codeVerifier = form["code_verifier"].ToString();
    var redirectUri = form["redirect_uri"].ToString();

    // 先取出(一次性消费)再判断过期:否则过期码在被清理前可被反复尝试
    if (string.IsNullOrEmpty(code) || !codes.TryRemove(code, out var rec))
        return Results.BadRequest(new { error = "invalid_grant", error_description = "code invalid or expired" });

    if (DateTime.UtcNow - rec.CreatedAt > TimeSpan.FromSeconds(AuthorizationCodeLifetimeSeconds))
        return Results.BadRequest(new { error = "invalid_grant", error_description = "authorization code expired" });

    if (!string.IsNullOrEmpty(rec.ClientId) && rec.ClientId != clientId)
        return Results.BadRequest(new { error = "invalid_grant", error_description = "code was issued to another client" });

    if (!string.IsNullOrEmpty(redirectUri) && !string.IsNullOrEmpty(rec.RedirectUri) && redirectUri != rec.RedirectUri)
        return Results.BadRequest(new { error = "invalid_grant", error_description = "redirect_uri mismatch" });

    // PKCE 校验:S256(code_verifier) 必须等于授权时登记的 code_challenge
    var expectedChallenge = Sha256Base64Url(codeVerifier);
    if (expectedChallenge != rec.CodeChallenge)
        return Results.BadRequest(new { error = "invalid_grant", error_description = "PKCE verification failed" });

    var subject = new TokenSubject(rec.UserId, rec.Username, rec.DisplayName);
    var scope = NormalizeScope(rec.Scope);

    // 公共客户端(PKCE、token_endpoint_auth_method=none)无法安全保管长期凭据,
    // 只能靠"短访问令牌 + 可轮换的刷新令牌"维持会话,故这里一律签发刷新令牌。
    var refresh = IssueRefreshToken(familyId: Guid.NewGuid().ToString("N"), clientId, subject, scope);
    var accessToken = IssueAccessToken(subject, scope, clientId);

    Console.WriteLine($"    [token] authorization_code -> user={subject.Username} (sub={subject.UserId}) " +
                      $"scope={scope} access_token_expires_in={accessTokenLifetimeSeconds}s refresh_family={Short(refresh.FamilyId)}");
    return Results.Json(new Dictionary<string, object?>
    {
        ["access_token"] = accessToken,
        ["token_type"] = "Bearer",
        ["expires_in"] = accessTokenLifetimeSeconds,
        ["scope"] = scope,
        ["refresh_token"] = refresh.Token,
    });
}

// ---- 刷新令牌换新令牌:轮换 + 重放检测 ----
IResult HandleRefreshTokenGrant(IFormCollection form, string clientId)
{
    var refreshToken = form["refresh_token"].ToString();
    var requestedScope = form["scope"].ToString();

    if (string.IsNullOrEmpty(refreshToken))
        return Results.BadRequest(new { error = "invalid_request", error_description = "refresh_token is required" });

    if (!refreshTokens.TryGetValue(refreshToken, out var rec))
        return Results.BadRequest(new { error = "invalid_grant", error_description = "refresh token invalid or revoked" });

    // 重放检测(OAuth 2.1 §4.14.2):已轮换掉的旧令牌再次出现,说明它可能已泄漏。
    // 此时不能只拒绝这一次请求 ------ 必须撤销整条令牌链,逼攻击者与合法客户端都重新登录。
    if (rec.Status != RefreshTokenStatus.Active)
    {
        RevokeFamily(rec.FamilyId, "refresh token replay detected");
        Console.WriteLine($"    [token] !!! REPLAY DETECTED family={Short(rec.FamilyId)} user={rec.Subject.Username} --- 整条令牌链已撤销");
        return Results.BadRequest(new { error = "invalid_grant", error_description = "refresh token reuse detected" });
    }

    if (rec.ClientId != clientId)
    {
        RevokeFamily(rec.FamilyId, "client mismatch on refresh");
        Console.WriteLine($"    [token] !!! client mismatch family={Short(rec.FamilyId)} expected={rec.ClientId} got={clientId}");
        return Results.BadRequest(new { error = "invalid_grant", error_description = "refresh token was issued to another client" });
    }

    if (DateTime.UtcNow >= rec.ExpiresAt)
    {
        refreshTokens.TryRemove(refreshToken, out _);
        Console.WriteLine($"    [token] refresh token expired user={rec.Subject.Username} --- 客户端需重新授权");
        return Results.BadRequest(new { error = "invalid_grant", error_description = "refresh token expired" });
    }

    // 刷新只能收窄 scope,不能放大(RFC 6749 §6)
    var scope = rec.Scope;
    if (!string.IsNullOrEmpty(requestedScope))
    {
        if (!IsSubsetScope(requestedScope, rec.Scope))
            return Results.BadRequest(new { error = "invalid_scope", error_description = "cannot widen scope on refresh" });
        scope = requestedScope;
    }

    // 轮换:旧令牌立即失效;新令牌沿用同一个 family,使得旧令牌日后被重放时可追溯到整条链
    rec.Status = RefreshTokenStatus.Rotated;
    var rotated = IssueRefreshToken(rec.FamilyId, clientId, rec.Subject, scope);
    var accessToken = IssueAccessToken(rec.Subject, scope, clientId);

    Console.WriteLine($"    [token] refresh_token -> user={rec.Subject.Username} (sub={rec.Subject.UserId}) " +
                      $"scope={scope} 旧令牌={Short(refreshToken)} 已轮换为={Short(rotated.Token)}");
    return Results.Json(new Dictionary<string, object?>
    {
        ["access_token"] = accessToken,
        ["token_type"] = "Bearer",
        ["expires_in"] = accessTokenLifetimeSeconds,
        ["scope"] = scope,
        ["refresh_token"] = rotated.Token,
    });
}

// 签发刷新令牌:32 字节随机不透明串(不可猜测,也不承载任何信息,便于服务端随时令其失效)
RefreshTokenRecord IssueRefreshToken(string familyId, string clientId, TokenSubject subject, string scope)
{
    SweepExpired();
    var token = "rt_" + Base64UrlEncode(RandomNumberGenerator.GetBytes(32));
    var rec = new RefreshTokenRecord
    {
        Token = token,
        FamilyId = familyId,
        ClientId = clientId,
        Subject = subject,
        Scope = scope,
        CreatedAt = DateTime.UtcNow,
        ExpiresAt = DateTime.UtcNow.AddSeconds(RefreshTokenLifetimeSeconds),
    };
    refreshTokens[token] = rec;
    return rec;
}

// 撤销一条令牌链上的所有刷新令牌(本篇仅供重放检测内部调用;对外的 /revoke 端点见第五篇)。
// 注意:已签发的访问令牌是自制 JWT、无状态,无法在此撤销 ------
// 这正是访问令牌必须短生命周期的原因(最长残留时间 = accessTokenLifetimeSeconds)。
void RevokeFamily(string familyId, string reason)
{
    foreach (var kv in refreshTokens)
    {
        if (kv.Value.FamilyId == familyId && kv.Value.Status != RefreshTokenStatus.Revoked)
        {
            kv.Value.Status = RefreshTokenStatus.Revoked;
            Console.WriteLine($"    [revoke] token={Short(kv.Key)} family={Short(familyId)} reason={reason}");
        }
    }
}

// 清理过期的授权码与已失效的刷新令牌(顺带在每次签发时执行,避免内存无界增长)
void SweepExpired()
{
    var now = DateTime.UtcNow;
    foreach (var kv in codes)
        if (now - kv.Value.CreatedAt > TimeSpan.FromSeconds(AuthorizationCodeLifetimeSeconds))
            codes.TryRemove(kv.Key, out _);

    foreach (var kv in refreshTokens)
        if (kv.Value.ExpiresAt < now)
            refreshTokens.TryRemove(kv.Key, out _);
}

// 签发访问令牌(RS256 JWT)。生命周期由 accessTokenLifetimeSeconds 决定------
// 刷新流程得以存在的意义就在这里:短到即使泄漏也只影响一小段时间。
string IssueAccessToken(TokenSubject subject, string scope, string clientId)
{
    var handler = new JwtSecurityTokenHandler();
    var descriptor = new SecurityTokenDescriptor
    {
        Issuer = Issuer,
        Audience = McpResourceAudience,
        Subject = new System.Security.Claims.ClaimsIdentity(new[]
        {
            // sub = 当前登录用户的 id,TaskMcpServer:7030 工具内据此隔离各用户的数据
            new System.Security.Claims.Claim(JwtRegisteredClaimNames.Sub, subject.UserId),
            new System.Security.Claims.Claim("name", subject.DisplayName),
            new System.Security.Claims.Claim("preferred_username", subject.Username),
            new System.Security.Claims.Claim("scope", scope),
            new System.Security.Claims.Claim("client_id", clientId),
        }),
        NotBefore = DateTime.UtcNow,
        Expires = DateTime.UtcNow.AddSeconds(accessTokenLifetimeSeconds),
        SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.RsaSha256),
    };
    return handler.WriteToken(handler.CreateToken(descriptor));
}

// ---------- helpers ----------
string RenderLoginForm(string? clientId, string? redirectUri, string? state, string? codeChallenge, string? scope, string? username, string? error)
{
    var errHtml = string.IsNullOrEmpty(error) ? "" :
        $"<p style=\"color:#c0392b;margin:8px 0\">{Escape(error)}</p>";
    var userVal = Escape(username);
    return $@"<html><head><meta charset=""utf-8""><title>登录授权</title></head>
<body style=""font-family:sans-serif;max-width:480px;margin:40px auto"">
<h2>登录并授权</h2>
<p>应用 <code>{Escape(clientId)}</code> 请求访问你的 MCP 服务,权限范围:<b>{Escape(scope)}</b></p>
{errHtml}
<form method=""post"" action=""/authorize/consent"">
<label>用户名:<br/><input type=""text"" name=""username"" value=""{userVal}"" style=""width:100%;padding:8px;margin:6px 0;box-sizing:border-box"" autocomplete=""username"" /></label><br/>
<label>密码:<br/><input type=""password"" name=""password"" value="""" style=""width:100%;padding:8px;margin:6px 0;box-sizing:border-box"" autocomplete=""current-password"" /></label><br/>
<input type=""hidden"" name=""client_id"" value=""{Escape(clientId)}"" />
<input type=""hidden"" name=""redirect_uri"" value=""{Escape(redirectUri)}"" />
<input type=""hidden"" name=""state"" value=""{Escape(state)}"" />
<input type=""hidden"" name=""code_challenge"" value=""{Escape(codeChallenge)}"" />
<input type=""hidden"" name=""scope"" value=""{Escape(scope)}"" />
<button type=""submit"" style=""padding:10px 24px;font-size:16px;margin-top:8px"">授权</button>
</form>
</body></html>";
}

// 规整 scope:去掉空白,保证默认 scope 存在。
// offline_access 只是一个"我要刷新令牌"的信号,不作为资源权限参与校验,故保留在串里不影响鉴权。
static string NormalizeScope(string? scope)
{
    var parts = (scope ?? "")
        .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
    if (parts.Length == 0) return "mcp:tools";
    return string.Join(' ', parts);
}

// requested 是否为 granted 的子集(RFC 6749 §6:刷新时不得放大权限)
static bool IsSubsetScope(string requested, string granted)
{
    var grantedSet = granted.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToHashSet(StringComparer.Ordinal);
    return requested.Split(' ', StringSplitOptions.RemoveEmptyEntries)
        .All(s => grantedSet.Contains(s));
}

// 日志里只打印令牌前 10 位,避免把完整凭据写进控制台
static string Short(string? token) =>
    string.IsNullOrEmpty(token) ? "<none>" : token.Length <= 10 ? token : token[..10] + "...";

// 解析 --name=value / --name value 形式的整数选项,缺省时回退到默认值
static int ResolveIntOption(string[] args, string name, int fallback)
{
    var flag = "--" + name;
    for (var i = 0; i < args.Length; i++)
    {
        if (args[i].StartsWith(flag + "=", StringComparison.Ordinal))
            return int.TryParse(args[i][(flag.Length + 1)..], out var v) ? v : fallback;
        if (args[i] == flag && i + 1 < args.Length && int.TryParse(args[i + 1], out var next))
            return next;
    }
    return fallback;
}

static string Base64UrlEncode(byte[] data) =>
    Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');

static byte[] Base64UrlDecode(string s) =>
    Convert.FromBase64String(s.Replace('-', '+').Replace('_', '/') +
        new string('=', (4 - s.Length % 4) % 4));

static string Sha256Base64Url(string input) =>
    Base64UrlEncode(SHA256.HashData(Encoding.ASCII.GetBytes(input)));

// 允许:loopback(http/https localhost/127.0.0.1) + 私有 URI scheme(RFC 8252 native app, 如 workbuddy://)
// 拒绝:http/https 非 loopback(防授权码泄漏到外网) + javascript:/data:/vbscript 等危险 scheme
static bool IsAllowedRedirect(string? uri)
{
    if (string.IsNullOrEmpty(uri)) return false;
    if (!Uri.TryCreate(uri, UriKind.Absolute, out var u)) return false;

    if (u.Scheme is "http" or "https")
        return u.Host is "localhost" or "127.0.0.1" or "[::1]";

    if (u.Scheme is "javascript" or "data" or "vbscript") return false;
    return Regex.IsMatch(u.Scheme, @"^[a-z][a-z0-9+.\-]*$");
}

static string Escape(string? s) =>
    string.IsNullOrEmpty(s) ? "" : s.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;").Replace("\"", "&quot;");

record AuthCodeRecord(string CodeChallenge, string RedirectUri, string State, string Scope, string ClientId, DateTime CreatedAt,
    string UserId, string Username, string DisplayName);

// 令牌的归属主体:访问令牌与刷新令牌共用,保证刷新后 sub 不漂移(否则用户数据会串号)
record TokenSubject(string UserId, string Username, string DisplayName);

// 刷新令牌状态机:Active --(使用一次)--> Rotated --(再次出现)--> 整条链 Revoked
enum RefreshTokenStatus
{
    Active,   // 当前可用
    Rotated,  // 已被用来换新令牌,不再有效;保留记录是为了识别重放
    Revoked,  // 整条令牌链因重放而失效
}

// FamilyId 把一次授权后产生的所有刷新令牌串成一条链:
// 重放检测命中时,撤销的是整条链而不是单个令牌。
sealed class RefreshTokenRecord
{
    public required string Token { get; init; }
    public required string FamilyId { get; init; }
    public required string ClientId { get; init; }
    public required TokenSubject Subject { get; init; }
    public required string Scope { get; init; }
    public required DateTime CreatedAt { get; init; }
    public required DateTime ExpiresAt { get; init; }
    public RefreshTokenStatus Status { get; set; } = RefreshTokenStatus.Active;
}

// 动态注册的客户端信息(RFC 7591)
record RegisteredClient(string ClientId, string? ClientName, string[] RedirectUris, string[] GrantTypes,
    string? Scope, string TokenEndpointAuthMethod);

// 固定用户记录(本地实验用)。真实部署请改为数据库 + 密码哈希存储。
record UserInfo(string UserId, string Username, string Password, string DisplayName);

依赖与第二篇相同:Microsoft.IdentityModel.Tokens 与 System.IdentityModel.Tokens.Jwt。

八、小结与下篇预告

本篇完成的事:

  1. 授权服务器增量支持 refresh_token grant:元数据声明(SEP-2207)、令牌链存储、签发与兑换;
  2. 按 OAuth 2.1 §4.14.2 实现轮换 + 重放检测:旧令牌标记 Rotated 保留记录,重放即整链撤销;
  3. 资源服务器两处配合:ClockSkew 收紧到 5 秒、工具暴露令牌过期时间,刷新全程可观测。

遗留问题只剩一个,也是 JWT 的结构性难题:撤销刷新令牌(或重放触发整链撤销)之后,已签发的访问令牌是无状态 JWT,依然能活到自然过期。30 秒的残留窗口无所谓,1 小时就很难说无所谓了------用户"登出"、管理员"踢人"这类场景都要求立即生效。下一篇正面解决这个问题:给访问令牌加上即时吊销能力(JTI 吊销列表 + RFC 7009 撤销端点),系列收官。

参考

  • OAuth 2.1(draft-ietf-oauth-v2-1)§4.14.2 刷新令牌轮换与重放检测、§7.1 短生命周期访问令牌:datatracker.ietf.org/doc/html/dr...
  • RFC 9700 --- Best Current Practice for OAuth 2.0 Security(公共客户端强制轮换)
  • SEP-2207 --- OIDC-Flavored Refresh Token Guidance(offline_access 与 grant_types 的约定)
  • RFC 6749 --- OAuth 2.0 授权框架(§6 刷新时 scope 不得放大)
  • MCP 官方授权教程:modelcontextprotocol.org/docs/2026-0...

我是小金,如果这篇帮你解决了一个小问题,欢迎关注我的公众号:小金的研发日志。

相关推荐
Dumbness2 小时前
写 MCP 服务器踩的坑:stdout 是协议通道,一行不带换行的日志就能让客户端超时
node.js·mcp
黑黑的脸蛋3 小时前
同时用 Claude Code 和 Codex,我做了个 Agent 资产库:能预览、能投递、能追更新
ai编程·claude·mcp
xrlfreedom21 小时前
MCP 面试高频考点:如何用 TypeScript SDK 设计带人工确认的高风险 Tool?
mcp·最小权限与审计日志·stdio 传输·typescript mcp sdk
余槐i1 天前
Claude Code 切换中转站后 401 不断:先查环境变量覆盖顺序
api·开发工具·mcp·claude code·cc-switch
小金的研发日志1 天前
ASP.NET 手搓 MCP 服务(三):让 MCP 服务只认访问令牌
mcp
Danny转行做跨境1 天前
我用 MCP 盯亚马逊选品排名?47 题自动监控这样搭
跨境电商·mcp·sorftime
挖掘狂人1 天前
2026 年,为什么一线团队开始从 MCP 撤回 CLI?
人工智能·agent·mcp
光依旧2 天前
飞书卡片消息构建实战:从文本到可交互富卡片
飞书·mcp·退避重试·卡片消息·按钮回调·频控
AI前端工程狮2 天前
从 Function Calling 到 MCP:用 TypeScript 写一个 AI 助手能用的工具服务
mcp