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;
}
}
}