Initial public release
Build Docker Image / build (push) Failing after 1m22s

MCP (Model Context Protocol) server for reading and writing an Obsidian
vault, gated by OAuth-issued JWT bearer tokens. See README.md for setup.
This commit is contained in:
2026-05-17 23:53:00 +08:00
commit 515763bc72
31 changed files with 1931 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
**/.git
**/.gitignore
**/.gitea
**/bin
**/obj
**/*.user
**/.vs
**/.vscode
**/.idea
**/test-vault
obsidian-mcp.Tests/
.dockerignore
README.md
*.md
LICENSE
+96
View File
@@ -0,0 +1,96 @@
name: Build Docker Image
# Registry / 镜像路径通过 gitea 仓库 Variables 配置:
# vars.REGISTRY 例如 git.example.com(不带协议、不带斜杠)
# vars.IMAGE_OWNER 例如 your-username 或组织名
# secrets.PACKAGES_TOKEN 推镜像用的 token
on:
push:
branches: [main]
paths-ignore:
- '**.md'
- 'LICENSE'
- '.gitignore'
- '.dockerignore'
workflow_dispatch:
inputs:
branch:
description: '要打包的分支(仅手动触发生效)'
required: true
default: 'main'
tag:
description: '镜像 tag(留空则用 commit short hash'
required: false
default: ''
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.2runc 1.1.x)兼容不支持 runc 1.2+ openat2/fsmount syscall 的内核
driver-opts: |
image=moby/buildkit:v0.13.2
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ vars.REGISTRY }}
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
IMAGE_REF="${{ vars.REGISTRY }}/${{ vars.IMAGE_OWNER }}/obsidian-mcp"
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
echo "image_ref=$IMAGE_REF" >> $GITHUB_OUTPUT
echo "full_sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "==> Image: $IMAGE_REF:$IMAGE_TAG"
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
cache-from: type=registry,ref=${{ steps.meta.outputs.image_ref }}:buildcache
cache-to: type=registry,ref=${{ steps.meta.outputs.image_ref }}:buildcache,mode=min,ignore-error=true
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ steps.meta.outputs.full_sha }}
tags: |
${{ steps.meta.outputs.image_ref }}:${{ steps.meta.outputs.image_tag }}
${{ steps.meta.outputs.image_ref }}: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 "| 镜像 | \`${{ steps.meta.outputs.image_ref }}:${{ steps.meta.outputs.image_tag }}\` + \`:latest\` |"
} >> "$GITHUB_STEP_SUMMARY"
+23
View File
@@ -0,0 +1,23 @@
## .NET
bin/
obj/
*.user
*.suo
.vs/
.vscode/
## 本地测试 vault(不入库)
test-vault/
## 运行时产物
/app/logs/
*.log
## 开发期 JWTdotnet user-jwts 签的,不入库)
.jwt/
## 环境变量文件(密钥不入库)
.env
.env.local
appsettings.*.local.json
secrets.json
+62
View File
@@ -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));
}
}
+85
View File
@@ -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}");
}
}
+27
View File
@@ -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(你的 auth server 的 issuer URL),必须通过 env 注入</summary>
public string Issuer { get; set; } = string.Empty;
/// <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; }
}
}
+22
View File
@@ -0,0 +1,22 @@
namespace ObsidianMcp.Config;
/// <summary>
/// /.well-known/oauth-authorization-server + /.well-known/oauth-protected-resource
/// 端点返回的元数据。环境变量前缀 Mcp__OAuthDiscovery__。
/// </summary>
public class McpDiscoveryOptions
{
public const string Section = "Mcp:OAuthDiscovery";
public string Issuer { get; set; } = string.Empty;
public string AuthorizationEndpoint { get; set; } = string.Empty;
public string TokenEndpoint { get; set; } = string.Empty;
public string RegistrationEndpoint { get; set; } = string.Empty;
/// <summary>
/// 本资源服务的标识符(RFC 9728 PRM 的 `resource` 字段,必须与 auth server
/// 上该资源条目的 resource_url 完全一致)。
/// 留空时 PRM 端点回退用请求的 `scheme://host`。
/// </summary>
public string ResourceUrl { get; set; } = string.Empty;
}
+19
View File
@@ -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; } = [];
}
+53
View File
@@ -0,0 +1,53 @@
# syntax=docker/dockerfile:1.6
# ─── Stage 1: build ───────────────────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS builder
WORKDIR /src
# 先只复制 csproj,利用层缓存加速 restore
COPY obsidian-mcp.csproj ./
RUN --mount=type=cache,target=/root/.nuget/packages,sharing=locked \
dotnet restore obsidian-mcp.csproj
# 复制全部源码并 publish
COPY . .
RUN --mount=type=cache,target=/root/.nuget/packages,sharing=locked \
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 useradd --system --no-create-home --shell /usr/sbin/nologin 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.title="obsidian-mcp"
LABEL org.opencontainers.image.description="Obsidian vault MCP server — read/write vault via MCP, auth via OAuth-issued JWT"
LABEL org.opencontainers.image.licenses="MIT"
ENTRYPOINT ["dotnet", "obsidian-mcp.dll"]
+49
View File
@@ -0,0 +1,49 @@
using ObsidianMcp.Config;
namespace ObsidianMcp.Endpoints;
public static class DiscoveryEndpoints
{
/// <summary>
/// 注册两个 well-known 端点:
/// 1. /.well-known/oauth-authorization-server (RFC 8414):指向配置的 AS
/// 2. /.well-known/oauth-protected-resource (RFC 9728):告诉客户端这个资源
/// 的 identifier 是什么 + 用哪个 AS。客户端读到 PRM 后会在
/// /authorize 请求里带 resource=<identifier>,满足 AS 的 RFC 8707 校验。
/// </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()
.WithName("OAuthDiscovery");
app.MapGet("/.well-known/oauth-protected-resource", (HttpContext ctx, McpDiscoveryOptions opts) =>
{
var resourceUrl = string.IsNullOrWhiteSpace(opts.ResourceUrl)
? $"{ctx.Request.Scheme}://{ctx.Request.Host}"
: opts.ResourceUrl;
return Results.Ok(new
{
resource = resourceUrl,
authorization_servers = new[] { opts.Issuer },
scopes_supported = new[] { "read:obsidian", "write:obsidian" },
bearer_methods_supported = new[] { "header" },
});
})
.AllowAnonymous()
.WithName("ProtectedResourceMetadata");
}
}
+83
View File
@@ -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>();
// IHttpContextAccessorTool 里取 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();
+23
View File
@@ -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"
}
}
}
}
+148
View File
@@ -0,0 +1,148 @@
# obsidian-mcp
Read and write an Obsidian vault via [MCP (Model Context Protocol)](https://modelcontextprotocol.io/),
gated by OAuth-issued JWT bearer tokens.
This server pairs with any OAuth 2.1 + PKCE authorization server that can mint
HS256 JWTs containing `aud`, `iss`, `sub`, `scope` claims. Bring your own AS.
## Architecture
```
MCP client (Claude.ai, etc.)
│ ① GET /.well-known/oauth-authorization-server (RFC 8414)
│ ② OAuth Authorization Code + PKCE (against your AS)
│ ③ Bearer JWT (aud=obsidian, scope=read:obsidian | write:obsidian)
obsidian-mcp /mcp
│ JWT verify (HS256, shared key with AS)
│ VaultPathResolver — chroot + blacklist
│ VaultWriteGuard — whitelist for writes
/vault (mounted directory — local folder, WebDAV sync target, etc.)
```
## MCP tools
| Tool | Scope 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` | 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
All settings are bound from configuration with `Vault__`, `Jwt__`, `Mcp__OAuthDiscovery__`
prefixes (double underscore = nested section). Production values must be injected via env vars.
| Variable | Default | Required | Description |
|---|---|---|---|
| `Vault__Root` | `/vault` | yes | Vault root directory inside the container |
| `Vault__Blacklist__0` | — | no | Extra path segments to deny (`.obsidian`, `.trash`, `.git` are always denied) |
| `Vault__WriteWhitelist__0` | — | for write tools | Writable path entries (see below) |
| `Jwt__Issuer` | — | **yes** | Expected `iss` claim — your AS's issuer URL |
| `Jwt__Audience` | `obsidian` | no | Expected `aud` claim |
| `Jwt__SigningKey__Current` | — | **yes** | HS256 signing key, shared with your AS |
| `Jwt__SigningKey__Previous` | — | no | Previous key during rotation window |
| `Mcp__OAuthDiscovery__Issuer` | — | **yes** | `/.well-known/oauth-authorization-server` `issuer` field |
| `Mcp__OAuthDiscovery__AuthorizationEndpoint` | — | **yes** | Your AS's `/authorize` URL |
| `Mcp__OAuthDiscovery__TokenEndpoint` | — | **yes** | Your AS's `/token` URL |
| `Mcp__OAuthDiscovery__RegistrationEndpoint` | — | no | Your AS's `/register` URL (DCR) |
| `Mcp__OAuthDiscovery__ResourceUrl` | request host | no | RFC 9728 `resource` identifier for this MCP server |
| `AuditLog__Directory` | `/app/logs` | no | Directory for audit log files |
| `ASPNETCORE_ENVIRONMENT` | `Production` | no | `Development` for verbose logs |
### Write whitelist format
`Vault__WriteWhitelist__N` entries gate every write/append operation:
- Ending with `/` (or `\`) → prefix match. Example: `Notes/` allows any path under `Notes/`.
- Otherwise → exact path match. Example: `todo.md` allows only that one file.
Always forbidden regardless of whitelist: any path whose filename is `AGENTS.md`,
`README.md`, or `CLAUDE.md` (these are common agent-context files; mutating them
tends to confuse downstream tooling).
If `WriteWhitelist` is empty, all writes are denied.
## Local development
```bash
# 1. Create a test vault
mkdir -p test-vault/Notes
echo "# Test" > test-vault/Notes/test.md
# 2. Set required env vars
export Vault__Root=./test-vault
export Vault__WriteWhitelist__0=Notes/
export Jwt__Issuer=https://your-auth-server.example.com
export Jwt__Audience=obsidian
export Jwt__SigningKey__Current=dev-secret-key-at-least-32-chars-long
export Mcp__OAuthDiscovery__Issuer=https://your-auth-server.example.com
export Mcp__OAuthDiscovery__AuthorizationEndpoint=https://your-auth-server.example.com/authorize
export Mcp__OAuthDiscovery__TokenEndpoint=https://your-auth-server.example.com/token
# 3. Run
dotnet run
# 4. Generate a test JWT (requires dotnet user-jwts)
dotnet user-jwts create \
--issuer https://your-auth-server.example.com \
--audience obsidian \
--name tester \
--claim sub=tester \
--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>
```
## Docker
A multi-stage Dockerfile is included. Build locally with:
```bash
docker build -t obsidian-mcp .
```
Run with a mounted vault:
```bash
docker run --rm -p 8080:8080 \
-v /path/to/vault:/vault \
-e Jwt__Issuer=https://your-auth-server.example.com \
-e Jwt__SigningKey__Current=$JWT_SIGNING_KEY \
-e Mcp__OAuthDiscovery__Issuer=https://your-auth-server.example.com \
-e Mcp__OAuthDiscovery__AuthorizationEndpoint=https://your-auth-server.example.com/authorize \
-e Mcp__OAuthDiscovery__TokenEndpoint=https://your-auth-server.example.com/token \
-e Vault__WriteWhitelist__0=Notes/ \
obsidian-mcp
```
The included `.gitea/workflows/build-image.yml` is a Gitea Actions workflow that
builds and pushes the image. It expects these repository Variables / Secrets:
- `vars.REGISTRY` — registry hostname (e.g. `ghcr.io`)
- `vars.IMAGE_OWNER` — registry owner/namespace
- `secrets.PACKAGES_TOKEN` — registry push token
## Running tests
```bash
cd obsidian-mcp.Tests
dotnet test
```
## License
MIT
+55
View File
@@ -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);
}
}
}
+142
View File
@@ -0,0 +1,142 @@
using ObsidianMcp.Config;
using Microsoft.Extensions.Options;
namespace ObsidianMcp.Services;
/// <summary>
/// Vault 路径安全守卫(chroot 语义)。
///
/// 职责:
/// - 把相对路径拼接到 VaultRoot,防止路径穿越(../
/// - 拒绝绝对路径输入
/// - 拒绝命中黑名单的路径段
///
/// 线程安全,注册为 Singleton。
/// </summary>
public class VaultPathResolver
{
// hardcode 黑名单路径段(任意路径段命中即拒)。
// 这几个是 Obsidian / Git 的内部目录,访问它们既无意义也容易踩坑(例如读取 .obsidian
// 配置可能泄露插件 secret)。用户可通过 Vault__Blacklist__N 追加自己的敏感目录。
private static readonly HashSet<string> HardcodeBlacklist =
new(StringComparer.OrdinalIgnoreCase)
{
".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}");
}
}
}
+104
View File
@@ -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 过滤,例如 "Notes/**/*.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; } = "";
}
+91
View File
@@ -0,0 +1,91 @@
using ObsidianMcp.Config;
using Microsoft.Extensions.Options;
namespace ObsidianMcp.Services;
/// <summary>
/// 写入门禁——在路径安全(VaultPathResolver)之上再加写入白名单控制。
///
/// 规则优先级(从高到低):
/// 1. 永禁写入:AGENTS.md / README.md / CLAUDE.md(任何路径下的同名文件)
/// 2. 必须命中写入白名单之一才允许(由 Vault__WriteWhitelist__N 配置)
///
/// 白名单格式:
/// - 以 / 或 \ 结尾 → 前缀匹配(例如 "Notes/" 允许 Notes 目录及其子树)
/// - 不以斜杠结尾 → 精确路径匹配(例如 "todo.md"
///
/// 默认白名单为空:未配置 Vault__WriteWhitelist__N 时所有写入都会被拒绝。
/// </summary>
public class VaultWriteGuard
{
// 永禁写入的文件名(任意目录下的同名文件都禁写)。
// 这几个是 agent / 仓库根常见的元信息文件,写坏会导致工具自身或下游 agent 行为异常。
private static readonly HashSet<string> ForbiddenFileNames =
new(StringComparer.OrdinalIgnoreCase)
{
"AGENTS.md",
"README.md",
"CLAUDE.md",
};
private readonly VaultPathResolver _resolver;
private readonly string[] _writeWhitelist;
public VaultWriteGuard(VaultPathResolver resolver, IOptions<VaultOptions> opts)
{
_resolver = resolver;
_writeWhitelist = 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. 写入白名单
if (!IsInWhitelist(normalized))
throw new UnauthorizedAccessException(
$"路径不在写入白名单内:{relativePath}");
return absPath;
}
private bool IsInWhitelist(string normalized)
{
foreach (var entry in _writeWhitelist)
{
if (string.IsNullOrWhiteSpace(entry)) continue;
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('\\', '/');
}
+80
View File
@@ -0,0 +1,80 @@
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 a running log or todo file without touching existing content. " +
"Same whitelist restrictions as write_file apply (path must be in Vault__WriteWhitelist).")]
public async Task<WriteResult> AppendFile(
[Description("Vault-relative path (must be in writable whitelist). " +
"e.g. 'Notes/todo.md', 'Projects/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; }
}
+60
View File
@@ -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. 'Notes/index.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; }
}
+54
View File
@@ -0,0 +1,54 @@
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 (.obsidian, .git, .trash) and any path configured in Vault__Blacklist are excluded.")]
public List<string> ListFiles(
[Description("Vault-relative path to list. Defaults to root if omitted. " +
"e.g. 'Notes', 'Projects/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>();
// 子目录(排除隐藏目录;黑名单目录在尝试访问时由 VaultPathResolver 拦截)
foreach (var dir in Directory.GetDirectories(absDir).OrderBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase))
{
var seg = Path.GetFileName(dir)!;
if (seg.StartsWith('.')) continue;
result.Add(seg + "/");
}
// 文件
foreach (var file in Directory.GetFiles(absDir).OrderBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase))
{
result.Add(Path.GetFileName(file));
}
return result;
}
}
+70
View File
@@ -0,0 +1,70 @@
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? }. " +
"Hidden directories (.obsidian, .trash, .git) and any path in Vault__Blacklist 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);
// 跳过隐藏文件/目录(以 . 开头);其他黑名单目录在尝试访问时由 VaultPathResolver 拦截
if (segName.StartsWith('.')) continue;
children.Add(BuildNode(entry, root, remainingDepth - 1));
}
}
catch (UnauthorizedAccessException)
{
// 无权限目录跳过
}
return new { name, type = "directory", children };
}
}
+55
View File
@@ -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. 'Notes/index.md', 'README.md', 'Projects/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);
}
}
+37
View File
@@ -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 'TODO' within 'Projects/**/*.md'.")]
public async Task<List<SearchHit>> Search(
[Description("Literal substring to search for (case-insensitive). " +
"e.g. 'docker compose', 'TODO', 'API_KEY'")] string query,
[Description("Optional glob pattern to narrow files. " +
"e.g. 'Notes/**/*.md', 'Projects/**', '*.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);
}
}
+68
View File
@@ -0,0 +1,68 @@
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 paths matching Vault__WriteWhitelist entries are allowed " +
"(entries ending with '/' are prefix matches, otherwise exact-path matches). " +
"Agent context files (AGENTS.md, README.md, CLAUDE.md) are always forbidden, " +
"as are any paths in Vault__Blacklist. " +
"Use append_file to add content without overwriting.")]
public async Task<WriteResult> WriteFile(
[Description("Vault-relative path (must be in writable whitelist). " +
"e.g. 'Projects/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";
}
+8
View File
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+36
View File
@@ -0,0 +1,36 @@
{
// 生产配置:所有敏感值通过环境变量注入。
//
// 必须通过 env 覆盖的项:
// Jwt__Issuer=<你的 auth server issuer URL>
// Jwt__SigningKey__Current=<与 auth server 共享的 HS256 密钥>
// Jwt__SigningKey__Previous=<密钥轮换时的旧密钥,可选>
// Mcp__OAuthDiscovery__Issuer=<同 Jwt__Issuer>
// Mcp__OAuthDiscovery__AuthorizationEndpoint=<auth server /authorize URL>
// Mcp__OAuthDiscovery__TokenEndpoint=<auth server /token URL>
// Mcp__OAuthDiscovery__RegistrationEndpoint=<auth server /register URL>
// Vault__Root=/vault
//
// 可选覆盖:
// Vault__Blacklist__0=<额外黑名单段>
// Vault__WriteWhitelist__0=<写入白名单前缀,例如 "Notes/" 或精确文件 "todo.md">
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft.AspNetCore": "Warning"
}
},
"Vault": {
"Root": "/vault"
},
"Jwt": {
"Audience": "obsidian"
},
"AuditLog": {
"Directory": "/app/logs"
}
}
+42
View File
@@ -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 验签配置(生产值必须通过 env 覆盖)
"Jwt": {
"Issuer": "",
"Audience": "obsidian",
"SigningKey": {
"Current": "",
"Previous": ""
}
},
// /.well-known/oauth-authorization-server 元数据(生产值必须通过 env 覆盖)
"Mcp": {
"OAuthDiscovery": {
"Issuer": "",
"AuthorizationEndpoint": "",
"TokenEndpoint": "",
"RegistrationEndpoint": ""
}
},
// 审计日志目录(容器内 /app/logs)
"AuditLog": {
"Directory": "/app/logs"
}
}
@@ -0,0 +1,124 @@
using Microsoft.Extensions.Options;
using ObsidianMcp.Config;
using ObsidianMcp.Services;
namespace ObsidianMcp.Tests;
/// <summary>
/// VaultPathResolver 路径安全单测。
/// 核心场景:路径穿越、黑名单、绝对路径拒绝。
/// </summary>
public class VaultPathResolverTests : IDisposable
{
private readonly string _tempRoot;
private readonly VaultPathResolver _resolver;
public VaultPathResolverTests()
{
// 每个测试类实例创建独立临时目录作 vault root
_tempRoot = Path.Combine(Path.GetTempPath(), "obsidian-mcp-test-" + Guid.NewGuid());
Directory.CreateDirectory(_tempRoot);
// 在 tempRoot 下建一些测试文件/目录
Directory.CreateDirectory(Path.Combine(_tempRoot, "Notes"));
File.WriteAllText(Path.Combine(_tempRoot, "Notes", "test.md"), "hello");
Directory.CreateDirectory(Path.Combine(_tempRoot, "Projects"));
var opts = Options.Create(new VaultOptions
{
Root = _tempRoot,
Blacklist = ["custom-black"],
WriteWhitelist = [],
});
_resolver = new VaultPathResolver(opts);
}
// ─── 正常路径 ────────────────────────────────────────────────────────────
[Fact]
public void Resolve_ValidRelativePath_ReturnsAbsolutePath()
{
var result = _resolver.Resolve("Notes/test.md");
Assert.Equal(Path.Combine(_tempRoot, "Notes", "test.md"), result);
}
[Fact]
public void Resolve_NestedPath_ReturnsCorrectAbsolutePath()
{
var result = _resolver.Resolve("Projects");
Assert.Equal(Path.Combine(_tempRoot, "Projects"), result);
}
// ─── 路径穿越(Path Traversal) ──────────────────────────────────────────
[Theory]
[InlineData("../etc/passwd")]
[InlineData("../../Windows/System32")]
[InlineData("Notes/../../../etc/shadow")]
[InlineData("Notes/../../outside")]
public void Resolve_PathTraversal_ThrowsUnauthorized(string path)
{
var ex = Assert.Throws<UnauthorizedAccessException>(() => _resolver.Resolve(path));
Assert.Contains("路径穿越", ex.Message);
}
// ─── 绝对路径拒绝 ────────────────────────────────────────────────────────
[Theory]
[InlineData("/etc/passwd")]
[InlineData("C:\\Windows\\System32")]
public void Resolve_AbsolutePath_ThrowsUnauthorized(string path)
{
// 绝对路径判断:Path.IsPathRooted 在 Windows 上对 / 开头也返回 true
var ex = Assert.Throws<UnauthorizedAccessException>(() => _resolver.Resolve(path));
Assert.Contains("绝对路径", ex.Message);
}
// ─── Hardcode 黑名单(.obsidian / .trash / .git) ────────────────────────
[Theory]
[InlineData(".obsidian/config")]
[InlineData(".trash/deleted.md")]
[InlineData(".git/config")]
public void Resolve_HardcodeBlacklist_ThrowsUnauthorized(string path)
{
// 先确保这些目录/文件在 vault root 下存在(避免路径规范化误报)
var abs = Path.Combine(_tempRoot, path.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(abs)!);
File.WriteAllText(abs, "test");
var ex = Assert.Throws<UnauthorizedAccessException>(() => _resolver.Resolve(path));
Assert.Contains("黑名单", ex.Message);
}
// ─── Env 扩展黑名单 ──────────────────────────────────────────────────────
[Fact]
public void Resolve_CustomBlacklist_ThrowsUnauthorized()
{
var customDir = Path.Combine(_tempRoot, "custom-black");
Directory.CreateDirectory(customDir);
File.WriteAllText(Path.Combine(customDir, "test.md"), "test");
var ex = Assert.Throws<UnauthorizedAccessException>(
() => _resolver.Resolve("custom-black/test.md"));
Assert.Contains("黑名单", ex.Message);
}
// ─── 空路径 ──────────────────────────────────────────────────────────────
[Theory]
[InlineData("")]
[InlineData(" ")]
public void Resolve_EmptyOrWhitespace_ThrowsArgumentException(string path)
{
Assert.Throws<ArgumentException>(() => _resolver.Resolve(path));
}
public void Dispose()
{
// 清理临时目录
try { Directory.Delete(_tempRoot, recursive: true); }
catch { /* best effort */ }
}
}
+139
View File
@@ -0,0 +1,139 @@
using Microsoft.Extensions.Options;
using ObsidianMcp.Config;
using ObsidianMcp.Services;
namespace ObsidianMcp.Tests;
/// <summary>
/// VaultWriteGuard 单测。
/// 核心场景:保护文件名永禁写、白名单允许、非白名单拒绝、空白名单全拒、前缀 vs 精确匹配。
/// </summary>
public class VaultWriteGuardTests : IDisposable
{
private readonly string _tempRoot;
public VaultWriteGuardTests()
{
_tempRoot = Path.Combine(Path.GetTempPath(), "obsidian-mcp-guard-" + Guid.NewGuid());
Directory.CreateDirectory(_tempRoot);
}
private VaultWriteGuard MakeGuard(string[] whitelist)
{
var opts = Options.Create(new VaultOptions
{
Root = _tempRoot,
Blacklist = [],
WriteWhitelist = whitelist,
});
var resolver = new VaultPathResolver(opts);
return new VaultWriteGuard(resolver, opts);
}
private void EnsureDirFor(string relPath)
{
var abs = Path.Combine(_tempRoot, relPath.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(abs)!);
}
// ─── 白名单:前缀匹配 ────────────────────────────────────────────────────
[Theory]
[InlineData("Notes/foo.md")]
[InlineData("Notes/sub/bar.md")]
public void EnsureWritable_PrefixWhitelist_AllowsMatchingPaths(string path)
{
var guard = MakeGuard(["Notes/"]);
EnsureDirFor(path);
var result = guard.EnsureWritable(path);
Assert.True(result.StartsWith(_tempRoot, StringComparison.OrdinalIgnoreCase));
}
// ─── 白名单:精确匹配 ────────────────────────────────────────────────────
[Fact]
public void EnsureWritable_ExactWhitelist_AllowsOnlyExactPath()
{
var guard = MakeGuard(["todo.md"]);
EnsureDirFor("todo.md");
var result = guard.EnsureWritable("todo.md");
Assert.True(result.StartsWith(_tempRoot, StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void EnsureWritable_ExactWhitelist_RejectsSiblingPath()
{
var guard = MakeGuard(["todo.md"]);
EnsureDirFor("other.md");
var ex = Assert.Throws<UnauthorizedAccessException>(
() => guard.EnsureWritable("other.md"));
Assert.Contains("白名单", ex.Message);
}
// ─── 保护文件名永禁(即便父目录被白名单允许) ──────────────────────────
[Theory]
[InlineData("AGENTS.md")]
[InlineData("README.md")]
[InlineData("CLAUDE.md")]
[InlineData("Notes/AGENTS.md")]
[InlineData("Notes/README.md")]
[InlineData("Notes/CLAUDE.md")]
public void EnsureWritable_ProtectedFileName_ThrowsUnauthorized(string path)
{
var guard = MakeGuard(["Notes/", "AGENTS.md", "README.md", "CLAUDE.md"]);
EnsureDirFor(path);
var ex = Assert.Throws<UnauthorizedAccessException>(() => guard.EnsureWritable(path));
Assert.Contains("禁止写入", ex.Message);
}
// ─── 空白名单:拒绝一切写入 ──────────────────────────────────────────────
[Theory]
[InlineData("Notes/foo.md")]
[InlineData("foo.md")]
public void EnsureWritable_EmptyWhitelist_RejectsEverything(string path)
{
var guard = MakeGuard([]);
EnsureDirFor(path);
var ex = Assert.Throws<UnauthorizedAccessException>(() => guard.EnsureWritable(path));
Assert.Contains("白名单", ex.Message);
}
// ─── 非白名单路径拒绝 ────────────────────────────────────────────────────
[Theory]
[InlineData("Other/note.md")]
[InlineData("random.md")]
public void EnsureWritable_NonWhitelistedPath_ThrowsUnauthorized(string path)
{
var guard = MakeGuard(["Notes/"]);
EnsureDirFor(path);
var ex = Assert.Throws<UnauthorizedAccessException>(() => guard.EnsureWritable(path));
Assert.Contains("白名单", ex.Message);
}
// ─── 反斜杠归一化 ────────────────────────────────────────────────────────
[Fact]
public void EnsureWritable_WhitelistAcceptsBackslashEntries()
{
var guard = MakeGuard(["Notes\\"]);
EnsureDirFor("Notes/foo.md");
var result = guard.EnsureWritable("Notes/foo.md");
Assert.True(result.StartsWith(_tempRoot, StringComparison.OrdinalIgnoreCase));
}
public void Dispose()
{
try { Directory.Delete(_tempRoot, recursive: true); }
catch { /* best effort */ }
}
}
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>obsidian_mcp.Tests</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\obsidian-mcp.csproj" />
</ItemGroup>
</Project>
+35
View File
@@ -0,0 +1,35 @@
<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>
<!-- 排除 Tests 子目录,避免 SDK.Web 的通配符 glob 把测试文件包进主项目 -->
<ItemGroup>
<Compile Remove="obsidian-mcp.Tests/**" />
<Content Remove="obsidian-mcp.Tests/**" />
<EmbeddedResource Remove="obsidian-mcp.Tests/**" />
<None Remove="obsidian-mcp.Tests/**" />
</ItemGroup>
<ItemGroup>
<!-- MCP SDKStreamable HTTP transport -->
<PackageReference Include="ModelContextProtocol" Version="1.0.0" />
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.0.0" />
<!-- JWT 验签。不显式 pin Microsoft.IdentityModel.Tokens
JwtBearer 10.0.7 传递依赖 Microsoft.IdentityModel.JsonWebTokens 9.x
JsonWebTokens 9.x 调 Base64UrlEncoder.Decode(ReadOnlySpan, Span)
该 overload 在 Microsoft.IdentityModel.Tokens 9.x 才加入,显式 pin 8.9.0
会强制 NuGet downgrade Tokens 到 8.9 → runtime MissingMethodException。
让 JwtBearer 拉自己的传递依赖即可,代码用的 SymmetricSecurityKey 等
基础类型在任意版本都可用。 -->
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" />
<!-- glob 匹配 -->
<PackageReference Include="Microsoft.Extensions.FileSystemGlobbing" Version="10.0.7" />
</ItemGroup>
</Project>