节点在线、应用在线、配置在线使用令牌查询
大石头 authored at 2021-12-16 19:49:30
15.79 KiB
Stardust
using System.Diagnostics;
using System.Reflection;
using System.Text.Json;
using NewLife;
using NewLife.Log;
using Stardust.Data.Platform;

namespace Stardust.Mcp;

/// <summary>MCP协议引擎。工具直出 tools/list(按scope与域白名单裁剪)、令牌鉴权、风险分级、审计双策略。
/// 设计见 Doc/MCP-4-平台MCP重新设计.md(v1.2)。仅依赖 Stardust.Data;宿主配置经 StardustMcpOptions 委托注入。</summary>
public class McpEngine
{
    private readonly StardustMcpOptions _options;
    private readonly Dictionary<String, IMcpTool> _tools = new(StringComparer.OrdinalIgnoreCase);

    /// <summary>支持的MCP协议版本(从新到旧)。initialize时按客户端请求协商</summary>
    internal static readonly String[] SupportedProtocolVersions =
    {
        "2026-07-28",
        "2025-06-18",
        "2025-03-26",
        "2024-11-05",
    };

    /// <summary>实例化引擎,扫描 Stardust.Mcp 程序集中的工具</summary>
    public McpEngine(StardustMcpOptions options) : this(options, typeof(McpEngine).Assembly) { }

    /// <summary>实例化引擎并扫描指定程序集中的工具</summary>
    public McpEngine(StardustMcpOptions options, params Assembly[] toolAssemblies)
    {
        _options = options ?? throw new ArgumentNullException(nameof(options));
        // 注入敏感 key 关键词列表到 McpGuard(运行时委托读取,宿主从 StarServerSetting 注入)
        var kws = options.SensitiveKeyKeywords?.Invoke();
        if (kws != null && kws.Length > 0)
            McpGuard.SensitiveKeyKeywords = kws;
        foreach (var asm in toolAssemblies.Length == 0 ? new[] { typeof(McpEngine).Assembly } : toolAssemblies)
            RegisterTools(asm);
        XTrace.WriteLine("MCP engine initialized with {0} tool(s)", _tools.Count);
    }

    private void RegisterTools(Assembly asm)
    {
        var iface = typeof(IMcpTool);
        foreach (var type in asm.GetTypes())
        {
            if (!type.IsClass || type.IsAbstract || !iface.IsAssignableFrom(type)) continue;
            var tool = (IMcpTool)Activator.CreateInstance(type)!;
            if (tool.Name.IsNullOrEmpty()) continue;
            _tools[tool.Name] = tool;
            XTrace.WriteLine("MCP tool registered: {0} ({1}, {2})", tool.Name, tool.Risk, tool.ScopeDomain);
        }
    }

    /// <summary>已注册工具(测试与自检用)</summary>
    public IReadOnlyCollection<IMcpTool> Tools => _tools.Values.ToArray();

