ASP.NET 手搓 MCP 服务(三):让 MCP 服务只认访问令牌

本文是《ASP.NET 手搓 MCP 服务》系列的第三篇。上一篇自建的 LocalOAuthServer:7031 已经能走通授权码 + PKCE 流程、签发合法 JWT。本篇回到第一篇的 TaskMcpServer:7030,给它上锁:配上 JwtBearer 验签、挂上授权保护,把工具里的 userId 参数换成令牌中的 sub,最后在 WorkBuddy 里跑通端到端授权。

一、资源服务器的职责

先明确 TaskMcpServer 在这条链路里的角色------它是 OAuth 意义上的受保护资源(Protected Resource),职责非常克制:

graph LR WB[WorkBuddy 客户端] -->|MCP Streamable HTTP| MCP[TaskMcpServer:7030<br/>资源服务] WB -->|OAuth 授权码 + PKCE| AUTH[LocalOAuthServer:7031<br/>授权服务器] MCP -->|JWKS 公钥验签| AUTH
  • TaskMcpServer:7030 承载工具,通过 JwtBearer 校验访问令牌;
  • LocalOAuthServer:7031(上一篇产物)负责登录、签发授权码与 JWT;
  • WorkBuddy 作为公共客户端(Native App,走 PKCE,不持有 client_secret)。

关键点:两个进程是解耦的,唯一的联系是 JWKS 公钥。TaskMcpServer 启动时从授权服务器拉一次元数据和公钥,之后所有验签都在本地完成------不回调、不解密、不感知授权服务器的运行时状态。

二、三处改动

TaskMcpServer 相对第一篇的改动集中在 Program.cs 的注册段,只有三处。

  1. 认证管线。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"],
    };
});
  1. 端点保护。MapMcp() 挂上 RequireAuthorization(),未携带合法令牌的请求一律 401:
csharp 复制代码
app.UseAuthentication();
app.UseAuthorization();
app.MapMcp().RequireAuthorization();
  1. 观测日志。联调阶段在 JwtBearerEvents 里打印验签结果------过期、签名失败、受众不匹配在日志里一眼可辨,比盯着客户端状态猜要快得多(完整代码见第七章)。

三、协议时序

两端都就绪后,完整流程(本篇暂不含刷新,令牌过期即重新授权):

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: /.well-known/oauth-authorization-server(发现) WB->>AUTH: POST /register(动态注册,获取 client_id) WB->>AUTH: GET /authorize(登录页,携带 code_challenge) AUTH-->>WB: 302 重定向 workbuddy://...?code=xxx&state=yyy WB->>AUTH: POST /token(code + code_verifier) AUTH-->>WB: access_token(RS256 JWT,sub=用户标识) WB->>MCP: 携带 Bearer token 的请求 MCP-->>WB: 200 工具结果(按 sub 隔离)

决定联调成败的字段,须严格对齐:

字段 取值 对齐关系
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 回调)

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

相关推荐
Danny转行做跨境6 小时前
我用 MCP 盯亚马逊选品排名?47 题自动监控这样搭
跨境电商·mcp·sorftime
挖掘狂人6 小时前
2026 年,为什么一线团队开始从 MCP 撤回 CLI?
人工智能·agent·mcp
光依旧20 小时前
飞书卡片消息构建实战:从文本到可交互富卡片
飞书·mcp·退避重试·卡片消息·按钮回调·频控
AI前端工程狮1 天前
从 Function Calling 到 MCP:用 TypeScript 写一个 AI 助手能用的工具服务
mcp
Mutualgoodnight1 天前
我把麦当劳官方 MCP 的 35 个工具全测了一遍,几个坑文档里没写
mcp
xrlfreedom1 天前
MCP 面试高频考点:如何用 JSON Schema、OAuth 2.1 与 OpenTelemetry 落地可复用 Prompts 工作流
json schema·opentelemetry·mcp·oauth 2.1
盈米AI开放平台1 天前
DeepSeek怎么辅助投资理财?MCP接入与能力边界说明
人工智能·金融·投资理财·deepseek·mcp
余槐i1 天前
Claude Code Skills 放对目录却不生效:插件启用顺序与缓存的三个坑
git·开发工具·ai agent·mcp·claude code
古法老登瞎折腾1 天前
大数据智能运维实战(三):实现第一个运维 MCP 工具——查询节点与组件状态
java·大数据·运维·人工智能·spring ai·mcp