Obsidian MCP

by takuya0206
Verified
import axios, { AxiosInstance, AxiosHeaders } from "axios"; import { NoteJson, PatchNoteOptions, AdvancedSearchResult, } from "../types.js"; import { containsNonASCII } from "../utils/formatting.js"; import * as dotenv from "dotenv"; dotenv.config(); export class ObsidianAPI { private client: AxiosInstance; private defaultHeaders: AxiosHeaders; constructor() { this.defaultHeaders = new AxiosHeaders({ Authorization: `Bearer ${process.env.apiKey}`, }); this.client = axios.create({ baseURL: `http://${process.env.host}:${process.env.port}`, headers: this.defaultHeaders, }); } async readNote(path: string): Promise<NoteJson> { const normalizedPath = path.startsWith("/") ? path.substring(1) : path; const response = await this.client.get( `/vault/${encodeURIComponent(normalizedPath)}`, { headers: { ...this.defaultHeaders, "Content-Type": "application/json", accept: "application/vnd.olrapi.note+json", }, } ); return response.data as NoteJson; } async readActiveNote(): Promise<NoteJson> { const response = await this.client.get(`/active/`, { headers: { ...this.defaultHeaders, "Content-Type": "application/json", accept: "application/vnd.olrapi.note+json", }, }); return response.data as NoteJson; } // MCPサーバーとして利用するには仕様が複雑すぎてAIが使いこなせない。 // 特にヘッダーがネストすると失敗するので、最上位のヘッダーを指定して利用するべし async patchNote( path: string, content: string, patchOptions: PatchNoteOptions ): Promise<void> { const normalizedPath = path.startsWith("/") ? path.substring(1) : path; const processedContent = content; const { operation, targetType, target, targetDelimiter = "::", trimTargetWhitespace = false, contentType = "text/markdown", } = patchOptions; // ターゲットを日本語で指定するとエラーになるため const encodedTarget = containsNonASCII(target) ? encodeURIComponent(target) : target; try { const response = await this.client.patch( `/vault/${encodeURIComponent(normalizedPath)}`, processedContent, { headers: { ...this.defaultHeaders, Operation: operation, "Target-Type": targetType, Target: encodedTarget, "Target-Delimiter": targetDelimiter, "Trim-Target-Whitespace": trimTargetWhitespace, "Content-Type": contentType, }, } ); return response.data; } catch (error: any) { throw error; } } /** * 指定フォルダ(なければ vault/)から再帰的にファイル・フォルダの階層構造を取得し、 * treeコマンドのような文字列を生成して返すメソッド */ async listAllItemsAsTree(folderName = ""): Promise<string> { // ルートフォルダ(vault直下)を指定するときは空文字列を渡す // インデント用文字列は初回呼び出し時は空にしておく return await this.listAllItemsRecursive(folderName, ""); } // 再帰的にアイテムを走査し、階層構造を文字列で返す内部メソッド private async listAllItemsRecursive( folderName: string, indent: string ): Promise<string> { let cleanedFolder = folderName.replace(/\/$/, ""); // folderName が空であれば "/vault/"、そうでなければ "/vault/[folderName]/" const urlPath = cleanedFolder ? `/vault/${encodeURIComponent(cleanedFolder)}/` : `/vault/`; // フォルダ配下の要素を取得 const response = await this.client.get(urlPath); const items: string[] = response.data.files ?? []; let treeStr = ""; const totalCount = items.length; for (let i = 0; i < totalCount; i++) { const item = items[i]; const isLast = i === totalCount - 1; // "├──" または "└──" const branch = isLast ? "└── " : "├── "; // サブ階層で使うインデント; "│ " または " " const newIndent = indent + (isLast ? " " : "│ "); if (item.endsWith("/")) { // フォルダの場合 treeStr += `${indent}${branch}${item}\n`; // 末尾スラッシュを除去しつつ再帰的に取得 const childFolder = item.replace(/\/$/, ""); const nextFolderName = cleanedFolder ? `${cleanedFolder}/${childFolder}` : childFolder; treeStr += await this.listAllItemsRecursive(nextFolderName, newIndent); } else { // ファイルの場合 treeStr += `${indent}${branch}${item}\n`; } } return treeStr; } async searchWithDQL(query: string): Promise<AdvancedSearchResult[]> { try { const response = await this.client.post("/search/", query, { headers: { ...this.defaultHeaders, "Content-Type": "application/vnd.olrapi.dataview.dql+txt", }, }); return response.data as AdvancedSearchResult[]; } catch (error: any) { throw error; } } async searchWithJsonLogic(query: object): Promise<AdvancedSearchResult[]> { try { const response = await this.client.post( "/search/", JSON.stringify(query), { headers: { ...this.defaultHeaders, "Content-Type": "application/vnd.olrapi.jsonlogic+json", }, } ); return response.data as AdvancedSearchResult[]; } catch (error: any) { throw error; } } }