    /// <summary>主入口。解析JSON-RPC请求、校验令牌、路由。通知类消息返回202无响应体</summary>
    public async Task<McpReply> HandleAsync(String body, String ip, String ua, String authorization, IServiceProvider? requestServices = null)
    {
        var sw = Stopwatch.StartNew();
        Object? id = null;
        String methodName = String.Empty;

        try
        {
            if (body.IsNullOrEmpty()) return Error(null, -32700, "解析错误:请求体为空");

            JsonElement request;
            try
            {
                request = JsonSerializer.Deserialize<JsonElement>(body);
            }
            catch (Exception ex)
            {
                return Error(null, -32700, "解析错误:" + ex.Message);
            }

            if (request.ValueKind != JsonValueKind.Object) return Error(null, -32600, "无效请求:不是JSON对象");
            if (!request.TryGetProperty("jsonrpc", out var v) || v.GetString() != "2.0")
                return Error(id, -32600, "无效请求:缺少或非法的 jsonrpc 字段");

            // 回显客户端请求的 id,必须保持原始类型(数字/字符串),否则严格客户端按 id 匹配会失败
            if (request.TryGetProperty("id", out var idEl))
            {
                id = idEl.ValueKind switch
                {
                    JsonValueKind.Number => idEl.GetInt64(),
                    JsonValueKind.String => idEl.GetString(),
                    _ => null
                };
            }

            if (!request.TryGetProperty("method", out var methodEl) || (methodName = methodEl.GetString()).IsNullOrEmpty())
                return Error(id, -32600, "无效请求:缺少 method");

            // 通知类消息不需要响应
            if (methodName.StartsWith("notifications/", StringComparison.OrdinalIgnoreCase))
                return new McpReply { StatusCode = 202 };

            // initialize 不需要鉴权
            if (methodName == "initialize") return Ok(id, HandleInitialize(request));
            if (methodName == "ping") return Ok(id, new { });

            // 其余方法都需要令牌鉴权
            var ctx = Authorize(authorization, ip, ua, requestServices);
            switch (methodName)
            {
                case "tools/list":
                    return Ok(id, HandleToolsList(ctx));
                case "tools/call":
                    if (!request.TryGetProperty("params", out var paramsEl))
                        return Error(id, -32602, "无效参数:缺少 params");
                    return Ok(id, await HandleToolsCallAsync(paramsEl, ctx, sw));
                default:
                    return Error(id, -32601, $"未知方法:{methodName}");
            }
        }
        catch (McpException ex)
        {
            XTrace.Log.Warn("[McpEngine] method={0} code={1} {2}", methodName, ex.Code, ex.Message);
            return Error(id, ex.Code, ex.Message);
        }
        catch (Exception ex)
        {
            XTrace.Log.Error("[McpEngine] method={0} error={1}", methodName, ex);
            return Error(id, -32603, "内部错误:" + ex.Message);
        }
    }

