using System.ComponentModel; using Microsoft.AspNetCore.Authorization; using ModelContextProtocol.Server; using ObsidianMcp.Auth; using ObsidianMcp.Services; namespace ObsidianMcp.Tools; [McpServerToolType] public class WriteFileTool( VaultWriteGuard guard, AuditLogger audit, IHttpContextAccessor http) { [McpServerTool] [Description( "Overwrite a vault file with new content (requires write:obsidian scope). " + "Completely replaces the existing content. " + "Only whitelisted paths are allowed: " + " - 02-ShengquGames/logs/ (monthly work logs), " + " - Coding/ (personal infra notes), " + " - NAS/NAS 待办清单.md (NAS todo list). " + "Vault entry files (AGENTS.md, PROFILE.md, README.md, CLAUDE.md) and 01-Secret/ are always forbidden. " + "Use append_file to add content without overwriting.")] public async Task WriteFile( [Description("Vault-relative path (must be in writable whitelist). " + "e.g. '02-ShengquGames/logs/2026-05.md'")] string path, [Description("Full file content to write (UTF-8). Replaces existing content entirely.")] string content) { // scope 校验 EnsureScope(ScopePolicies.WriteObsidian); var user = GetUser(); var clientId = GetClientId(); string? absPath = null; try { absPath = guard.EnsureWritable(path); // 确保父目录存在 var dir = Path.GetDirectoryName(absPath)!; Directory.CreateDirectory(dir); await File.WriteAllTextAsync(absPath, content, System.Text.Encoding.UTF8); var written = System.Text.Encoding.UTF8.GetByteCount(content); audit.LogWrite(user, clientId, "write_file", path, written, ok: true); return new WriteResult { Ok = true, WrittenBytes = written }; } catch (Exception ex) { audit.LogWrite(user, clientId, "write_file", path, 0, ok: false, error: ex.Message); throw; } } private void EnsureScope(string requiredScope) { // OAuth 2.0 (RFC 6749) 规定 scope 大小写敏感,按 Ordinal 比对。 ToolScopeGuard.EnsureScope(http, requiredScope); } private string GetUser() => http.HttpContext?.User?.FindFirst("sub")?.Value ?? "unknown"; private string GetClientId() => http.HttpContext?.User?.FindFirst("client_id")?.Value ?? "unknown"; }