本文是《ASP.NET 手搓 MCP 服务》系列的第三篇。上一篇自建的 LocalOAuthServer:7031 已经能走通授权码 + PKCE 流程、签发合法 JWT。本篇回到第一篇的 TaskMcpServer:7030,给它上锁:配上 JwtBearer 验签、挂上授权保护,把工具里的 userId 参数换成令牌中的 sub,最后在 WorkBuddy 里跑通端到端授权。
一、资源服务器的职责
先明确 TaskMcpServer 在这条链路里的角色------它是 OAuth 意义上的受保护资源(Protected Resource),职责非常克制:
- TaskMcpServer:7030 承载工具,通过 JwtBearer 校验访问令牌;
- LocalOAuthServer:7031(上一篇产物)负责登录、签发授权码与 JWT;
- WorkBuddy 作为公共客户端(Native App,走 PKCE,不持有 client_secret)。
关键点:两个进程是解耦的,唯一的联系是 JWKS 公钥。TaskMcpServer 启动时从授权服务器拉一次元数据和公钥,之后所有验签都在本地完成------不回调、不解密、不感知授权服务器的运行时状态。
二、三处改动
TaskMcpServer 相对第一篇的改动集中在 Program.cs 的注册段,只有三处。
- 认证管线。AddJwtBearer 指向授权服务器,AddMcp 声明受保护资源元数据(RFC 9728,客户端拿到 401 后靠它找到授权服务器):
csharp
builder.Services.AddAuthentication(options =>
{
options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.RequireHttpsMetadata = false; // 本地实验用 http 授权服务器,允许非 HTTPS 元数据地址
options.Authority = inMemoryOAuthServerUrl;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidAudience = serverUrl, // aud 必须与资源自身地址完全一致(RFC 8707)
ValidIssuer = inMemoryOAuthServerUrl,
NameClaimType = "name",
RoleClaimType = "roles",
};
})
.AddMcp(options =>
{
options.ResourceMetadata = new()
{
AuthorizationServers = { inMemoryOAuthServerUrl },
ScopesSupported = ["mcp:tools"],
};
});
- 端点保护。MapMcp() 挂上 RequireAuthorization(),未携带合法令牌的请求一律 401:
csharp
app.UseAuthentication();
app.UseAuthorization();
app.MapMcp().RequireAuthorization();
- 观测日志。联调阶段在 JwtBearerEvents 里打印验签结果------过期、签名失败、受众不匹配在日志里一眼可辨,比盯着客户端状态猜要快得多(完整代码见第七章)。
三、协议时序
两端都就绪后,完整流程(本篇暂不含刷新,令牌过期即重新授权):
决定联调成败的字段,须严格对齐:
| 字段 | 取值 | 对齐关系 |
|---|---|---|
| iss | http://localhost:7031 | 授权服务器签发 ↔ JwtBearer 的 ValidIssuer |
| aud | http://localhost:7030/ | 授权服务器签发 ↔ ValidAudience,注意末尾斜杠 |
| sub | 用户标识(u-1001 等) | 授权服务器写入 ↔ 工具内用户隔离依据 |
| jwks_uri | http://localhost:7031/.well-known/jwks | OIDC 发现文档 ↔ JwtBearer 拉公钥验签 |
| code_challenge | S256(code_verifier) | 授权服务器登记 ↔ /token 校验 |
| expires_in | 3600 | 授权服务器返回 ↔ 客户端判断令牌有效期 |
audience 末尾的斜杠是高频出错点:资源服务自身地址为 http://localhost:7030/,令牌中的 aud 必须与其完全一致(含斜杠),否则 JwtBearer 判定 audience 不匹配,报 401 且日志里只有一句含糊的 "invalid audience"。
四、用户身份在工具中的落地
链路打通后,工具要消费令牌里的用户身份。此处有一个 .NET 特有的细节:
JwtBearer 默认会把 JWT 的 sub claim 重映射为 ClaimTypes.NameIdentifier。工具里只 FindFirst("sub") 会读不到。两种处理:读取时双路兜底,或者在启动早期清除 inbound 映射让 sub 原样保留。本文两者都做:
csharp
// 启动早期:保留 JWT 原始 claim 名称(如 sub)
System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler
.DefaultInboundClaimTypeMap.Clear();
// 工具内:双路兜底读取
private static string? GetCurrentUserId() =>
CurrentUserAccessor.HttpContextAccessor?.HttpContext?.User?.FindFirst("sub")?.Value
?? CurrentUserAccessor.HttpContextAccessor?.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
第一篇埋的线在这里收掉:userId 参数从所有工具签名中删除。对比第一篇的工具签名:
csharp
// 第一篇:身份由调用方随口指定
public static Task<TaskItem[]> GetTasksAsync(string userId)
// 本篇:身份由令牌决定,调用方无法伪造
public static Task<TaskItem[]> GetMyTasksAsync()
数据源不用动------第一篇就按用户 id 分桶了,授权服务器签发的 sub(u-1001 / u-1002 / u-1003)正好落在三个桶上。分别以 alice、bob 登录调用 get_my_tasks / get_current_user,各自只能看到自己的任务------具体隔离效果的配图见第五节「WorkBuddy 端到端联调」。
五、WorkBuddy 端到端联调
WorkBuddy 侧的 mcp.json 与第一篇完全一样------不要在 headers 中预置令牌,保留 401 以便客户端自行走 OAuth:
json
{
"mcpServers": {
"taskServer": {
"type": "streamableHttp",
"url": "http://localhost:7030/",
"disabled": false
}
}
}
启动顺序:先启动 LocalOAuthServer:7031,再启动 TaskMcpServer:7030(资源服务器启动时要拉授权服务器的元数据)。若此前缓存过旧令牌,先 disconnect 再 reconnect。
首次使用的授权操作分四步,对应第三章时序中的"发现 → 授权 → 换取令牌":
第一步,保存连接器后显示"需要授权"。资源服务对无令牌请求返回 401,WorkBuddy 据此提示授权。

