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>
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
**/.git
|
||||||
|
**/.gitignore
|
||||||
|
**/.gitea
|
||||||
|
**/bin
|
||||||
|
**/obj
|
||||||
|
**/*.user
|
||||||
|
**/.vs
|
||||||
|
**/.vscode
|
||||||
|
**/.idea
|
||||||
|
**/test-vault
|
||||||
|
obsidian-mcp.Tests/
|
||||||
|
.dockerignore
|
||||||
|
README.md
|
||||||
|
*.md
|
||||||
|
LICENSE
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
name: Build Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
# 自动触发:push 到 main 分支(纯文档改动跳过)
|
||||||
|
# ⚠️ quirk:git commit --allow-empty 不触发 paths-ignore 过滤的 workflow,
|
||||||
|
# 要强制重新触发必须改一个非 ignore 路径的真实文件(改 build-image.yml 自己最稳)
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
- 'LICENSE'
|
||||||
|
- '.gitignore'
|
||||||
|
- '.dockerignore'
|
||||||
|
# 手动触发:应急通道(重新打包 / 指定自定义 tag)
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
branch:
|
||||||
|
description: '要打包的分支(仅手动触发生效)'
|
||||||
|
required: true
|
||||||
|
default: 'main'
|
||||||
|
tag:
|
||||||
|
description: '镜像 tag(留空则用 commit short hash)'
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
|
||||||
|
# 同分支连续 push 只跑最新的 run,旧 in-progress run 被取消(build + deploy 一起停)
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout target branch
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.branch || github.ref_name }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
# 钉 v0.13.2(runc 1.1.x)避免 DSM 4.4.x 内核不支持 runc 1.2+ 的
|
||||||
|
# openat2/fsmount syscall 导致 build 失败
|
||||||
|
driver-opts: |
|
||||||
|
image=moby/buildkit:v0.13.2
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.zhengchentao.win
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.PACKAGES_TOKEN }}
|
||||||
|
|
||||||
|
- name: Determine image tag and revision
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
if [ -n "${{ inputs.tag }}" ]; then
|
||||||
|
IMAGE_TAG="${{ inputs.tag }}"
|
||||||
|
else
|
||||||
|
IMAGE_TAG="$(git rev-parse --short HEAD)"
|
||||||
|
fi
|
||||||
|
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
|
||||||
|
echo "full_sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||||
|
echo "==> Image tag: $IMAGE_TAG"
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.source=https://git.zhengchentao.win/zhengchen.tao/obsidian-mcp
|
||||||
|
org.opencontainers.image.revision=${{ steps.meta.outputs.full_sha }}
|
||||||
|
tags: |
|
||||||
|
git.zhengchentao.win/zhengchen.tao/obsidian-mcp:${{ steps.meta.outputs.image_tag }}
|
||||||
|
git.zhengchentao.win/zhengchen.tao/obsidian-mcp:latest
|
||||||
|
|
||||||
|
- name: Build summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
{
|
||||||
|
echo "## Build Summary"
|
||||||
|
echo ""
|
||||||
|
echo "| 项 | 值 |"
|
||||||
|
echo "|---|---|"
|
||||||
|
echo "| 触发方式 | \`${{ github.event_name }}\` |"
|
||||||
|
echo "| 源分支 | \`${{ inputs.branch || github.ref_name }}\` |"
|
||||||
|
echo "| 源 commit (full) | \`${{ steps.meta.outputs.full_sha }}\` |"
|
||||||
|
echo "| 源 commit (short) | \`${{ steps.meta.outputs.image_tag }}\` |"
|
||||||
|
echo "| 镜像 | \`git.zhengchentao.win/zhengchen.tao/obsidian-mcp:${{ steps.meta.outputs.image_tag }}\` + \`:latest\` |"
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
# needs: build 串起来 —— build 失败则 deploy 自动 skip,无需 if 条件
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# deploy job 跑在独立 runner 容器上,凭据不从 build job 继承,必须再登一次
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.zhengchentao.win
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.PACKAGES_TOKEN }}
|
||||||
|
|
||||||
|
- name: Pull and restart obsidian-mcp
|
||||||
|
env:
|
||||||
|
NAS_INFRA_TOKEN: ${{ secrets.NAS_INFRA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
|
||||||
|
TMPDIR=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$TMPDIR"' EXIT
|
||||||
|
|
||||||
|
if [ -n "$NAS_INFRA_TOKEN" ]; then
|
||||||
|
CLONE_URL="https://x-access-token:${NAS_INFRA_TOKEN}@git.zhengchentao.win/dev/nas-infra.git"
|
||||||
|
else
|
||||||
|
CLONE_URL="https://git.zhengchentao.win/dev/nas-infra.git"
|
||||||
|
fi
|
||||||
|
|
||||||
|
git clone --depth 1 "$CLONE_URL" "$TMPDIR/nas-infra"
|
||||||
|
cd "$TMPDIR/nas-infra/obsidian-mcp"
|
||||||
|
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
sleep 3
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs --tail=30 obsidian-mcp
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
## .NET
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
.vs/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
## 本地测试 vault(不入库)
|
||||||
|
test-vault/
|
||||||
|
|
||||||
|
## 运行时产物
|
||||||
|
/app/logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
## 开发期 JWT(dotnet user-jwts 签的,不入库)
|
||||||
|
.jwt/
|
||||||
|
|
||||||
|
## 环境变量文件(密钥不入库)
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
appsettings.*.local.json
|
||||||
|
secrets.json
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using ObsidianMcp.Config;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ObsidianMcp.Auth;
|
||||||
|
|
||||||
|
public static class JwtBearerSetup
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 配置 HS256 JWT Bearer 认证。
|
||||||
|
/// 支持 Current + Previous 双密钥,方便密钥轮换过渡期。
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddObsidianJwtBearer(
|
||||||
|
this IServiceCollection services,
|
||||||
|
JwtOptions opts)
|
||||||
|
{
|
||||||
|
services
|
||||||
|
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
|
.AddJwtBearer(options =>
|
||||||
|
{
|
||||||
|
// 关闭默认的入站 claim type 映射,否则 "sub"/"scope" 会被改写成
|
||||||
|
// ClaimTypes.NameIdentifier 之类的长 URI,下游 FindFirst("sub") 取不到。
|
||||||
|
options.MapInboundClaims = false;
|
||||||
|
|
||||||
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidIssuer = opts.Issuer,
|
||||||
|
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidAudience = opts.Audience,
|
||||||
|
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
// Current 必须有值;Previous 可选(密钥轮换过渡期)。
|
||||||
|
// ToList 物化一次,避免每次验签都重建 SymmetricSecurityKey。
|
||||||
|
IssuerSigningKeys = BuildSigningKeys(opts).ToList(),
|
||||||
|
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ClockSkew = TimeSpan.FromMinutes(2),
|
||||||
|
|
||||||
|
// scope claim 的 claim type 直接保持原样,User.FindAll("scope") 能取到。
|
||||||
|
NameClaimType = "sub",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<SecurityKey> BuildSigningKeys(JwtOptions opts)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(opts.SigningKey.Current))
|
||||||
|
throw new InvalidOperationException("Jwt:SigningKey:Current 未配置,服务无法启动。");
|
||||||
|
|
||||||
|
yield return new SymmetricSecurityKey(
|
||||||
|
Encoding.UTF8.GetBytes(opts.SigningKey.Current));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(opts.SigningKey.Previous))
|
||||||
|
yield return new SymmetricSecurityKey(
|
||||||
|
Encoding.UTF8.GetBytes(opts.SigningKey.Previous));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
namespace ObsidianMcp.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// JWT 验签配置。
|
||||||
|
/// 环境变量:Jwt__Issuer, Jwt__Audience, Jwt__SigningKey__Current, Jwt__SigningKey__Previous
|
||||||
|
/// </summary>
|
||||||
|
public class JwtOptions
|
||||||
|
{
|
||||||
|
public const string Section = "Jwt";
|
||||||
|
|
||||||
|
/// <summary>期望的 iss claim,生产:https://auth.zhengchentao.win</summary>
|
||||||
|
public string Issuer { get; set; } = "https://auth.zhengchentao.win";
|
||||||
|
|
||||||
|
/// <summary>期望的 aud claim,生产:obsidian</summary>
|
||||||
|
public string Audience { get; set; } = "obsidian";
|
||||||
|
|
||||||
|
public SigningKeyPair SigningKey { get; set; } = new();
|
||||||
|
|
||||||
|
public class SigningKeyPair
|
||||||
|
{
|
||||||
|
/// <summary>当前签名密钥(HS256 对称密钥),env: Jwt__SigningKey__Current</summary>
|
||||||
|
public string Current { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>上一轮密钥,密钥轮换过渡期用,env: Jwt__SigningKey__Previous(可为空)</summary>
|
||||||
|
public string? Previous { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace ObsidianMcp.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// /.well-known/oauth-authorization-server 返回的元数据配置。
|
||||||
|
/// 环境变量前缀 Mcp__OAuthDiscovery__,例如 Mcp__OAuthDiscovery__Issuer=...
|
||||||
|
/// </summary>
|
||||||
|
public class McpDiscoveryOptions
|
||||||
|
{
|
||||||
|
public const string Section = "Mcp:OAuthDiscovery";
|
||||||
|
|
||||||
|
public string Issuer { get; set; } = "https://auth.zhengchentao.win";
|
||||||
|
public string AuthorizationEndpoint { get; set; } = "https://auth.zhengchentao.win/authorize";
|
||||||
|
public string TokenEndpoint { get; set; } = "https://auth.zhengchentao.win/token";
|
||||||
|
public string RegistrationEndpoint { get; set; } = "https://auth.zhengchentao.win/register";
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace ObsidianMcp.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Vault 根目录与路径安全配置。
|
||||||
|
/// 环境变量前缀 Vault__,例如 Vault__Root=/vault
|
||||||
|
/// </summary>
|
||||||
|
public class VaultOptions
|
||||||
|
{
|
||||||
|
public const string Section = "Vault";
|
||||||
|
|
||||||
|
/// <summary>Vault 根目录的绝对路径,容器内默认 /vault</summary>
|
||||||
|
public string Root { get; set; } = "/vault";
|
||||||
|
|
||||||
|
/// <summary>额外黑名单路径段(与 hardcode 合并),env: Vault__Blacklist__0, __1...</summary>
|
||||||
|
public string[] Blacklist { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>额外写入白名单前缀(与 hardcode 合并),env: Vault__WriteWhitelist__0...</summary>
|
||||||
|
public string[] WriteWhitelist { get; set; } = [];
|
||||||
|
}
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
# ─── Stage 1: build ───────────────────────────────────────────────────────────
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS builder
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# 先只复制 csproj,利用层缓存加速 restore
|
||||||
|
COPY obsidian-mcp.csproj ./
|
||||||
|
RUN dotnet restore obsidian-mcp.csproj
|
||||||
|
|
||||||
|
# 复制全部源码并 publish
|
||||||
|
COPY . .
|
||||||
|
RUN dotnet publish obsidian-mcp.csproj \
|
||||||
|
--configuration Release \
|
||||||
|
--no-restore \
|
||||||
|
--output /app/publish
|
||||||
|
|
||||||
|
# ─── Stage 2: runtime ─────────────────────────────────────────────────────────
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装 ripgrep(V3 搜索优化预留,现在不调用但容器里装上备用)
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ripgrep \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 从 builder 阶段复制 publish 产物
|
||||||
|
COPY --from=builder /app/publish .
|
||||||
|
|
||||||
|
# 日志目录(审计日志挂载点)
|
||||||
|
RUN mkdir -p /app/logs
|
||||||
|
|
||||||
|
# 非 root 运行,安全加固
|
||||||
|
RUN adduser --disabled-password --no-create-home obsidian-mcp \
|
||||||
|
&& chown -R obsidian-mcp:obsidian-mcp /app
|
||||||
|
|
||||||
|
USER obsidian-mcp
|
||||||
|
|
||||||
|
# 容器内监听 0.0.0.0:8080
|
||||||
|
ENV ASPNETCORE_URLS=http://0.0.0.0:8080
|
||||||
|
ENV ASPNETCORE_ENVIRONMENT=Production
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# OCI 标签(source/revision 在 CI 构建时通过 --label 覆盖)
|
||||||
|
LABEL org.opencontainers.image.source="https://git.zhengchentao.win/zhengchen.tao/obsidian-mcp"
|
||||||
|
LABEL org.opencontainers.image.description="Obsidian vault MCP server — read/write vault via MCP, auth via nas-auth"
|
||||||
|
|
||||||
|
ENTRYPOINT ["dotnet", "obsidian-mcp.dll"]
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using ObsidianMcp.Config;
|
||||||
|
|
||||||
|
namespace ObsidianMcp.Endpoints;
|
||||||
|
|
||||||
|
public static class DiscoveryEndpoints
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 注册 /.well-known/oauth-authorization-server 端点。
|
||||||
|
/// 返回静态 JSON 指向 nas-auth,让 Claude.ai 能自动发现授权服务器。
|
||||||
|
/// 所有字段从 McpDiscoveryOptions 读,不 hardcode。
|
||||||
|
/// </summary>
|
||||||
|
public static void MapDiscoveryEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
app.MapGet("/.well-known/oauth-authorization-server", (McpDiscoveryOptions opts) =>
|
||||||
|
{
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
issuer = opts.Issuer,
|
||||||
|
authorization_endpoint = opts.AuthorizationEndpoint,
|
||||||
|
token_endpoint = opts.TokenEndpoint,
|
||||||
|
registration_endpoint = opts.RegistrationEndpoint,
|
||||||
|
response_types_supported = new[] { "code" },
|
||||||
|
grant_types_supported = new[] { "authorization_code", "refresh_token" },
|
||||||
|
code_challenge_methods_supported = new[] { "S256" },
|
||||||
|
scopes_supported = new[] { "read:obsidian", "write:obsidian" },
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.AllowAnonymous() // well-known 端点不需要认证,Claude 在登录前就要访问它
|
||||||
|
.WithName("OAuthDiscovery");
|
||||||
|
}
|
||||||
|
}
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ObsidianMcp.Auth;
|
||||||
|
using ObsidianMcp.Config;
|
||||||
|
using ObsidianMcp.Endpoints;
|
||||||
|
using ObsidianMcp.Services;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// ─── 配置绑定 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var jwtOpts = builder.Configuration.GetSection(JwtOptions.Section).Get<JwtOptions>()
|
||||||
|
?? new JwtOptions();
|
||||||
|
|
||||||
|
// ─── 配置对象注册到 DI ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
builder.Services.Configure<VaultOptions>(
|
||||||
|
builder.Configuration.GetSection(VaultOptions.Section));
|
||||||
|
builder.Services.Configure<JwtOptions>(
|
||||||
|
builder.Configuration.GetSection(JwtOptions.Section));
|
||||||
|
|
||||||
|
// McpDiscoveryOptions 直接注册为单例(供 DiscoveryEndpoints 依赖注入)
|
||||||
|
var discoveryOpts = builder.Configuration.GetSection(McpDiscoveryOptions.Section).Get<McpDiscoveryOptions>()
|
||||||
|
?? new McpDiscoveryOptions();
|
||||||
|
builder.Services.AddSingleton(discoveryOpts);
|
||||||
|
|
||||||
|
// ─── 认证与授权 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
builder.Services.AddObsidianJwtBearer(jwtOpts);
|
||||||
|
|
||||||
|
builder.Services.AddAuthorization(opts =>
|
||||||
|
{
|
||||||
|
opts.AddScopePolicies();
|
||||||
|
// 默认 policy:只要求已认证(JWT 验签通过即可),不要求特定 scope
|
||||||
|
opts.DefaultPolicy = new AuthorizationPolicyBuilder()
|
||||||
|
.RequireAuthenticatedUser()
|
||||||
|
.Build();
|
||||||
|
});
|
||||||
|
|
||||||
|
// scope 自定义 handler
|
||||||
|
builder.Services.AddSingleton<IAuthorizationHandler, ScopeAuthorizationHandler>();
|
||||||
|
|
||||||
|
// IHttpContextAccessor(Tool 里取 User / scope 用)
|
||||||
|
builder.Services.AddHttpContextAccessor();
|
||||||
|
|
||||||
|
// ─── MCP SDK ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
builder.Services.AddMcpServer()
|
||||||
|
.WithHttpTransport() // Streamable HTTP(单端点 POST /mcp)
|
||||||
|
.WithToolsFromAssembly(); // 自动扫描 [McpServerToolType]
|
||||||
|
|
||||||
|
// ─── 业务服务 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<VaultPathResolver>();
|
||||||
|
builder.Services.AddSingleton<VaultWriteGuard>();
|
||||||
|
builder.Services.AddSingleton<VaultSearchService>();
|
||||||
|
builder.Services.AddSingleton<AuditLogger>();
|
||||||
|
|
||||||
|
// ─── Build ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// ─── Middleware 顺序(顺序不能乱)───────────────────────────────────────────
|
||||||
|
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
// ─── 路由 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// /.well-known/oauth-authorization-server(不需要认证)
|
||||||
|
app.MapDiscoveryEndpoints();
|
||||||
|
|
||||||
|
// 健康检查(方便 docker compose 和 NPM 探活)
|
||||||
|
app.MapGet("/health", () => Results.Ok(new { status = "ok", time = DateTime.UtcNow }))
|
||||||
|
.AllowAnonymous();
|
||||||
|
|
||||||
|
// MCP 端点(必须认证,Bearer JWT)。
|
||||||
|
// 端点级只校验"已认证",scope 校验放在每个 Tool 里:
|
||||||
|
// - 读 tool 校验 read:obsidian
|
||||||
|
// - 写 tool 校验 write:obsidian
|
||||||
|
// 这样客户端拿单一 scope(仅读 / 仅写)的 Token 都能正常用对应工具。
|
||||||
|
app.MapMcp("/mcp").RequireAuthorization();
|
||||||
|
|
||||||
|
app.Run();
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"applicationUrl": "http://localhost:5117",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"applicationUrl": "https://localhost:7031;http://localhost:5117",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# obsidian-mcp
|
||||||
|
|
||||||
|
Read and write an Obsidian vault via MCP (Model Context Protocol), with OAuth authentication via nas-auth.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Claude.ai / MCP client
|
||||||
|
│
|
||||||
|
│ ① GET /.well-known/oauth-authorization-server
|
||||||
|
│ ② OAuth Authorization Code + PKCE (via nas-auth)
|
||||||
|
│ ③ Bearer JWT (aud=obsidian, scope=read:obsidian | write:obsidian)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
obs.zhengchentao.win/mcp (this service, port 9090 → 8080)
|
||||||
|
│ JWT verify (HS256, shared key with nas-auth)
|
||||||
|
│ VaultPathResolver — chroot + blacklist
|
||||||
|
│ VaultWriteGuard — whitelist for writes
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
/vault (Docker volume, backed by WebDAV share synced via Remotely Save)
|
||||||
|
```
|
||||||
|
|
||||||
|
## MCP Tools
|
||||||
|
|
||||||
|
| Tool | Auth required | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `list_vault_tree` | read:obsidian | Depth-limited directory tree of the vault |
|
||||||
|
| `list_files` | read:obsidian | Files and subdirs in a directory |
|
||||||
|
| `read_file` | read:obsidian | Read file content (UTF-8), with optional byte-range params |
|
||||||
|
| `search` | read:obsidian | Literal substring search, glob-filterable |
|
||||||
|
| `get_metadata` | read:obsidian | Size, modified_at, has_frontmatter |
|
||||||
|
| `write_file` | write:obsidian | Overwrite a whitelisted file |
|
||||||
|
| `append_file` | write:obsidian | Append to a whitelisted file |
|
||||||
|
|
||||||
|
## Configuration (environment variables)
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `Vault__Root` | `./test-vault` | Vault root directory inside the container |
|
||||||
|
| `Vault__Blacklist__0` | — | Extra blacklist path segments (01-Secret, .obsidian, .trash, .git are hardcoded) |
|
||||||
|
| `Vault__WriteWhitelist__0` | — | Extra writable path prefixes |
|
||||||
|
| `Jwt__Issuer` | `https://auth.zhengchentao.win` | Expected `iss` claim |
|
||||||
|
| `Jwt__Audience` | `obsidian` | Expected `aud` claim |
|
||||||
|
| `Jwt__SigningKey__Current` | **required** | HS256 signing key (share with nas-auth) |
|
||||||
|
| `Jwt__SigningKey__Previous` | — | Previous key during rotation |
|
||||||
|
| `Mcp__OAuthDiscovery__Issuer` | `https://auth.zhengchentao.win` | `/.well-known` issuer field |
|
||||||
|
| `AuditLog__Directory` | `/app/logs` | Directory for audit log files |
|
||||||
|
| `ASPNETCORE_ENVIRONMENT` | `Production` | Set to `Development` for verbose logs |
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Create a test vault
|
||||||
|
mkdir -p test-vault/NAS test-vault/Coding
|
||||||
|
echo "# Test" > test-vault/NAS/test.md
|
||||||
|
|
||||||
|
# 2. Set a dev signing key
|
||||||
|
export Jwt__SigningKey__Current=dev-secret-key-at-least-32-chars-long
|
||||||
|
|
||||||
|
# 3. Run
|
||||||
|
dotnet run
|
||||||
|
|
||||||
|
# 4. Generate a test JWT (requires dotnet user-jwts)
|
||||||
|
dotnet user-jwts create \
|
||||||
|
--issuer https://auth.zhengchentao.win \
|
||||||
|
--audience obsidian \
|
||||||
|
--name tao \
|
||||||
|
--claim sub=tao \
|
||||||
|
--claim scope="read:obsidian write:obsidian"
|
||||||
|
|
||||||
|
# 5. Test with MCP Inspector
|
||||||
|
npx @modelcontextprotocol/inspector
|
||||||
|
# Transport: Streamable HTTP
|
||||||
|
# URL: http://localhost:5000/mcp
|
||||||
|
# Bearer Token: <paste JWT from step 4>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Write whitelist
|
||||||
|
|
||||||
|
Write tools only accept paths matching (hardcoded, extendable via env):
|
||||||
|
|
||||||
|
- Prefix `02-ShengquGames/logs/`
|
||||||
|
- Prefix `Coding/`
|
||||||
|
- Exact `NAS/NAS 待办清单.md`
|
||||||
|
|
||||||
|
Always forbidden (any directory): `AGENTS.md`, `PROFILE.md`, `README.md`, `CLAUDE.md`, `01-Secret/`
|
||||||
|
|
||||||
|
## Running tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd obsidian-mcp.Tests
|
||||||
|
dotnet test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related docs
|
||||||
|
|
||||||
|
- [obsidian-mcp 设计](../Obsidian%20Vault/Zhengchen/Coding/obsidian-mcp/obsidian-mcp%20设计.md)
|
||||||
|
- [MCP 实现指南](../Obsidian%20Vault/Zhengchen/Coding/obsidian-mcp/MCP%20实现指南.md)
|
||||||
|
- [nas-auth 设计](../Obsidian%20Vault/Zhengchen/Coding/nas-auth/nas-auth%20设计.md)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace ObsidianMcp.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 写操作审计日志(JSON lines 格式,按天 rotate)。
|
||||||
|
/// 输出到 /app/logs/audit-YYYY-MM-DD.log。
|
||||||
|
/// 注册为 Singleton,内部用 lock 保证多线程写入安全。
|
||||||
|
/// </summary>
|
||||||
|
public class AuditLogger
|
||||||
|
{
|
||||||
|
private readonly string _logDir;
|
||||||
|
private readonly object _lock = new();
|
||||||
|
|
||||||
|
public AuditLogger(IConfiguration config)
|
||||||
|
{
|
||||||
|
// 允许通过配置覆盖日志目录,默认 /app/logs
|
||||||
|
_logDir = config["AuditLog:Directory"] ?? "/app/logs";
|
||||||
|
Directory.CreateDirectory(_logDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 记录一次写操作审计条目。
|
||||||
|
/// </summary>
|
||||||
|
public void LogWrite(
|
||||||
|
string user,
|
||||||
|
string clientId,
|
||||||
|
string tool,
|
||||||
|
string path,
|
||||||
|
long bytes,
|
||||||
|
bool ok,
|
||||||
|
string? error = null)
|
||||||
|
{
|
||||||
|
var entry = new
|
||||||
|
{
|
||||||
|
timestamp = DateTime.UtcNow.ToString("O"),
|
||||||
|
user,
|
||||||
|
tool,
|
||||||
|
path,
|
||||||
|
bytes,
|
||||||
|
client_id = clientId,
|
||||||
|
ok,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
|
||||||
|
var line = JsonSerializer.Serialize(entry);
|
||||||
|
var fileName = $"audit-{DateTime.UtcNow:yyyy-MM-dd}.log";
|
||||||
|
var filePath = Path.Combine(_logDir, fileName);
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
File.AppendAllText(filePath, line + Environment.NewLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
using ObsidianMcp.Config;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace ObsidianMcp.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Vault 路径安全守卫(chroot 语义)。
|
||||||
|
///
|
||||||
|
/// 职责:
|
||||||
|
/// - 把相对路径拼接到 VaultRoot,防止路径穿越(../)
|
||||||
|
/// - 拒绝绝对路径输入
|
||||||
|
/// - 拒绝命中黑名单的路径段
|
||||||
|
///
|
||||||
|
/// 线程安全,注册为 Singleton。
|
||||||
|
/// </summary>
|
||||||
|
public class VaultPathResolver
|
||||||
|
{
|
||||||
|
// hardcode 黑名单路径段(任意路径段命中即拒)
|
||||||
|
private static readonly HashSet<string> HardcodeBlacklist =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"01-Secret",
|
||||||
|
".obsidian",
|
||||||
|
".trash",
|
||||||
|
".git",
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly string _root;
|
||||||
|
private readonly HashSet<string> _blacklist;
|
||||||
|
|
||||||
|
public VaultPathResolver(IOptions<VaultOptions> opts)
|
||||||
|
{
|
||||||
|
var o = opts.Value;
|
||||||
|
_root = Path.GetFullPath(o.Root);
|
||||||
|
|
||||||
|
// 合并 hardcode + env 配置的黑名单,去重
|
||||||
|
_blacklist = new HashSet<string>(HardcodeBlacklist, StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var b in o.Blacklist)
|
||||||
|
if (!string.IsNullOrWhiteSpace(b))
|
||||||
|
_blacklist.Add(b.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>返回 vault 根目录的绝对路径(规范化后)。</summary>
|
||||||
|
public string VaultRoot => _root;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将相对路径解析为 vault 内的绝对路径。
|
||||||
|
///
|
||||||
|
/// 可能抛出:
|
||||||
|
/// UnauthorizedAccessException — 路径穿越、绝对路径、命中黑名单、目标是 symlink
|
||||||
|
/// ArgumentException — relativePath 为空
|
||||||
|
/// </summary>
|
||||||
|
public string Resolve(string relativePath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(relativePath))
|
||||||
|
throw new ArgumentException("路径不能为空。", nameof(relativePath));
|
||||||
|
|
||||||
|
// 拒绝绝对路径输入(防止容器外访问;包括 Linux /etc/... 与 Windows C:\... / UNC \\server)
|
||||||
|
if (Path.IsPathRooted(relativePath))
|
||||||
|
throw new UnauthorizedAccessException(
|
||||||
|
$"拒绝绝对路径输入:{relativePath}");
|
||||||
|
|
||||||
|
// 把 Windows 反斜杠归一化成 Unix 分隔符,避免 Linux 容器上把 "..\\.." 当成单段不消解。
|
||||||
|
// 注意:仅对相对路径输入做归一化;root 路径已经由 Path.GetFullPath 处理过。
|
||||||
|
var normalizedRel = relativePath.Replace('\\', '/');
|
||||||
|
|
||||||
|
// 拼接并规范化(自动消解 .. 和 .)
|
||||||
|
var target = Path.GetFullPath(Path.Combine(_root, normalizedRel));
|
||||||
|
|
||||||
|
// 确认解析后的路径仍在 vault root 内
|
||||||
|
if (!IsUnderRoot(target))
|
||||||
|
throw new UnauthorizedAccessException(
|
||||||
|
$"路径穿越 vault 根目录:{relativePath}");
|
||||||
|
|
||||||
|
// 逐段检查黑名单
|
||||||
|
CheckBlacklist(target, relativePath);
|
||||||
|
|
||||||
|
// 拒绝 symlink(无论指向 vault 内外,统一禁;vault 真实内容应是普通文件 / 目录)。
|
||||||
|
// 这是兜底防线:万一 WebDAV / 操作失误把 symlink 落到 vault 里,避免 Tool 跟随到容器外。
|
||||||
|
RejectSymlink(target, relativePath);
|
||||||
|
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 检查路径自身(以及任一父级路径段)是否是 symlink。是 → 拒绝。
|
||||||
|
/// 防御链外文件 leak(例如有人在 vault 里建一个指向 /etc/passwd 的软链)。
|
||||||
|
/// </summary>
|
||||||
|
private void RejectSymlink(string absPath, string original)
|
||||||
|
{
|
||||||
|
// 从 absPath 一直向上检查到 _root(不含 root 本体;root 是已知信任的挂载点)
|
||||||
|
var current = absPath;
|
||||||
|
while (current != null && current.Length > _root.Length)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var info = new FileInfo(current);
|
||||||
|
if (info.Exists && info.LinkTarget != null)
|
||||||
|
throw new UnauthorizedAccessException($"拒绝 symlink 路径:{original}");
|
||||||
|
if (!info.Exists)
|
||||||
|
{
|
||||||
|
var di = new DirectoryInfo(current);
|
||||||
|
if (di.Exists && di.LinkTarget != null)
|
||||||
|
throw new UnauthorizedAccessException($"拒绝 symlink 路径:{original}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException) { throw; }
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// I/O 异常不在这里阻断;后续真正读文件时会自然抛
|
||||||
|
}
|
||||||
|
var parent = Path.GetDirectoryName(current);
|
||||||
|
if (parent == null || parent == current) break;
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>检查绝对路径是否在 vault root 下(含等于 root)。</summary>
|
||||||
|
private bool IsUnderRoot(string absPath)
|
||||||
|
{
|
||||||
|
return absPath == _root
|
||||||
|
|| absPath.StartsWith(_root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>逐个路径段检查黑名单。</summary>
|
||||||
|
private void CheckBlacklist(string absPath, string original)
|
||||||
|
{
|
||||||
|
// 把 absPath 中 root 之后的部分按分隔符拆分,逐段比对
|
||||||
|
var relative = absPath[_root.Length..].TrimStart(Path.DirectorySeparatorChar);
|
||||||
|
var segments = relative.Split(
|
||||||
|
[Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
|
||||||
|
StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
foreach (var seg in segments)
|
||||||
|
{
|
||||||
|
if (_blacklist.Contains(seg))
|
||||||
|
throw new UnauthorizedAccessException(
|
||||||
|
$"路径命中黑名单段 '{seg}':{original}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using Microsoft.Extensions.FileSystemGlobbing;
|
||||||
|
using Microsoft.Extensions.FileSystemGlobbing.Abstractions;
|
||||||
|
|
||||||
|
namespace ObsidianMcp.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Vault 全文搜索服务。
|
||||||
|
/// 纯 C# 实现,大小写不敏感子串匹配(不支持 regex)。
|
||||||
|
/// V3 可替换为 ripgrep 调用。
|
||||||
|
/// </summary>
|
||||||
|
public class VaultSearchService
|
||||||
|
{
|
||||||
|
private readonly VaultPathResolver _resolver;
|
||||||
|
|
||||||
|
public VaultSearchService(VaultPathResolver resolver)
|
||||||
|
{
|
||||||
|
_resolver = resolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <param name="query">大小写不敏感的子串</param>
|
||||||
|
/// <param name="glob">glob 过滤,例如 "NAS/**/*.md",为 null 时搜全 vault</param>
|
||||||
|
/// <param name="limit">最多返回条数</param>
|
||||||
|
/// <param name="ct">CancellationToken</param>
|
||||||
|
public async Task<List<SearchHit>> SearchAsync(
|
||||||
|
string query,
|
||||||
|
string? glob,
|
||||||
|
int limit,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var root = _resolver.VaultRoot;
|
||||||
|
var files = GetFilesToSearch(root, glob);
|
||||||
|
|
||||||
|
var hits = new List<SearchHit>();
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
if (ct.IsCancellationRequested) break;
|
||||||
|
if (hits.Count >= limit) break;
|
||||||
|
|
||||||
|
await SearchFileAsync(file, root, query, limit, hits, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> GetFilesToSearch(string root, string? glob)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(glob))
|
||||||
|
{
|
||||||
|
// 全 vault 搜索,只搜 .md 文件(.json/.yaml 通常不需要全文检索)
|
||||||
|
return Directory.EnumerateFiles(root, "*.md", SearchOption.AllDirectories);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用 Microsoft.Extensions.FileSystemGlobbing 做 glob 过滤
|
||||||
|
var matcher = new Matcher(StringComparison.OrdinalIgnoreCase);
|
||||||
|
matcher.AddInclude(glob);
|
||||||
|
var dirInfo = new DirectoryInfoWrapper(new DirectoryInfo(root));
|
||||||
|
var result = matcher.Execute(dirInfo);
|
||||||
|
return result.Files.Select(f => Path.Combine(root, f.Path));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SearchFileAsync(
|
||||||
|
string filePath,
|
||||||
|
string root,
|
||||||
|
string query,
|
||||||
|
int limit,
|
||||||
|
List<SearchHit> hits,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
// 跳过过大的文件(>5MB),避免 OOM
|
||||||
|
var fi = new FileInfo(filePath);
|
||||||
|
if (!fi.Exists || fi.Length > 5 * 1024 * 1024) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int lineNumber = 0;
|
||||||
|
await foreach (var line in File.ReadLinesAsync(filePath, ct))
|
||||||
|
{
|
||||||
|
lineNumber++;
|
||||||
|
if (hits.Count >= limit) break;
|
||||||
|
|
||||||
|
if (line.Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
hits.Add(new SearchHit
|
||||||
|
{
|
||||||
|
File = Path.GetRelativePath(root, filePath).Replace('\\', '/'),
|
||||||
|
Line = lineNumber,
|
||||||
|
Preview = line.Length > 200 ? line[..200] + "..." : line,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
// 文件读取失败(权限、锁定等),跳过不影响其他结果
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SearchHit
|
||||||
|
{
|
||||||
|
public string File { get; set; } = "";
|
||||||
|
public int Line { get; set; }
|
||||||
|
public string Preview { get; set; } = "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
using ObsidianMcp.Config;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace ObsidianMcp.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 写入门禁——在路径安全(VaultPathResolver)之上再加写入白名单控制。
|
||||||
|
///
|
||||||
|
/// 规则优先级(从高到低):
|
||||||
|
/// 1. 永禁写入:AGENTS.md / PROFILE.md / README.md / CLAUDE.md(任何路径下的同名文件)
|
||||||
|
/// 2. 永禁前缀:01-Secret/
|
||||||
|
/// 3. 必须命中写入白名单之一才允许
|
||||||
|
///
|
||||||
|
/// 白名单(hardcode):
|
||||||
|
/// - 前缀 02-ShengquGames/logs/
|
||||||
|
/// - 前缀 Coding/
|
||||||
|
/// - 精确匹配 NAS/NAS 待办清单.md
|
||||||
|
///
|
||||||
|
/// 白名单可通过 env Vault__WriteWhitelist__N 扩展。
|
||||||
|
/// </summary>
|
||||||
|
public class VaultWriteGuard
|
||||||
|
{
|
||||||
|
// 永禁写入的文件名(不含路径,任何目录下的同名文件都禁写)
|
||||||
|
private static readonly HashSet<string> ForbiddenFileNames =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"AGENTS.md",
|
||||||
|
"PROFILE.md",
|
||||||
|
"README.md",
|
||||||
|
"CLAUDE.md",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 永禁写入的路径前缀(相对路径)
|
||||||
|
private static readonly string[] ForbiddenPrefixes =
|
||||||
|
[
|
||||||
|
"01-Secret/",
|
||||||
|
"01-Secret\\",
|
||||||
|
];
|
||||||
|
|
||||||
|
// hardcode 写入白名单
|
||||||
|
// 前缀匹配:以 / 或 \ 结尾表示前缀;精确匹配:其他
|
||||||
|
private static readonly string[] HardcodeWhitelist =
|
||||||
|
[
|
||||||
|
"02-ShengquGames/logs/",
|
||||||
|
"02-ShengquGames\\logs\\",
|
||||||
|
"Coding/",
|
||||||
|
"Coding\\",
|
||||||
|
"NAS/NAS 待办清单.md",
|
||||||
|
"NAS\\NAS 待办清单.md",
|
||||||
|
];
|
||||||
|
|
||||||
|
private readonly VaultPathResolver _resolver;
|
||||||
|
private readonly string[] _extraWhitelist;
|
||||||
|
|
||||||
|
public VaultWriteGuard(VaultPathResolver resolver, IOptions<VaultOptions> opts)
|
||||||
|
{
|
||||||
|
_resolver = resolver;
|
||||||
|
_extraWhitelist = opts.Value.WriteWhitelist ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 校验相对路径是否允许写入。
|
||||||
|
/// 通过则返回规范化后的绝对路径;不通过则抛 UnauthorizedAccessException。
|
||||||
|
/// </summary>
|
||||||
|
public string EnsureWritable(string relativePath)
|
||||||
|
{
|
||||||
|
// 先过路径安全守卫(防穿越 + 黑名单)
|
||||||
|
var absPath = _resolver.Resolve(relativePath);
|
||||||
|
|
||||||
|
// 规范化相对路径(用于白名单匹配),统一用 /
|
||||||
|
var normalized = NormalizeRelative(relativePath);
|
||||||
|
|
||||||
|
// 1. 永禁文件名
|
||||||
|
var fileName = Path.GetFileName(absPath);
|
||||||
|
if (ForbiddenFileNames.Contains(fileName))
|
||||||
|
throw new UnauthorizedAccessException(
|
||||||
|
$"禁止写入保护文件:{relativePath}");
|
||||||
|
|
||||||
|
// 2. 永禁前缀
|
||||||
|
foreach (var prefix in ForbiddenPrefixes)
|
||||||
|
{
|
||||||
|
if (normalized.StartsWith(NormalizeRelative(prefix), StringComparison.OrdinalIgnoreCase))
|
||||||
|
throw new UnauthorizedAccessException(
|
||||||
|
$"禁止写入 01-Secret/ 目录:{relativePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 白名单(hardcode + env 扩展)
|
||||||
|
if (!IsInWhitelist(normalized))
|
||||||
|
throw new UnauthorizedAccessException(
|
||||||
|
$"路径不在写入白名单内:{relativePath}");
|
||||||
|
|
||||||
|
return absPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsInWhitelist(string normalized)
|
||||||
|
{
|
||||||
|
var allWhitelist = HardcodeWhitelist.Concat(_extraWhitelist);
|
||||||
|
foreach (var entry in allWhitelist)
|
||||||
|
{
|
||||||
|
var normalizedEntry = NormalizeRelative(entry);
|
||||||
|
if (normalizedEntry.EndsWith('/'))
|
||||||
|
{
|
||||||
|
// 前缀匹配
|
||||||
|
if (normalized.StartsWith(normalizedEntry, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 精确匹配
|
||||||
|
if (string.Equals(normalized, normalizedEntry, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>统一用 / 作分隔符,用于白名单匹配。</summary>
|
||||||
|
private static string NormalizeRelative(string path) =>
|
||||||
|
path.Replace('\\', '/');
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
using ObsidianMcp.Auth;
|
||||||
|
using ObsidianMcp.Services;
|
||||||
|
|
||||||
|
namespace ObsidianMcp.Tools;
|
||||||
|
|
||||||
|
[McpServerToolType]
|
||||||
|
public class AppendFileTool(
|
||||||
|
VaultWriteGuard guard,
|
||||||
|
AuditLogger audit,
|
||||||
|
IHttpContextAccessor http)
|
||||||
|
{
|
||||||
|
[McpServerTool]
|
||||||
|
[Description(
|
||||||
|
"Append text to the end of a vault file (requires write:obsidian scope). " +
|
||||||
|
"Automatically prepends a newline if the file is non-empty and does not end with one. " +
|
||||||
|
"Ideal for adding entries to monthly work logs (02-ShengquGames/logs/YYYY-MM.md) " +
|
||||||
|
"or the NAS todo list (NAS/NAS 待办清单.md) without touching existing content. " +
|
||||||
|
"Same whitelist restrictions as write_file apply.")]
|
||||||
|
public async Task<WriteResult> AppendFile(
|
||||||
|
[Description("Vault-relative path (must be in writable whitelist). " +
|
||||||
|
"e.g. 'NAS/NAS 待办清单.md', '02-ShengquGames/logs/2026-05.md'")] string path,
|
||||||
|
[Description("Text to append (UTF-8). A newline is automatically inserted before this text " +
|
||||||
|
"if the file does not already end with one.")] string content)
|
||||||
|
{
|
||||||
|
// scope 校验
|
||||||
|
EnsureScope(ScopePolicies.WriteObsidian);
|
||||||
|
|
||||||
|
var user = GetUser();
|
||||||
|
var clientId = GetClientId();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var absPath = guard.EnsureWritable(path);
|
||||||
|
|
||||||
|
// 确保父目录存在
|
||||||
|
var dir = Path.GetDirectoryName(absPath)!;
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
|
||||||
|
// 如果文件已存在且末尾没有换行,先补一个
|
||||||
|
string prefix = string.Empty;
|
||||||
|
if (File.Exists(absPath))
|
||||||
|
{
|
||||||
|
var existing = await File.ReadAllTextAsync(absPath);
|
||||||
|
if (existing.Length > 0 && !existing.EndsWith('\n'))
|
||||||
|
prefix = Environment.NewLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
await File.AppendAllTextAsync(absPath, prefix + content, System.Text.Encoding.UTF8);
|
||||||
|
var written = System.Text.Encoding.UTF8.GetByteCount(prefix + content);
|
||||||
|
|
||||||
|
audit.LogWrite(user, clientId, "append_file", path, written, ok: true);
|
||||||
|
return new WriteResult { Ok = true, WrittenBytes = written };
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
audit.LogWrite(user, clientId, "append_file", path, 0, ok: false, error: ex.Message);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureScope(string requiredScope)
|
||||||
|
{
|
||||||
|
// OAuth 2.0 (RFC 6749) 规定 scope 大小写敏感,按 Ordinal 比对。
|
||||||
|
ToolScopeGuard.EnsureScope(http, requiredScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetUser() =>
|
||||||
|
http.HttpContext?.User?.FindFirst("sub")?.Value ?? "unknown";
|
||||||
|
|
||||||
|
private string GetClientId() =>
|
||||||
|
http.HttpContext?.User?.FindFirst("client_id")?.Value ?? "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>写入操作的返回值。</summary>
|
||||||
|
public class WriteResult
|
||||||
|
{
|
||||||
|
public bool Ok { get; set; }
|
||||||
|
public int WrittenBytes { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
using ObsidianMcp.Auth;
|
||||||
|
using ObsidianMcp.Services;
|
||||||
|
|
||||||
|
namespace ObsidianMcp.Tools;
|
||||||
|
|
||||||
|
[McpServerToolType]
|
||||||
|
public class GetMetadataTool(VaultPathResolver resolver, IHttpContextAccessor http)
|
||||||
|
{
|
||||||
|
[McpServerTool]
|
||||||
|
[Description(
|
||||||
|
"Get metadata for a vault file without reading its content. " +
|
||||||
|
"Returns size in bytes, last modified timestamp (UTC ISO-8601), " +
|
||||||
|
"and whether the file starts with YAML frontmatter (--- ... ---). " +
|
||||||
|
"Useful for deciding whether to read a file (staleness check, size guard before read_file).")]
|
||||||
|
public FileMetadata GetMetadata(
|
||||||
|
[Description("Vault-relative path to the file. " +
|
||||||
|
"e.g. 'NAS/NAS 总览.md'")] string path)
|
||||||
|
{
|
||||||
|
ToolScopeGuard.EnsureScope(http, ScopePolicies.ReadObsidian);
|
||||||
|
|
||||||
|
var absPath = resolver.Resolve(path);
|
||||||
|
|
||||||
|
if (!File.Exists(absPath))
|
||||||
|
throw new FileNotFoundException($"文件不存在:{path}");
|
||||||
|
|
||||||
|
var fi = new FileInfo(absPath);
|
||||||
|
|
||||||
|
// 简单判断是否有 frontmatter:读前 4 字节检查是否以 --- 开头
|
||||||
|
bool hasFrontmatter = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var fs = File.OpenRead(absPath);
|
||||||
|
var header = new byte[4];
|
||||||
|
if (fs.Read(header, 0, 4) == 4)
|
||||||
|
hasFrontmatter = header[0] == '-' && header[1] == '-' && header[2] == '-';
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 读取失败不影响其他字段
|
||||||
|
}
|
||||||
|
|
||||||
|
return new FileMetadata
|
||||||
|
{
|
||||||
|
Path = path,
|
||||||
|
SizeBytes = fi.Length,
|
||||||
|
ModifiedAt = fi.LastWriteTimeUtc.ToString("O"),
|
||||||
|
HasFrontmatter = hasFrontmatter,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FileMetadata
|
||||||
|
{
|
||||||
|
public string Path { get; set; } = "";
|
||||||
|
public long SizeBytes { get; set; }
|
||||||
|
public string ModifiedAt { get; set; } = "";
|
||||||
|
public bool HasFrontmatter { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
using ObsidianMcp.Auth;
|
||||||
|
using ObsidianMcp.Services;
|
||||||
|
|
||||||
|
namespace ObsidianMcp.Tools;
|
||||||
|
|
||||||
|
[McpServerToolType]
|
||||||
|
public class ListFilesTool(VaultPathResolver resolver, IHttpContextAccessor http)
|
||||||
|
{
|
||||||
|
[McpServerTool]
|
||||||
|
[Description(
|
||||||
|
"List files and immediate subdirectories in a vault directory. " +
|
||||||
|
"Returns a flat list of names (not full paths). " +
|
||||||
|
"Use list_vault_tree for a recursive overview, or this tool to drill into one directory. " +
|
||||||
|
"Hidden directories and blacklisted paths (01-Secret, .obsidian, etc.) are excluded.")]
|
||||||
|
public List<string> ListFiles(
|
||||||
|
[Description("Vault-relative path to list. Defaults to root if omitted. " +
|
||||||
|
"e.g. 'NAS', '02-ShengquGames/logs'")] string? path = null)
|
||||||
|
{
|
||||||
|
ToolScopeGuard.EnsureScope(http, ScopePolicies.ReadObsidian);
|
||||||
|
|
||||||
|
string absDir;
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
{
|
||||||
|
absDir = resolver.VaultRoot;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
absDir = resolver.Resolve(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Directory.Exists(absDir))
|
||||||
|
throw new DirectoryNotFoundException($"目录不存在:{path}");
|
||||||
|
|
||||||
|
var result = new List<string>();
|
||||||
|
|
||||||
|
// 子目录(排除隐藏和黑名单)
|
||||||
|
foreach (var dir in Directory.GetDirectories(absDir).OrderBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var seg = Path.GetFileName(dir)!;
|
||||||
|
if (seg.StartsWith('.')) continue;
|
||||||
|
if (seg.Equals("01-Secret", StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
|
result.Add(seg + "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件
|
||||||
|
foreach (var file in Directory.GetFiles(absDir).OrderBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
result.Add(Path.GetFileName(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
using ObsidianMcp.Auth;
|
||||||
|
using ObsidianMcp.Services;
|
||||||
|
|
||||||
|
namespace ObsidianMcp.Tools;
|
||||||
|
|
||||||
|
[McpServerToolType]
|
||||||
|
public class ListVaultTreeTool(VaultPathResolver resolver, IHttpContextAccessor http)
|
||||||
|
{
|
||||||
|
[McpServerTool]
|
||||||
|
[Description(
|
||||||
|
"Return a depth-limited directory tree of the entire Obsidian vault as JSON. " +
|
||||||
|
"Use this first when you need an overview of the vault structure. " +
|
||||||
|
"Each node has { name, type (file|directory), children? }. " +
|
||||||
|
"Blacklisted directories (01-Secret, .obsidian, .trash, .git) are excluded. " +
|
||||||
|
"Prefer this over multiple list_files calls when you need the big picture.")]
|
||||||
|
public object ListVaultTree(
|
||||||
|
[Description("Maximum depth to traverse (default 3). Root is depth 0.")] int depth = 3)
|
||||||
|
{
|
||||||
|
ToolScopeGuard.EnsureScope(http, ScopePolicies.ReadObsidian);
|
||||||
|
|
||||||
|
if (depth < 0) depth = 0;
|
||||||
|
if (depth > 10) depth = 10; // 防止超大 vault 超时
|
||||||
|
|
||||||
|
var root = resolver.VaultRoot;
|
||||||
|
return BuildNode(root, root, depth);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object BuildNode(string path, string root, int remainingDepth)
|
||||||
|
{
|
||||||
|
var name = path == root ? "/" : Path.GetFileName(path);
|
||||||
|
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
return new { name, type = "file" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Directory.Exists(path))
|
||||||
|
return new { name, type = "unknown" };
|
||||||
|
|
||||||
|
if (remainingDepth == 0)
|
||||||
|
return new { name, type = "directory" };
|
||||||
|
|
||||||
|
// 枚举子项,排序:目录先、文件后,各自按名字排序
|
||||||
|
List<object> children = [];
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var entries = Directory.GetFileSystemEntries(path)
|
||||||
|
.OrderBy(e => File.Exists(e) ? 1 : 0)
|
||||||
|
.ThenBy(e => Path.GetFileName(e), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var entry in entries)
|
||||||
|
{
|
||||||
|
var segName = Path.GetFileName(entry);
|
||||||
|
// 跳过隐藏文件/目录(以 . 开头)
|
||||||
|
if (segName.StartsWith('.')) continue;
|
||||||
|
// 跳过 01-Secret
|
||||||
|
if (segName.Equals("01-Secret", StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
|
|
||||||
|
children.Add(BuildNode(entry, root, remainingDepth - 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
// 无权限目录跳过
|
||||||
|
}
|
||||||
|
|
||||||
|
return new { name, type = "directory", children };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
using ObsidianMcp.Auth;
|
||||||
|
using ObsidianMcp.Services;
|
||||||
|
|
||||||
|
namespace ObsidianMcp.Tools;
|
||||||
|
|
||||||
|
[McpServerToolType]
|
||||||
|
public class ReadFileTool(VaultPathResolver resolver, IHttpContextAccessor http)
|
||||||
|
{
|
||||||
|
[McpServerTool]
|
||||||
|
[Description(
|
||||||
|
"Read the full content of a vault file (UTF-8). " +
|
||||||
|
"This is the primary tool for reading notes, design docs, logs, and config files. " +
|
||||||
|
"Use get_metadata first if you want to check the file size before reading. " +
|
||||||
|
"For very large files (>100 KB), use the offset and limit parameters (in bytes) " +
|
||||||
|
"to read specific byte ranges and avoid context window overflow. " +
|
||||||
|
"Returns the raw Markdown text including frontmatter.")]
|
||||||
|
public async Task<string> ReadFile(
|
||||||
|
[Description("Vault-relative path to the file. " +
|
||||||
|
"e.g. 'NAS/NAS 总览.md', 'PROFILE.md', '02-ShengquGames/logs/2026-05.md'")] string path,
|
||||||
|
[Description("Byte offset to start reading from (0-based). Omit to read from the beginning.")] long? offset = null,
|
||||||
|
[Description("Number of bytes to read. Omit to read the entire file.")] long? limit = null)
|
||||||
|
{
|
||||||
|
ToolScopeGuard.EnsureScope(http, ScopePolicies.ReadObsidian);
|
||||||
|
|
||||||
|
var absPath = resolver.Resolve(path);
|
||||||
|
|
||||||
|
if (!File.Exists(absPath))
|
||||||
|
throw new FileNotFoundException($"文件不存在:{path}");
|
||||||
|
|
||||||
|
// 无分页时直接读全文
|
||||||
|
if (offset is null && limit is null)
|
||||||
|
return await File.ReadAllTextAsync(absPath);
|
||||||
|
|
||||||
|
// 有分页参数时按字节切片
|
||||||
|
await using var fs = new FileStream(absPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
|
||||||
|
long startByte = offset ?? 0;
|
||||||
|
if (startByte < 0) startByte = 0;
|
||||||
|
if (startByte >= fs.Length)
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
fs.Seek(startByte, SeekOrigin.Begin);
|
||||||
|
|
||||||
|
long bytesToRead = limit.HasValue
|
||||||
|
? Math.Min(limit.Value, fs.Length - startByte)
|
||||||
|
: fs.Length - startByte;
|
||||||
|
|
||||||
|
var buffer = new byte[bytesToRead];
|
||||||
|
var read = await fs.ReadAsync(buffer.AsMemory(0, (int)bytesToRead));
|
||||||
|
|
||||||
|
return System.Text.Encoding.UTF8.GetString(buffer, 0, read);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
using ObsidianMcp.Auth;
|
||||||
|
using ObsidianMcp.Services;
|
||||||
|
|
||||||
|
namespace ObsidianMcp.Tools;
|
||||||
|
|
||||||
|
[McpServerToolType]
|
||||||
|
public class SearchTool(VaultSearchService searchSvc, IHttpContextAccessor http)
|
||||||
|
{
|
||||||
|
[McpServerTool]
|
||||||
|
[Description(
|
||||||
|
"Full-text search the Obsidian vault using case-insensitive literal substring match. " +
|
||||||
|
"Does NOT support regex or wildcards in the query string. " +
|
||||||
|
"Returns up to 50 hits by default, each with file path, line number, and a preview snippet. " +
|
||||||
|
"Use the glob parameter to narrow the search to specific directories or file patterns. " +
|
||||||
|
"For exact file reading use read_file instead. " +
|
||||||
|
"Examples: search for 'docker compose' across all files, or 'Uam' within '02-ShengquGames/**/*.md'.")]
|
||||||
|
public async Task<List<SearchHit>> Search(
|
||||||
|
[Description("Literal substring to search for (case-insensitive). " +
|
||||||
|
"e.g. 'docker compose', 'iptables PREROUTING', 'DOCKER_API_VERSION'")] string query,
|
||||||
|
[Description("Optional glob pattern to narrow files. " +
|
||||||
|
"e.g. 'NAS/**/*.md', '02-ShengquGames/**', '*.md'. " +
|
||||||
|
"Omit to search all .md files.")] string? glob = null,
|
||||||
|
[Description("Maximum number of results to return (default 50, max 200).")] int limit = 50)
|
||||||
|
{
|
||||||
|
ToolScopeGuard.EnsureScope(http, ScopePolicies.ReadObsidian);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(query))
|
||||||
|
throw new ArgumentException("query 不能为空。");
|
||||||
|
|
||||||
|
if (limit <= 0) limit = 50;
|
||||||
|
if (limit > 200) limit = 200;
|
||||||
|
|
||||||
|
return await searchSvc.SearchAsync(query, glob, limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
using ObsidianMcp.Auth;
|
||||||
|
using ObsidianMcp.Services;
|
||||||
|
|
||||||
|
namespace ObsidianMcp.Tools;
|
||||||
|
|
||||||
|
[McpServerToolType]
|
||||||
|
public class WriteFileTool(
|
||||||
|
VaultWriteGuard guard,
|
||||||
|
AuditLogger audit,
|
||||||
|
IHttpContextAccessor http)
|
||||||
|
{
|
||||||
|
[McpServerTool]
|
||||||
|
[Description(
|
||||||
|
"Overwrite a vault file with new content (requires write:obsidian scope). " +
|
||||||
|
"Completely replaces the existing content. " +
|
||||||
|
"Only whitelisted paths are allowed: " +
|
||||||
|
" - 02-ShengquGames/logs/ (monthly work logs), " +
|
||||||
|
" - Coding/ (personal infra notes), " +
|
||||||
|
" - NAS/NAS 待办清单.md (NAS todo list). " +
|
||||||
|
"Vault entry files (AGENTS.md, PROFILE.md, README.md, CLAUDE.md) and 01-Secret/ are always forbidden. " +
|
||||||
|
"Use append_file to add content without overwriting.")]
|
||||||
|
public async Task<WriteResult> WriteFile(
|
||||||
|
[Description("Vault-relative path (must be in writable whitelist). " +
|
||||||
|
"e.g. '02-ShengquGames/logs/2026-05.md'")] string path,
|
||||||
|
[Description("Full file content to write (UTF-8). Replaces existing content entirely.")] string content)
|
||||||
|
{
|
||||||
|
// scope 校验
|
||||||
|
EnsureScope(ScopePolicies.WriteObsidian);
|
||||||
|
|
||||||
|
var user = GetUser();
|
||||||
|
var clientId = GetClientId();
|
||||||
|
string? absPath = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
absPath = guard.EnsureWritable(path);
|
||||||
|
|
||||||
|
// 确保父目录存在
|
||||||
|
var dir = Path.GetDirectoryName(absPath)!;
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
|
||||||
|
await File.WriteAllTextAsync(absPath, content, System.Text.Encoding.UTF8);
|
||||||
|
var written = System.Text.Encoding.UTF8.GetByteCount(content);
|
||||||
|
|
||||||
|
audit.LogWrite(user, clientId, "write_file", path, written, ok: true);
|
||||||
|
return new WriteResult { Ok = true, WrittenBytes = written };
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
audit.LogWrite(user, clientId, "write_file", path, 0, ok: false, error: ex.Message);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureScope(string requiredScope)
|
||||||
|
{
|
||||||
|
// OAuth 2.0 (RFC 6749) 规定 scope 大小写敏感,按 Ordinal 比对。
|
||||||
|
ToolScopeGuard.EnsureScope(http, requiredScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetUser() =>
|
||||||
|
http.HttpContext?.User?.FindFirst("sub")?.Value ?? "unknown";
|
||||||
|
|
||||||
|
private string GetClientId() =>
|
||||||
|
http.HttpContext?.User?.FindFirst("client_id")?.Value ?? "unknown";
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
// 生产配置:所有敏感值通过环境变量注入,这里只放非敏感的生产默认值。
|
||||||
|
//
|
||||||
|
// 必须通过 env 覆盖的项:
|
||||||
|
// Jwt__SigningKey__Current=<与 nas-auth 共享的 HS256 密钥>
|
||||||
|
// Jwt__SigningKey__Previous=<密钥轮换时的旧密钥,可选>
|
||||||
|
// Vault__Root=/vault
|
||||||
|
//
|
||||||
|
// 可选覆盖:
|
||||||
|
// Vault__Blacklist__0=<额外黑名单段>
|
||||||
|
// Vault__WriteWhitelist__0=<额外写入白名单前缀>
|
||||||
|
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Warning",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"Vault": {
|
||||||
|
"Root": "/vault"
|
||||||
|
},
|
||||||
|
|
||||||
|
"Jwt": {
|
||||||
|
"Issuer": "https://auth.zhengchentao.win",
|
||||||
|
"Audience": "obsidian"
|
||||||
|
},
|
||||||
|
|
||||||
|
"Mcp": {
|
||||||
|
"OAuthDiscovery": {
|
||||||
|
"Issuer": "https://auth.zhengchentao.win",
|
||||||
|
"AuthorizationEndpoint": "https://auth.zhengchentao.win/authorize",
|
||||||
|
"TokenEndpoint": "https://auth.zhengchentao.win/token",
|
||||||
|
"RegistrationEndpoint": "https://auth.zhengchentao.win/register"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"AuditLog": {
|
||||||
|
"Directory": "/app/logs"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"Microsoft.AspNetCore.Authentication": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
|
||||||
|
// Vault 路径配置(生产值通过 env 覆盖)
|
||||||
|
"Vault": {
|
||||||
|
"Root": "./test-vault",
|
||||||
|
"Blacklist": [],
|
||||||
|
"WriteWhitelist": []
|
||||||
|
},
|
||||||
|
|
||||||
|
// JWT 验签配置(生产 key 通过 env 覆盖,不要在此填真实密钥)
|
||||||
|
"Jwt": {
|
||||||
|
"Issuer": "https://auth.zhengchentao.win",
|
||||||
|
"Audience": "obsidian",
|
||||||
|
"SigningKey": {
|
||||||
|
"Current": "",
|
||||||
|
"Previous": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// /.well-known/oauth-authorization-server 元数据
|
||||||
|
"Mcp": {
|
||||||
|
"OAuthDiscovery": {
|
||||||
|
"Issuer": "https://auth.zhengchentao.win",
|
||||||
|
"AuthorizationEndpoint": "https://auth.zhengchentao.win/authorize",
|
||||||
|
"TokenEndpoint": "https://auth.zhengchentao.win/token",
|
||||||
|
"RegistrationEndpoint": "https://auth.zhengchentao.win/register"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 审计日志目录(容器内 /app/logs)
|
||||||
|
"AuditLog": {
|
||||||
|
"Directory": "/app/logs"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<RootNamespace>ObsidianMcp</RootNamespace>
|
||||||
|
<AssemblyName>obsidian-mcp</AssemblyName>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- MCP SDK(Streamable HTTP transport) -->
|
||||||
|
<PackageReference Include="ModelContextProtocol" Version="1.0.0" />
|
||||||
|
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.0.0" />
|
||||||
|
<!-- JWT 验签 -->
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" />
|
||||||
|
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.9.0" />
|
||||||
|
<!-- glob 匹配 -->
|
||||||
|
<PackageReference Include="Microsoft.Extensions.FileSystemGlobbing" Version="10.0.7" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Reference in New Issue
Block a user