commit 515763bc7258ed5d163d71fe16d6e68ae8fd05bf Author: Zhengchen Tao Date: Sun May 17 23:53:00 2026 +0800 Initial public release MCP (Model Context Protocol) server for reading and writing an Obsidian vault, gated by OAuth-issued JWT bearer tokens. See README.md for setup. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8315027 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +**/.git +**/.gitignore +**/.gitea +**/bin +**/obj +**/*.user +**/.vs +**/.vscode +**/.idea +**/test-vault +obsidian-mcp.Tests/ +.dockerignore +README.md +*.md +LICENSE diff --git a/.gitea/workflows/build-image.yml b/.gitea/workflows/build-image.yml new file mode 100644 index 0000000..3ee7242 --- /dev/null +++ b/.gitea/workflows/build-image.yml @@ -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.2(runc 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" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d2ed19a --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Auth/JwtBearerSetup.cs b/Auth/JwtBearerSetup.cs new file mode 100644 index 0000000..3ec83dd --- /dev/null +++ b/Auth/JwtBearerSetup.cs @@ -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 +{ + /// + /// 配置 HS256 JWT Bearer 认证。 + /// 支持 Current + Previous 双密钥,方便密钥轮换过渡期。 + /// + 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 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)); + } +} diff --git a/Auth/ScopePolicies.cs b/Auth/ScopePolicies.cs new file mode 100644 index 0000000..4d5d9e2 --- /dev/null +++ b/Auth/ScopePolicies.cs @@ -0,0 +1,85 @@ +using Microsoft.AspNetCore.Authorization; + +namespace ObsidianMcp.Auth; + +/// +/// 自定义 scope 校验 Policy: +/// RequireScope("read:obsidian") +/// RequireScope("write:obsidian") +/// +/// JWT 的 scope claim 可能是单个字符串(空格分隔)或多个 claim,两种都处理。 +/// +public static class ScopePolicies +{ + public const string ReadObsidian = "read:obsidian"; + public const string WriteObsidian = "write:obsidian"; + + /// 注册两条 scope policy 到 AuthorizationOptions。 + 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 +{ + 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 ---------- + +/// +/// MCP Tool 内部 scope 校验:从当前 HttpContext.User 读 scope claim, +/// 不包含 requiredScope 时抛 UnauthorizedAccessException。 +/// +/// 用法:在每个读 / 写 Tool 的方法体首行调一下,给客户端可读的失败原因。 +/// 端点级 RequireAuthorization 只确保 JWT 验签通过;scope 颗粒度门禁在这里。 +/// OAuth 2.0 (RFC 6749) 规定 scope 大小写敏感。 +/// +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}"); + } +} diff --git a/Config/JwtOptions.cs b/Config/JwtOptions.cs new file mode 100644 index 0000000..d899a17 --- /dev/null +++ b/Config/JwtOptions.cs @@ -0,0 +1,27 @@ +namespace ObsidianMcp.Config; + +/// +/// JWT 验签配置。 +/// 环境变量:Jwt__Issuer, Jwt__Audience, Jwt__SigningKey__Current, Jwt__SigningKey__Previous +/// +public class JwtOptions +{ + public const string Section = "Jwt"; + + /// 期望的 iss claim(你的 auth server 的 issuer URL),必须通过 env 注入 + public string Issuer { get; set; } = string.Empty; + + /// 期望的 aud claim,默认 obsidian + public string Audience { get; set; } = "obsidian"; + + public SigningKeyPair SigningKey { get; set; } = new(); + + public class SigningKeyPair + { + /// 当前签名密钥(HS256 对称密钥),env: Jwt__SigningKey__Current + public string Current { get; set; } = string.Empty; + + /// 上一轮密钥,密钥轮换过渡期用,env: Jwt__SigningKey__Previous(可为空) + public string? Previous { get; set; } + } +} diff --git a/Config/McpDiscoveryOptions.cs b/Config/McpDiscoveryOptions.cs new file mode 100644 index 0000000..a34af28 --- /dev/null +++ b/Config/McpDiscoveryOptions.cs @@ -0,0 +1,22 @@ +namespace ObsidianMcp.Config; + +/// +/// /.well-known/oauth-authorization-server + /.well-known/oauth-protected-resource +/// 端点返回的元数据。环境变量前缀 Mcp__OAuthDiscovery__。 +/// +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; + + /// + /// 本资源服务的标识符(RFC 9728 PRM 的 `resource` 字段,必须与 auth server + /// 上该资源条目的 resource_url 完全一致)。 + /// 留空时 PRM 端点回退用请求的 `scheme://host`。 + /// + public string ResourceUrl { get; set; } = string.Empty; +} diff --git a/Config/VaultOptions.cs b/Config/VaultOptions.cs new file mode 100644 index 0000000..fe7e1a4 --- /dev/null +++ b/Config/VaultOptions.cs @@ -0,0 +1,19 @@ +namespace ObsidianMcp.Config; + +/// +/// Vault 根目录与路径安全配置。 +/// 环境变量前缀 Vault__,例如 Vault__Root=/vault +/// +public class VaultOptions +{ + public const string Section = "Vault"; + + /// Vault 根目录的绝对路径,容器内默认 /vault + public string Root { get; set; } = "/vault"; + + /// 额外黑名单路径段(与 hardcode 合并),env: Vault__Blacklist__0, __1... + public string[] Blacklist { get; set; } = []; + + /// 额外写入白名单前缀(与 hardcode 合并),env: Vault__WriteWhitelist__0... + public string[] WriteWhitelist { get; set; } = []; +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..50425b7 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Endpoints/DiscoveryEndpoints.cs b/Endpoints/DiscoveryEndpoints.cs new file mode 100644 index 0000000..14a78f8 --- /dev/null +++ b/Endpoints/DiscoveryEndpoints.cs @@ -0,0 +1,49 @@ +using ObsidianMcp.Config; + +namespace ObsidianMcp.Endpoints; + +public static class DiscoveryEndpoints +{ + /// + /// 注册两个 well-known 端点: + /// 1. /.well-known/oauth-authorization-server (RFC 8414):指向配置的 AS + /// 2. /.well-known/oauth-protected-resource (RFC 9728):告诉客户端这个资源 + /// 的 identifier 是什么 + 用哪个 AS。客户端读到 PRM 后会在 + /// /authorize 请求里带 resource=,满足 AS 的 RFC 8707 校验。 + /// + 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"); + } +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..82c210b --- /dev/null +++ b/Program.cs @@ -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() + ?? new JwtOptions(); + +// ─── 配置对象注册到 DI ─────────────────────────────────────────────────────── + +builder.Services.Configure( + builder.Configuration.GetSection(VaultOptions.Section)); +builder.Services.Configure( + builder.Configuration.GetSection(JwtOptions.Section)); + +// McpDiscoveryOptions 直接注册为单例(供 DiscoveryEndpoints 依赖注入) +var discoveryOpts = builder.Configuration.GetSection(McpDiscoveryOptions.Section).Get() + ?? 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(); + +// IHttpContextAccessor(Tool 里取 User / scope 用) +builder.Services.AddHttpContextAccessor(); + +// ─── MCP SDK ───────────────────────────────────────────────────────────────── + +builder.Services.AddMcpServer() + .WithHttpTransport() // Streamable HTTP(单端点 POST /mcp) + .WithToolsFromAssembly(); // 自动扫描 [McpServerToolType] + +// ─── 业务服务 ──────────────────────────────────────────────────────────────── + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// ─── 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(); diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 0000000..818ff0f --- /dev/null +++ b/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..4a93fb7 --- /dev/null +++ b/README.md @@ -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: +``` + +## 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 diff --git a/Services/AuditLogger.cs b/Services/AuditLogger.cs new file mode 100644 index 0000000..3920349 --- /dev/null +++ b/Services/AuditLogger.cs @@ -0,0 +1,55 @@ +using System.Text.Json; + +namespace ObsidianMcp.Services; + +/// +/// 写操作审计日志(JSON lines 格式,按天 rotate)。 +/// 输出到 /app/logs/audit-YYYY-MM-DD.log。 +/// 注册为 Singleton,内部用 lock 保证多线程写入安全。 +/// +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); + } + + /// + /// 记录一次写操作审计条目。 + /// + 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); + } + } +} diff --git a/Services/VaultPathResolver.cs b/Services/VaultPathResolver.cs new file mode 100644 index 0000000..8f8735c --- /dev/null +++ b/Services/VaultPathResolver.cs @@ -0,0 +1,142 @@ +using ObsidianMcp.Config; +using Microsoft.Extensions.Options; + +namespace ObsidianMcp.Services; + +/// +/// Vault 路径安全守卫(chroot 语义)。 +/// +/// 职责: +/// - 把相对路径拼接到 VaultRoot,防止路径穿越(../) +/// - 拒绝绝对路径输入 +/// - 拒绝命中黑名单的路径段 +/// +/// 线程安全,注册为 Singleton。 +/// +public class VaultPathResolver +{ + // hardcode 黑名单路径段(任意路径段命中即拒)。 + // 这几个是 Obsidian / Git 的内部目录,访问它们既无意义也容易踩坑(例如读取 .obsidian + // 配置可能泄露插件 secret)。用户可通过 Vault__Blacklist__N 追加自己的敏感目录。 + private static readonly HashSet HardcodeBlacklist = + new(StringComparer.OrdinalIgnoreCase) + { + ".obsidian", + ".trash", + ".git", + }; + + private readonly string _root; + private readonly HashSet _blacklist; + + public VaultPathResolver(IOptions opts) + { + var o = opts.Value; + _root = Path.GetFullPath(o.Root); + + // 合并 hardcode + env 配置的黑名单,去重 + _blacklist = new HashSet(HardcodeBlacklist, StringComparer.OrdinalIgnoreCase); + foreach (var b in o.Blacklist) + if (!string.IsNullOrWhiteSpace(b)) + _blacklist.Add(b.Trim()); + } + + /// 返回 vault 根目录的绝对路径(规范化后)。 + public string VaultRoot => _root; + + /// + /// 将相对路径解析为 vault 内的绝对路径。 + /// + /// 可能抛出: + /// UnauthorizedAccessException — 路径穿越、绝对路径、命中黑名单、目标是 symlink + /// ArgumentException — relativePath 为空 + /// + 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; + } + + /// + /// 检查路径自身(以及任一父级路径段)是否是 symlink。是 → 拒绝。 + /// 防御链外文件 leak(例如有人在 vault 里建一个指向 /etc/passwd 的软链)。 + /// + 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; + } + } + + /// 检查绝对路径是否在 vault root 下(含等于 root)。 + private bool IsUnderRoot(string absPath) + { + return absPath == _root + || absPath.StartsWith(_root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + } + + /// 逐个路径段检查黑名单。 + 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}"); + } + } +} diff --git a/Services/VaultSearchService.cs b/Services/VaultSearchService.cs new file mode 100644 index 0000000..873e4ea --- /dev/null +++ b/Services/VaultSearchService.cs @@ -0,0 +1,104 @@ +using Microsoft.Extensions.FileSystemGlobbing; +using Microsoft.Extensions.FileSystemGlobbing.Abstractions; + +namespace ObsidianMcp.Services; + +/// +/// Vault 全文搜索服务。 +/// 纯 C# 实现,大小写不敏感子串匹配(不支持 regex)。 +/// V3 可替换为 ripgrep 调用。 +/// +public class VaultSearchService +{ + private readonly VaultPathResolver _resolver; + + public VaultSearchService(VaultPathResolver resolver) + { + _resolver = resolver; + } + + /// 大小写不敏感的子串 + /// glob 过滤,例如 "Notes/**/*.md",为 null 时搜全 vault + /// 最多返回条数 + /// CancellationToken + public async Task> SearchAsync( + string query, + string? glob, + int limit, + CancellationToken ct = default) + { + var root = _resolver.VaultRoot; + var files = GetFilesToSearch(root, glob); + + var hits = new List(); + 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 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 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; } = ""; +} diff --git a/Services/VaultWriteGuard.cs b/Services/VaultWriteGuard.cs new file mode 100644 index 0000000..4c45dda --- /dev/null +++ b/Services/VaultWriteGuard.cs @@ -0,0 +1,91 @@ +using ObsidianMcp.Config; +using Microsoft.Extensions.Options; + +namespace ObsidianMcp.Services; + +/// +/// 写入门禁——在路径安全(VaultPathResolver)之上再加写入白名单控制。 +/// +/// 规则优先级(从高到低): +/// 1. 永禁写入:AGENTS.md / README.md / CLAUDE.md(任何路径下的同名文件) +/// 2. 必须命中写入白名单之一才允许(由 Vault__WriteWhitelist__N 配置) +/// +/// 白名单格式: +/// - 以 / 或 \ 结尾 → 前缀匹配(例如 "Notes/" 允许 Notes 目录及其子树) +/// - 不以斜杠结尾 → 精确路径匹配(例如 "todo.md") +/// +/// 默认白名单为空:未配置 Vault__WriteWhitelist__N 时所有写入都会被拒绝。 +/// +public class VaultWriteGuard +{ + // 永禁写入的文件名(任意目录下的同名文件都禁写)。 + // 这几个是 agent / 仓库根常见的元信息文件,写坏会导致工具自身或下游 agent 行为异常。 + private static readonly HashSet ForbiddenFileNames = + new(StringComparer.OrdinalIgnoreCase) + { + "AGENTS.md", + "README.md", + "CLAUDE.md", + }; + + private readonly VaultPathResolver _resolver; + private readonly string[] _writeWhitelist; + + public VaultWriteGuard(VaultPathResolver resolver, IOptions opts) + { + _resolver = resolver; + _writeWhitelist = opts.Value.WriteWhitelist ?? []; + } + + /// + /// 校验相对路径是否允许写入。 + /// 通过则返回规范化后的绝对路径;不通过则抛 UnauthorizedAccessException。 + /// + 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; + } + + /// 统一用 / 作分隔符,用于白名单匹配。 + private static string NormalizeRelative(string path) => + path.Replace('\\', '/'); +} diff --git a/Tools/AppendFileTool.cs b/Tools/AppendFileTool.cs new file mode 100644 index 0000000..b684a7c --- /dev/null +++ b/Tools/AppendFileTool.cs @@ -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 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"; +} + +/// 写入操作的返回值。 +public class WriteResult +{ + public bool Ok { get; set; } + public int WrittenBytes { get; set; } +} diff --git a/Tools/GetMetadataTool.cs b/Tools/GetMetadataTool.cs new file mode 100644 index 0000000..b68bde5 --- /dev/null +++ b/Tools/GetMetadataTool.cs @@ -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; } +} diff --git a/Tools/ListFilesTool.cs b/Tools/ListFilesTool.cs new file mode 100644 index 0000000..05d93aa --- /dev/null +++ b/Tools/ListFilesTool.cs @@ -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 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(); + + // 子目录(排除隐藏目录;黑名单目录在尝试访问时由 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; + } +} diff --git a/Tools/ListVaultTreeTool.cs b/Tools/ListVaultTreeTool.cs new file mode 100644 index 0000000..83b8ac4 --- /dev/null +++ b/Tools/ListVaultTreeTool.cs @@ -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 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 }; + } +} diff --git a/Tools/ReadFileTool.cs b/Tools/ReadFileTool.cs new file mode 100644 index 0000000..589dd90 --- /dev/null +++ b/Tools/ReadFileTool.cs @@ -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 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); + } +} diff --git a/Tools/SearchTool.cs b/Tools/SearchTool.cs new file mode 100644 index 0000000..4b8b7c4 --- /dev/null +++ b/Tools/SearchTool.cs @@ -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> 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); + } +} diff --git a/Tools/WriteFileTool.cs b/Tools/WriteFileTool.cs new file mode 100644 index 0000000..12f491f --- /dev/null +++ b/Tools/WriteFileTool.cs @@ -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 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"; +} diff --git a/appsettings.Development.json b/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/appsettings.Production.json b/appsettings.Production.json new file mode 100644 index 0000000..fa0c8c8 --- /dev/null +++ b/appsettings.Production.json @@ -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= + // Mcp__OAuthDiscovery__TokenEndpoint= + // Mcp__OAuthDiscovery__RegistrationEndpoint= + // 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" + } +} diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..c1d92a4 --- /dev/null +++ b/appsettings.json @@ -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" + } +} diff --git a/obsidian-mcp.Tests/VaultPathResolverTests.cs b/obsidian-mcp.Tests/VaultPathResolverTests.cs new file mode 100644 index 0000000..44d153c --- /dev/null +++ b/obsidian-mcp.Tests/VaultPathResolverTests.cs @@ -0,0 +1,124 @@ +using Microsoft.Extensions.Options; +using ObsidianMcp.Config; +using ObsidianMcp.Services; + +namespace ObsidianMcp.Tests; + +/// +/// VaultPathResolver 路径安全单测。 +/// 核心场景:路径穿越、黑名单、绝对路径拒绝。 +/// +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(() => _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(() => _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(() => _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( + () => _resolver.Resolve("custom-black/test.md")); + Assert.Contains("黑名单", ex.Message); + } + + // ─── 空路径 ────────────────────────────────────────────────────────────── + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Resolve_EmptyOrWhitespace_ThrowsArgumentException(string path) + { + Assert.Throws(() => _resolver.Resolve(path)); + } + + public void Dispose() + { + // 清理临时目录 + try { Directory.Delete(_tempRoot, recursive: true); } + catch { /* best effort */ } + } +} diff --git a/obsidian-mcp.Tests/VaultWriteGuardTests.cs b/obsidian-mcp.Tests/VaultWriteGuardTests.cs new file mode 100644 index 0000000..bddd4b9 --- /dev/null +++ b/obsidian-mcp.Tests/VaultWriteGuardTests.cs @@ -0,0 +1,139 @@ +using Microsoft.Extensions.Options; +using ObsidianMcp.Config; +using ObsidianMcp.Services; + +namespace ObsidianMcp.Tests; + +/// +/// VaultWriteGuard 单测。 +/// 核心场景:保护文件名永禁写、白名单允许、非白名单拒绝、空白名单全拒、前缀 vs 精确匹配。 +/// +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( + () => 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(() => 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(() => 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(() => 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 */ } + } +} diff --git a/obsidian-mcp.Tests/obsidian-mcp.Tests.csproj b/obsidian-mcp.Tests/obsidian-mcp.Tests.csproj new file mode 100644 index 0000000..7188d71 --- /dev/null +++ b/obsidian-mcp.Tests/obsidian-mcp.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + obsidian_mcp.Tests + enable + enable + false + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/obsidian-mcp.csproj b/obsidian-mcp.csproj new file mode 100644 index 0000000..4830788 --- /dev/null +++ b/obsidian-mcp.csproj @@ -0,0 +1,35 @@ + + + + net10.0 + enable + enable + ObsidianMcp + obsidian-mcp + + + + + + + + + + + + + + + + + + + + +