    /// <summary>从Authorization头提取Bearer令牌</summary>
    private static String ExtractToken(String? authorization)
    {
        if (authorization.IsNullOrEmpty()) return String.Empty;
        var prefix = "Bearer ";
        if (authorization.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
            return authorization[prefix.Length..].Trim();
        return authorization.Trim();
    }

    /// <summary>令牌鉴权并构造调用上下文(含scope解析)</summary>
    private McpContext Authorize(String? authorization, String ip, String ua, IServiceProvider? requestServices)
    {
        var tokenStr = ExtractToken(authorization);
        if (tokenStr.IsNullOrEmpty()) throw new McpException(-32001, "未经授权:缺少 Bearer 令牌");

        var token = McpToken.FindByToken(tokenStr);
        if (token == null || !McpToken.SafeEquals(token.Token, tokenStr)) throw new McpException(-32001, "未经授权:令牌不存在");
        if (!token.IsValid()) throw new McpException(-32001, "未经授权:令牌已禁用或已过期");

        // 更新调用统计(失败不影响主响应)
        try
        {
            token.RecordCall(ip);
        }
        catch (Exception ex)
        {
            XTrace.Log.Warn("[McpEngine] 更新令牌调用统计失败(忽略):{0}", ex.Message);
        }

        var scopes = new HashSet<String>(StringComparer.OrdinalIgnoreCase);
        if (!token.Scopes.IsNullOrEmpty())
        {
            foreach (var s in token.Scopes.Split(',', StringSplitOptions.RemoveEmptyEntries))
            {
                if (!s.Trim().IsNullOrEmpty()) scopes.Add(s.Trim());
            }
        }

        return new McpContext
        {
            TokenId = token.Id,
            TokenName = token.Name,
            UserId = token.UserId,
            ProjectId = token.ProjectId,
            Scopes = scopes,
            CallerIp = ip,
            UserAgent = ua,
            TraceId = DefaultSpan.Current?.TraceId,
            Services = requestServices,
        };
    }

    /// <summary>处理 initialize。按客户端请求的protocolVersion协商返回</summary>
    private static Object HandleInitialize(JsonElement request)
    {
        var requested = request.TryGetProperty("params", out var p) && p.TryGetProperty("protocolVersion", out var pv)
            ? pv.GetString() : null;
        var negotiated = NegotiateProtocolVersion(requested);
        if (negotiated == null)
            throw new McpException(-32602, $"不支持的协议版本:{requested}。支持:{String.Join(", ", SupportedProtocolVersions)}");

        return new
        {
            protocolVersion = negotiated,
            serverInfo = new { name = "Stardust", version = "2.0.0" },
            instructions = "Stardust distributed platform (nodes, apps, config, deployment, monitoring). Tools are journey-oriented: descriptions are in English; summaries and error messages are in Chinese.",
            capabilities = new { tools = new { } },
        };
    }

    /// <summary>协议版本协商:返回≤客户端请求的最高支持版本;请求为空返回最低支持版;比最低还旧返回null</summary>
    private static String? NegotiateProtocolVersion(String? requested)
    {
        if (requested.IsNullOrEmpty()) return SupportedProtocolVersions[^1];
        foreach (var v in SupportedProtocolVersions)
        {
            if (String.CompareOrdinal(v, requested) <= 0) return v;
        }
        return null;
    }

    /// <summary>处理 tools/list。按域白名单与令牌scope裁剪,客户端看不到无权工具</summary>
    private Object HandleToolsList(McpContext ctx)
    {
        var domains = ParseDomains();
        var tools = _tools.Values
            .Where(t => domains == null || domains.Contains(t.ScopeDomain))
            .Where(t => !t.RequireScope || HasScope(ctx, t))
            .OrderBy(t => t.Name, StringComparer.Ordinal)
            .Select(t => new
            {
                name = t.Name,
                description = t.Description,
                inputSchema = SchemaOrEmpty(t),
            })
            .ToArray();
        return new { tools };
    }

    /// <summary>处理 tools/call。域白名单→scope→L2确认→预写审计→执行→回写审计</summary>
    private async Task<Object> HandleToolsCallAsync(JsonElement @params, McpContext ctx, Stopwatch sw)
    {
        if (!@params.TryGetProperty("name", out var nameEl))
            throw new McpException(-32602, "无效参数:缺少工具名 name");
        var name = nameEl.GetString();

        if (!_tools.TryGetValue(name, out var tool))
            throw new McpException(-32601, $"未知工具:{name}");

        var domains = ParseDomains();
        if (domains != null && !domains.Contains(tool.ScopeDomain))
            throw new McpException(-32601, $"工具 {name} 已被服务端域白名单禁用");

        if (tool.RequireScope && !HasScope(ctx, tool))
            throw new McpException(-32003, $"禁止访问:缺少授权范围 {tool.ScopeDomain}{RequiredScope(tool)}。请联系管理员在「MCP令牌」管理页为该令牌增加对应 scope");

        var arguments = @params.TryGetProperty("arguments", out var argEl) ? argEl : default;

        // L2 高危操作必须显式确认(设计 §4.2)
        if (tool.Risk == McpRiskLevel.L2 &&
            !(arguments.ValueKind == JsonValueKind.Object && arguments.TryGetProperty("confirm", out var c) && c.ValueKind == JsonValueKind.True))
            throw new McpException(-32003, $"高危操作需要确认:{name} 必须在参数中显式传入 confirm:true。{tool.Description}");

        var success = true;
        String? error = null;
        Object content = null;
        McpAudit? audit = null;
        try
        {
            // 写操作审计 fail-closed(设计 §7.1):预写审计行,持久化失败即拒绝执行
            if (tool.Risk != McpRiskLevel.L0)
            {
                try
                {
                    audit = McpAuditWriter.Begin(ctx, tool, arguments.ToString());
                }
                catch (Exception ex)
                {
                    // 拒绝本身尽力留痕(best-effort,此时未执行任何操作)
                    try
                    {
                        McpAudit.WriteAsync(ctx.TokenId, ctx.TokenName, name, null, ctx.CallerIp, ctx.UserAgent,
                            arguments.ToString(), false, "审计写入失败,操作已拒绝:" + ex.Message, 0, ctx.TraceId);
                    }
                    catch { }
                    throw new McpException(-32000, "审计写入失败,已拒绝执行该操作:" + ex.Message);
                }
            }

            content = await tool.InvokeAsync(arguments, ctx);
        }
        catch (McpException ex)
        {
            success = false;
            error = ex.Message;
            throw;
        }
        catch (Exception ex)
        {
            success = false;
            error = ex.Message;
            throw new McpException(-32000, "工具执行失败:" + ex.Message);
        }
        finally
        {
            if (audit != null)
            {
                // 写操作:回写预写的审计行(成功时带上工具返回的结果快照 AfterValue)
                var after = success && content != null ? JsonSerializer.Serialize(content) : null;
                McpAuditWriter.Finish(audit, success, error, (Int32)sw.ElapsedMilliseconds, after);
            }
            else
            {
                // 只读:best-effort 异步审计
                try
                {
                    McpAudit.WriteAsync(ctx.TokenId, ctx.TokenName, name, null, ctx.CallerIp, ctx.UserAgent,
                        arguments.ToString(), success, error, (Int32)sw.ElapsedMilliseconds, ctx.TraceId);
                }
                catch (Exception ex)
                {
                    XTrace.Log.Warn("[McpEngine] 写入审计日志失败(忽略):{0}", ex.Message);
                }
            }
        }

        return new { content = new[] { new { type = "text", text = JsonSerializer.Serialize(content) } } };
    }

    /// <summary>工具所需scope后缀</summary>
    internal static String RequiredScope(IMcpTool tool) => tool.Risk switch
    {
        McpRiskLevel.L0 => ":read",
        McpRiskLevel.L1 => ":write",
        _ => ":admin",
    };

    /// <summary>令牌是否具备工具所需scope</summary>
    internal static Boolean HasScope(McpContext ctx, IMcpTool tool)
        => !tool.RequireScope || ctx.Scopes.Contains(tool.ScopeDomain + RequiredScope(tool));

    private HashSet<String>? ParseDomains()
    {
        var raw = _options.Domains();
        if (raw.IsNullOrEmpty() || raw.Trim() == "*") return null;
        var set = new HashSet<String>(StringComparer.OrdinalIgnoreCase);
        foreach (var s in raw.Split(',', StringSplitOptions.RemoveEmptyEntries))
        {
            if (!s.Trim().IsNullOrEmpty()) set.Add(s.Trim());
        }
        return set.Count == 0 ? null : set;
    }

    private static Object SchemaOrEmpty(IMcpTool tool)
        => tool.InputSchema.ValueKind == JsonValueKind.Undefined || tool.InputSchema.ValueKind == JsonValueKind.Null
            ? (Object)new { type = "object" }
            : tool.InputSchema;

    #region JSON-RPC 构造

    private static String BuildResult(Object? id, Object? result)
        => JsonSerializer.Serialize(new { jsonrpc = "2.0", id, result });

    /// <summary>构造JSON-RPC错误响应(中间件空体场景也复用)</summary>
    public static String BuildError(Object? id, Int32 code, String message)
        => JsonSerializer.Serialize(new { jsonrpc = "2.0", id, error = new { code, message } });

    private static McpReply Ok(Object? id, Object? result) => new() { StatusCode = 200, Body = BuildResult(id, result) };

    private static McpReply Error(Object? id, Int32 code, String message) => new() { StatusCode = 200, Body = BuildError(id, code, message) };
    #endregion
}

/// <summary>MCP处理结果承载对象。StatusCode=202表示通知类无响应体</summary>
public sealed class McpReply
{
    /// <summary>HTTP状态码</summary>
    public Int32 StatusCode { get; init; } = 200;

    /// <summary>响应体JSON</summary>
    public String Body { get; init; }
}