Files
zhengchen.tao 28f9a54ba9
Build Docker Image / build (push) Failing after 9m21s
Build Docker Image / deploy (push) Has been skipped
obsidian-mcp: 初次落地 Obsidian Vault MCP Server (.NET 10, read+write)
把 Obsidian vault 通过 MCP 暴露给 Claude.ai,OAuth 走 nas-auth。
设计文档见 vault Coding/obsidian-mcp/obsidian-mcp 设计.md。
代码层落地参考 vault Coding/obsidian-mcp/MCP 实现指南.md。

V1+V2 同时实现(用户要求跳过分阶段直接全部):

读 Tools(需 scope=read:obsidian):
- list_vault_tree(一次性 vault 地图,限制深度)
- list_files / read_file(含 offset/limit 大文件分页)
- search(子串匹配 + glob 过滤,最多 50 hits)
- get_metadata(size / modified_at / has_frontmatter)

写 Tools(需 scope=write:obsidian):
- write_file / append_file
- 多重门禁:scope 校验 + 路径黑名单 + 写入白名单 + 永禁文件
  - 永禁写:任意目录的 AGENTS.md / PROFILE.md / README.md / CLAUDE.md / 01-Secret/**
  - 白名单:02-ShengquGames/logs/ + Coding/ + NAS/NAS 待办清单.md
- 写入审计日志按天 rotate(JSON line)

安全:
- VaultPathResolver chroot:path traversal + symlink 双拒绝
- JwtBearer (HS256, Current+Previous fallback, MapInboundClaims=false)
- aud=obsidian, iss=https://auth.zhengchentao.win
- 黑名单:01-Secret / .obsidian / .trash / .git

技术栈:
- .NET 10 + ModelContextProtocol SDK 1.0
- Streamable HTTP transport (POST /mcp)
- JwtBearer 10.0 + IdentityModel.Tokens 8.x

部署:
- Dockerfile multi-stage,runtime 装 ripgrep(V3 备用),non-root user
- .gitea/workflows/build-image.yml:build + deploy 双 job,buildkit v0.13.2
- 容器内 :8080,宿主端口 9090
- 子域名 obs.zhengchentao.win
- vault 挂载 /volume1/docker/webdav/data/Zhengchen:/vault:rw(V2 写入需要 rw)

测试:35/35 单测过(VaultPathResolver path traversal/blacklist/symlink + VaultWriteGuard whitelist/forbidden)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 01:32:11 +08:00

86 lines
3.0 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.AspNetCore.Authorization;
namespace ObsidianMcp.Auth;
/// <summary>
/// 自定义 scope 校验 Policy
/// RequireScope("read:obsidian")
/// RequireScope("write:obsidian")
///
/// JWT 的 scope claim 可能是单个字符串(空格分隔)或多个 claim,两种都处理。
/// </summary>
public static class ScopePolicies
{
public const string ReadObsidian = "read:obsidian";
public const string WriteObsidian = "write:obsidian";
/// <summary>注册两条 scope policy 到 AuthorizationOptions。</summary>
public static void AddScopePolicies(this AuthorizationOptions opts)
{
opts.AddPolicy(ReadObsidian, policy =>
policy.RequireAuthenticatedUser()
.AddRequirements(new ScopeRequirement(ReadObsidian)));
opts.AddPolicy(WriteObsidian, policy =>
policy.RequireAuthenticatedUser()
.AddRequirements(new ScopeRequirement(WriteObsidian)));
}
}
// ---------- Requirement ----------
public class ScopeRequirement(string scope) : IAuthorizationRequirement
{
public string RequiredScope { get; } = scope;
}
// ---------- Handler ----------
public class ScopeAuthorizationHandler : AuthorizationHandler<ScopeRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ScopeRequirement requirement)
{
// scope claim 在 JWT 里可能是一整个空格分隔的字符串,也可能是多个 claim。
// OAuth 2.0 (RFC 6749) 规定 scope 大小写敏感,按 Ordinal 比对。
var scopes = context.User
.FindAll("scope")
.SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries))
.ToHashSet(StringComparer.Ordinal);
if (scopes.Contains(requirement.RequiredScope))
context.Succeed(requirement);
return Task.CompletedTask;
}
}
// ---------- Per-tool scope guard helper ----------
/// <summary>
/// MCP Tool 内部 scope 校验:从当前 HttpContext.User 读 scope claim
/// 不包含 requiredScope 时抛 UnauthorizedAccessException。
///
/// 用法:在每个读 / 写 Tool 的方法体首行调一下,给客户端可读的失败原因。
/// 端点级 RequireAuthorization 只确保 JWT 验签通过;scope 颗粒度门禁在这里。
/// OAuth 2.0 (RFC 6749) 规定 scope 大小写敏感。
/// </summary>
public static class ToolScopeGuard
{
public static void EnsureScope(IHttpContextAccessor http, string requiredScope)
{
var ctx = http.HttpContext
?? throw new InvalidOperationException("无 HttpContext,无法校验 scope。");
var scopes = ctx.User
.FindAll("scope")
.SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries))
.ToHashSet(StringComparer.Ordinal);
if (!scopes.Contains(requiredScope))
throw new UnauthorizedAccessException(
$"当前 Token 缺少所需 scope{requiredScope}");
}
}