第二步,点击授权跳转到登录页。WorkBuddy 打开 http://localhost:7031/authorize,呈现用户名 + 密码表单。

第三步,登录授权,提示回到 WorkBuddy。凭据校验通过后签发授权码,302 重定向把授权码带回 workbuddy:// 私有回调地址。

第四步,回到 WorkBuddy,连接就绪。客户端已用授权码换取访问令牌,连接器进入可用状态,可正常调用工具。



六、排障速查表
联调中各类典型现象与根因归属,授权服务器侧的问题回指第二篇对应小节:
| 现象 | 根因 | 回指 |
|---|---|---|
| 连接器保存即 connect failed | 注册响应 redirect_uris 缺失或不是数组 | 第二篇 §四 |
| TaskMcpServer 启动报 500 | OIDC 发现文档缺 jwks_uri,或 Authority 为 http 未设 RequireHttpsMetadata = false | 第二篇 §三 / 本篇 §二 |
| WorkBuddy 报 fetch failed | Node 侧拒绝自签证书,授权服务器 https 改 http | 第二篇 §八(本地取舍) |
| 永远停在"授权中",无人报错 | 授权服务器重启后 kid 变化,JWKS 缓存失配 | 第二篇 §七(密钥持久化) |
| 401,日志提示 invalid audience | aud 末尾斜杠与资源地址不一致 | 本篇 §三 |
| 工具里读不到 sub | JwtBearer 默认 inbound claim 重映射 | 本篇 §四 |
| 授权后浏览器无响应 | redirect_uri 私有 scheme 被服务端拦截 | 第二篇 §五 |
七、完整源码
文件:TaskMcpServer/Program.cs(授权服务器源码见第二篇)
csharp
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using ModelContextProtocol.AspNetCore.Authentication;
using ModelContextProtocol.Server;
using System.ComponentModel;
using System.Security.Claims;
namespace TaskMcpServer
{
public class Program
{
public static void Main(string[] args)
{
var serverUrl = "http://localhost:7030/";
// 本地实验用 http 消除 TLS 证书变量,便于观测 WorkBuddy 客户端是否来访(真实部署改回 https)
var inMemoryOAuthServerUrl = "http://localhost:7031";
// 保留 JWT 原始 claim 名称(如 sub),避免 JwtBearer 默认 inbound map 把 sub 改写为 NameIdentifier,
// 便于工具内用 FindFirst("sub") 直接读取当前用户 id。
System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(options =>
{
options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.RequireHttpsMetadata = false; // 本地实验用 http 授权服务器,允许非 HTTPS 元数据地址
options.Authority = inMemoryOAuthServerUrl;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidAudience = serverUrl, // Validate that the audience matches the resource metadata as suggested in RFC 8707
ValidIssuer = inMemoryOAuthServerUrl,
NameClaimType = "name",
RoleClaimType = "roles",
};
options.Events = new JwtBearerEvents
{
OnTokenValidated = context =>
{
var sub = context.Principal?.FindFirst("sub")?.Value ?? "unknown";
var name = context.Principal?.FindFirst("name")?.Value ?? "unknown";
Console.WriteLine($"Token validated for: {name} (sub={sub})");
return Task.CompletedTask;
},
OnAuthenticationFailed = context =>
{
// 必须区分"过期"与"其它失败":
// 过期在本篇意味着用户需要重新登录(第四篇引入刷新令牌后,它会成为刷新流程的正常触发点);
// 签名/受众/签发者失败则是配置或密钥问题,客户端只能重新走完整授权。
var isExpired = context.Exception is SecurityTokenExpiredException;
Console.WriteLine($"Authentication failed [{(isExpired ? "token expired" : "invalid token")}]: " +
$"{context.Exception.Message}");
return Task.CompletedTask;
},
};
})
.AddMcp(options =>
{
options.ResourceMetadata = new()
{
AuthorizationServers = { inMemoryOAuthServerUrl },
ScopesSupported = ["mcp:tools"],
};
});
builder.Services.AddAuthorization();
builder.Services.AddHttpContextAccessor();
builder.Services.AddMcpServer()
.WithHttpTransport(options =>
{
options.Stateless = true;
})
.WithToolsFromAssembly();
var app = builder.Build();
// 供 MCP 工具在不依赖 DI 注入的情况下读取当前登录用户
CurrentUserAccessor.HttpContextAccessor = app.Services.GetRequiredService<IHttpContextAccessor>();
app.UseAuthentication();
app.UseAuthorization();
app.MapMcp().RequireAuthorization();
app.Run(serverUrl);
}
}
// 静态持有 IHttpContextAccessor,供 MCP 工具在静态方法内读取当前请求用户
public static class CurrentUserAccessor
{
public static IHttpContextAccessor? HttpContextAccessor { get; set; }
}
[McpServerToolType, Description("我的任务工具")]
public static class MyTaskTool
{
public class TaskItem
{
public required string Id { get; set; }
public required string Description { get; set; }
public bool IsCompleted { get; set; } = false;
}
// 每个用户自己的任务(按用户 id 分桶)。真实部署应改为数据库按 user_id 查询。
private static readonly Dictionary<string, List<TaskItem>> _tasksByUser = new()
{
["u-1001"] = new()
{
new() { Id = "1", Description = "Alice 的任务:完成 MCP 服务端工具的开发" },
new() { Id = "2", Description = "Alice 的任务:完成 MCP 服务端工具的测试" },
},
["u-1002"] = new()
{
new() { Id = "1", Description = "Bob 的任务:编写项目计划" },
new() { Id = "2", Description = "Bob 的任务:评审设计文档" },
new() { Id = "3", Description = "Bob 的任务:部署到测试环境" },
},
["u-1003"] = new()
{
new() { Id = "1", Description = "Carol 的任务:准备每周周报" },
},
};
// 从当前已认证用户的 JWT 中读取用户 id(claim: sub)。
// 注意:JwtBearer 默认会把 sub 改写为 ClaimTypes.NameIdentifier,故两者都尝试。
private static string? GetCurrentUserId() =>
CurrentUserAccessor.HttpContextAccessor?.HttpContext?.User?.FindFirst("sub")?.Value
?? CurrentUserAccessor.HttpContextAccessor?.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
private static List<TaskItem>? GetCurrentUserTasks()
{
var userId = GetCurrentUserId();
return string.IsNullOrEmpty(userId) ? null : _tasksByUser.GetValueOrDefault(userId);
}
[McpServerTool, Description("获取当前登录用户的信息(用户id / 用户名 / 显示名)")]
public static Task<CurrentUser> GetCurrentUserAsync()
{
var user = CurrentUserAccessor.HttpContextAccessor?.HttpContext?.User;
return Task.FromResult(new CurrentUser
{
UserId = GetCurrentUserId() ?? "anonymous",
Username = user?.FindFirst("preferred_username")?.Value ?? "unknown",
DisplayName = user?.FindFirst("name")?.Value ?? "unknown",
});
}
[McpServerTool, Description("获取当前登录用户自己的任务")]
public static Task<TaskItem[]> GetMyTasksAsync()
{
var tasks = GetCurrentUserTasks();
return Task.FromResult(tasks is null ? Array.Empty<TaskItem>() : tasks.ToArray());
}
[McpServerTool, Description("标记当前登录用户的某条任务为已完成")]
public static Task<string> MarkTaskAsCompleteAsync(string taskId)
{
var tasks = GetCurrentUserTasks();
if (tasks is null)
return Task.FromResult("未找到当前用户或该用户没有任务。");
var task = tasks.FirstOrDefault(t => t.Id == taskId);
if (task is null)
return Task.FromResult($"任务ID {taskId} 不存在(或不属于当前用户)。");
task.IsCompleted = true;
return Task.FromResult($"任务ID {taskId} 已标记为已完成。");
}
}
public class CurrentUser
{
public string UserId { get; set; } = "";
public string Username { get; set; } = "";
public string DisplayName { get; set; } = "";
}
}
依赖:TaskMcpServer.csproj 需引用 ModelContextProtocol.AspNetCore。
八、小结与下篇预告
本篇完成的事:
- TaskMcpServer 三处改动接入鉴权:JwtBearer 验签、RFC 9728 资源元数据、RequireAuthorization 端点保护;
- sub 取代 userId 参数成为用户身份的唯一来源,数据按用户隔离;
- WorkBuddy 四步授权全流程跑通,并以排障速查表收拢了前两篇所有的坑。
遗留问题很直接:访问令牌 1 小时过期后,用户必须重新登录。但令牌又不能简单签发成长期有效------公共客户端无法安全保管长期凭据。下一篇引入刷新令牌:短访问令牌 + 可轮换的刷新令牌,以及规范里两条极易被忽略的硬性要求(强制轮换与重放检测)。
参考
- MCP 官方授权教程:modelcontextprotocol.org/docs/2026-0...
- RFC 9728 --- OAuth 2.0 Protected Resource Metadata
- RFC 8707 --- Resource Indicators(aud 与资源地址对齐)
- RFC 8414 --- OAuth 2.0 Authorization Server Metadata
- RFC 8252 --- OAuth 2.0 for Native Apps(私有 URI scheme 回调)
我是小金,如果这篇帮你解决了一个小问题,欢迎关注我的公众号:小金的研发日志。