Capture current Ansible control plane state

Commit the accumulated infrastructure work that was living only in the
working tree: monitoring stack, emergency access/bot, gyro allocator,
grimmory, adguard, backup audit and the OpenCode agent definitions.

Also ignore Python bytecode, local archives and Nix/direnv artifacts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTocXkGUUazHdKKd3r9k71
This commit is contained in:
Dmitry
2026-08-26 21:39:28 +03:00
co-authored by Claude Opus 5
parent 4bafa7d09e
commit c676be81ec
126 changed files with 10583 additions and 44 deletions
+42
View File
@@ -0,0 +1,42 @@
# Grimmory OpenCode MCP
Read-only Grimmory `v3.2.4` API integration with manual synchronization to
individual Obsidian book notes.
## Security
- Grimmory mutations are not implemented.
- Authentication uses the current user's local login because annotations and
reading progress are user-scoped.
- The password is read from a mode `0600` file and is never passed in argv.
- Access and refresh tokens remain in process memory only.
## Configure
```bash
cd tools/grimmory-mcp
npm install
npm run configure
```
The default password file is:
```text
~/.config/opencode/secrets/grimmory_password
```
## Obsidian behavior
- Notes: `90 Library/Books/<title> — grimmory-<id>.md`
- Covers: `99 System/Export/Grimmory/Covers/<id>.<ext>`
- Existing notes are matched by `grimmory_id`, so title changes rename instead
of duplicate the note.
- Text between `grimmory:user:start` and `grimmory:user:end` is preserved.
- Generated metadata, progress, annotations and sessions are replaced from
Grimmory on every sync.
- `99 System/INDEX.md` is rebuilt after a successful manual sync.
## OpenCode
Restart OpenCode after changing its MCP configuration. Run `/grimmory-sync` to
synchronize the complete accessible library manually.
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "grimmory-opencode-mcp",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Read-only Grimmory MCP server with manual Obsidian synchronization",
"engines": {
"node": ">=22"
},
"scripts": {
"start": "node src/server.js",
"configure": "node src/configure.js",
"test": "node --test"
},
"dependencies": {
"@modelcontextprotocol/sdk": "1.30.0",
"zod": "3.25.76"
}
}
+311
View File
@@ -0,0 +1,311 @@
import { constants } from "node:fs";
import { open } from "node:fs/promises";
function unwrap(payload) {
if (
payload &&
typeof payload === "object" &&
!Array.isArray(payload) &&
Object.hasOwn(payload, "data")
) {
return payload.data;
}
return payload;
}
export class GrimmoryClient {
constructor({ baseUrl, username, passwordFile, fetchImpl = fetch }) {
this.baseUrl = baseUrl.replace(/\/$/, "");
this.username = username;
this.passwordFile = passwordFile;
this.fetch = fetchImpl;
this.accessToken = null;
this.refreshToken = null;
this.loginPromise = null;
this.refreshPromise = null;
}
async credentials() {
let password;
let handle;
try {
handle = await open(
this.passwordFile,
constants.O_RDONLY | constants.O_NOFOLLOW,
);
const info = await handle.stat();
if (!info.isFile()) {
throw new Error("Grimmory password path must be a regular file");
}
if (process.getuid && info.uid !== process.getuid()) {
throw new Error("Grimmory password file must be owned by the current user");
}
if ((info.mode & 0o077) !== 0) {
throw new Error("Grimmory password file must not be accessible by group or others");
}
password = await handle.readFile("utf8");
} catch (error) {
if (error.code === "ENOENT") {
throw new Error(
`Grimmory password file is missing: ${this.passwordFile}. Run npm run configure in the MCP directory.`,
);
}
throw error;
} finally {
await handle?.close();
}
if (!password) throw new Error("Grimmory password file is empty");
return { username: this.username, password };
}
async login(attempt = 0) {
const response = await this.fetch(`${this.baseUrl}/api/v1/auth/login`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(await this.credentials()),
});
let payload;
try {
payload = await this.parseResponse(response);
} catch (error) {
// v3.2.4 can generate the same refresh JWT for concurrent logins in one second.
if (error.status === 400 && attempt === 0) {
await new Promise((resolve) => setTimeout(resolve, 1100));
return this.login(1);
}
throw error;
}
const tokens = unwrap(payload);
if (!tokens?.accessToken || !tokens?.refreshToken) {
throw new Error("Grimmory login response does not contain tokens");
}
this.accessToken = tokens.accessToken;
this.refreshToken = tokens.refreshToken;
}
async refresh() {
if (!this.refreshToken) return this.login();
if (!this.refreshPromise) {
this.refreshPromise = (async () => {
const response = await this.fetch(`${this.baseUrl}/api/v1/auth/refresh`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ refreshToken: this.refreshToken }),
});
if (!response.ok) {
this.accessToken = null;
this.refreshToken = null;
return this.login();
}
const tokens = unwrap(await this.parseResponse(response));
this.accessToken = tokens.accessToken;
this.refreshToken = tokens.refreshToken;
})().finally(() => {
this.refreshPromise = null;
});
}
return this.refreshPromise;
}
async ensureLogin() {
if (this.accessToken) return;
if (!this.loginPromise) {
this.loginPromise = this.login().finally(() => {
this.loginPromise = null;
});
}
await this.loginPromise;
}
async parseResponse(response) {
if (response.status === 204) return null;
const text = await response.text();
let payload = null;
if (text) {
try {
payload = JSON.parse(text);
} catch {
payload = text;
}
}
if (!response.ok) {
const message =
payload?.message || payload?.error || text || response.statusText;
throw Object.assign(
new Error(`Grimmory API ${response.status}: ${message}`),
{ status: response.status },
);
}
return payload;
}
async request(path, { optional = false, raw = false, retry = true } = {}) {
await this.ensureLogin();
const response = await this.fetch(`${this.baseUrl}${path}`, {
headers: { authorization: `Bearer ${this.accessToken}` },
});
if (response.status === 401 && retry) {
await this.refresh();
return this.request(path, { optional, raw, retry: false });
}
if (optional && [204, 404].includes(response.status)) return null;
if (raw) {
if (!response.ok) await this.parseResponse(response);
return response;
}
return unwrap(await this.parseResponse(response));
}
currentUser() {
return this.request("/api/v1/users/me");
}
async libraries() {
try {
return await this.request("/api/v1/app/libraries");
} catch (error) {
if (!error.message.startsWith("Grimmory API 500:")) throw error;
const user = await this.currentUser();
if (user.assignedLibraries?.length) return user.assignedLibraries;
const books = await this.allBooks();
const byLibrary = new Map();
for (const book of books) {
const entry = byLibrary.get(book.libraryId) || {
id: book.libraryId,
name: null,
bookCount: 0,
};
entry.bookCount += 1;
byLibrary.set(book.libraryId, entry);
}
for (const entry of byLibrary.values()) {
const sample = books.find((book) => book.libraryId === entry.id);
if (sample) {
const detail = await this.request(
`/api/v1/books/${sample.id}?withDescription=false`,
);
entry.name = detail.libraryName || `Library ${entry.id}`;
}
}
return [...byLibrary.values()];
}
}
shelves() {
return this.request("/api/v1/app/shelves");
}
authors(params = {}) {
return this.request(`/api/v1/app/authors?${query(params)}`);
}
filterOptions(params = {}) {
return this.request(`/api/v1/app/filter-options?${query(params)}`);
}
books(params = {}) {
return this.request(`/api/v1/app/books?${query(params)}`);
}
searchBooks(search, params = {}) {
return this.request(
`/api/v1/app/books/search?${query({ q: search, ...params })}`,
);
}
async allBooks() {
const books = [];
const seenPages = new Set();
for (let page = 0; page < 10_000; page += 1) {
const result = await this.books({ page, size: 50, sort: "title", dir: "asc" });
if (!Array.isArray(result?.content)) {
throw new Error("Grimmory books response has no content array");
}
const signature = result.content.map((book) => book.id).join(",");
if (seenPages.has(signature) && signature) {
throw new Error("Grimmory books pagination repeated a page");
}
seenPages.add(signature);
books.push(...result.content);
if (!hasNextPage(result, page, 50)) return books;
}
throw new Error("Grimmory books pagination exceeded the safety limit");
}
async springPage(path) {
const entries = [];
const seenPages = new Set();
for (let page = 0; page < 10_000; page += 1) {
const separator = path.includes("?") ? "&" : "?";
const result = await this.request(`${path}${separator}page=${page}&size=100`);
if (!Array.isArray(result?.content)) {
throw new Error("Grimmory session response has no content array");
}
const signature = JSON.stringify(result.content.map((entry) => entry.id));
if (seenPages.has(signature) && result.content.length) {
throw new Error("Grimmory session pagination repeated a page");
}
seenPages.add(signature);
entries.push(...result.content);
if (!hasNextPage(result, page, 100)) return entries;
}
throw new Error("Grimmory session pagination exceeded the safety limit");
}
async bookBundle(bookId) {
const id = Number(bookId);
const [book, progress, notebook, annotations, bookmarks, notes, cfiNotes, reviews, sessions] =
await Promise.all([
this.request(`/api/v1/books/${id}?withDescription=true`),
this.request(`/api/v1/app/books/${id}/progress`, { optional: true }),
this.request(`/api/v1/notebook/export?bookId=${id}`, { optional: true }),
this.request(`/api/v1/annotations/book/${id}`, { optional: true }),
this.request(`/api/v1/bookmarks/book/${id}`, { optional: true }),
this.request(`/api/v1/book-notes/book/${id}`, { optional: true }),
this.request(`/api/v2/book-notes/book/${id}`, { optional: true }),
this.request(`/api/v1/reviews/book/${id}`, { optional: true }),
this.springPage(`/api/v1/reading-sessions/book/${id}`),
]);
return {
book,
progress,
notebook: notebook || [],
annotations: annotations || [],
bookmarks: bookmarks || [],
notes: notes || [],
cfiNotes: cfiNotes || [],
reviews: reviews || [],
sessions,
};
}
cover(bookId) {
return this.request(`/api/v1/media/book/${Number(bookId)}/cover`, {
optional: true,
raw: true,
});
}
}
export function query(values) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(values)) {
if (value === undefined || value === null || value === "") continue;
for (const item of Array.isArray(value) ? value : [value]) {
params.append(key, String(item));
}
}
return params.toString();
}
export function hasNextPage(result, currentPage, pageSize) {
if (typeof result.hasNext === "boolean") return result.hasNext;
if (typeof result.last === "boolean") return !result.last;
const number = result.page?.number ?? result.number ?? currentPage;
const totalPages = result.page?.totalPages ?? result.totalPages;
if (totalPages !== undefined) return number + 1 < totalPages;
if (result.content.length < pageSize) return false;
throw new Error("Grimmory books response has no pagination metadata");
}
export { unwrap };
+42
View File
@@ -0,0 +1,42 @@
import { chmod, mkdir, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import process from "node:process";
const destination =
process.env.GRIMMORY_PASSWORD_FILE ||
path.join(os.homedir(), ".config/opencode/secrets/grimmory_password");
if (!process.stdin.isTTY) {
console.error("Run this command in an interactive terminal.");
process.exit(1);
}
process.stdout.write("Grimmory password: ");
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.setEncoding("utf8");
let password = "";
process.stdin.on("data", async (character) => {
if (character === "\u0003") process.exit(130);
if (character === "\r" || character === "\n") {
process.stdin.setRawMode(false);
process.stdin.pause();
process.stdout.write("\n");
if (!password) {
console.error("Password cannot be empty.");
process.exit(1);
}
await mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
await writeFile(destination, password, { mode: 0o600 });
await chmod(destination, 0o600);
console.log(`Password saved to ${destination}`);
return;
}
if (character === "\u007f") {
password = password.slice(0, -1);
return;
}
password += character;
});
+371
View File
@@ -0,0 +1,371 @@
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { constants } from "node:fs";
import { lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const USER_START = "<!-- grimmory:user:start -->";
const USER_END = "<!-- grimmory:user:end -->";
export class ObsidianSync {
constructor({ vaultPath, booksDir, coversDir, indexScript }) {
this.vaultPath = path.resolve(vaultPath);
this.booksPath = safeJoin(this.vaultPath, booksDir);
this.coversDir = coversDir;
this.coversPath = safeJoin(this.vaultPath, coversDir);
this.indexScript = safeJoin(this.vaultPath, indexScript);
}
async ensureDirectories() {
await mkdir(this.booksPath, { recursive: true });
await mkdir(this.coversPath, { recursive: true });
const realVault = await realpath(this.vaultPath);
assertWithin(realVault, await realpath(this.booksPath));
assertWithin(realVault, await realpath(this.coversPath));
}
async findExistingNote(bookId) {
let files = [];
try {
files = await readdir(this.booksPath);
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
const matches = [];
for (const file of files.filter((name) => name.endsWith(".md"))) {
const filePath = path.join(this.booksPath, file);
if (frontmatterId(await readFile(filePath, "utf8")) === Number(bookId)) {
matches.push(filePath);
}
}
if (matches.length > 1) {
throw new Error(`Duplicate Obsidian notes for grimmory_id ${bookId}`);
}
return matches[0] || null;
}
async saveCover(client, bookId) {
await this.ensureDirectories();
const response = await client.cover(bookId);
if (!response) return null;
const declaredSize = Number(response.headers.get("content-length"));
if (declaredSize > 10 * 1024 * 1024) throw new Error("Grimmory cover exceeds 10 MB");
const bytes = Buffer.from(await response.arrayBuffer());
if (bytes.length > 10 * 1024 * 1024) throw new Error("Grimmory cover exceeds 10 MB");
const extension = coverExtension(bytes);
if (!extension) throw new Error("Grimmory cover response is not a supported image");
const relative = path.join(this.coversDir, `${bookId}.${extension}`);
const destination = safeJoin(this.vaultPath, relative);
await atomicWrite(destination, bytes);
return relative.split(path.sep).join("/");
}
async syncBook(bundle, coverPath = null) {
await this.ensureDirectories();
const title = bookTitle(bundle.book);
const desiredPath = path.join(
this.booksPath,
`${sanitizeFilename(title)} — grimmory-${bundle.book.id}.md`,
);
const existingPath = await this.findExistingNote(bundle.book.id);
let userSection = defaultUserSection();
if (existingPath) {
const existingContent = await readFile(existingPath, "utf8");
if (!hasValidUserSection(existingContent)) {
throw new Error(`Existing Grimmory note has invalid user markers: ${existingPath}`);
}
userSection = extractUserSection(existingContent);
}
if (desiredPath !== existingPath) {
try {
await lstat(desiredPath);
throw new Error(`Refusing to overwrite unrelated Obsidian note: ${desiredPath}`);
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
}
await atomicWrite(desiredPath, renderBookNote(bundle, coverPath, userSection));
if (existingPath && existingPath !== desiredPath) await rm(existingPath);
return path.relative(this.vaultPath, desiredPath);
}
async rebuildIndex() {
const realVault = await realpath(this.vaultPath);
const realScript = await realpath(this.indexScript);
assertWithin(realVault, realScript);
if (!(await stat(realScript)).isFile()) throw new Error("Vault index script is not a regular file");
const { stdout } = await execFileAsync("python3", [realScript, "rebuild"], {
cwd: this.vaultPath,
timeout: 120_000,
});
return stdout.trim();
}
}
export function renderBookNote(bundle, coverPath = null, userSection = defaultUserSection()) {
const { book, progress = {}, notebook = [], annotations = [], bookmarks = [], notes = [], cfiNotes = [], reviews = [], sessions = [] } = bundle;
const metadata = book.metadata || {};
const title = bookTitle(book);
const authors = metadata.authors || book.authors || [];
const status = studyStatus(book.readStatus || progress.readStatus);
const percent = progressPercent(progress.readProgress ?? book.readProgress);
const updated = new Date().toISOString().slice(0, 10);
const created = dateOnly(book.addedOn) || updated;
const cover = coverPath ? `[[${coverPath}]]` : "";
const frontmatter = [
"---",
`title: ${yamlString(title)}`,
"type: study",
"tags: [study, book, grimmory]",
`created: ${created}`,
`updated: ${updated}`,
`aliases: ${yamlArray([title])}`,
"kind: book",
`status: ${status}`,
"area:",
"priority: medium",
`author: ${yamlString(authors.join(", "))}`,
`url: ${yamlString(`https://books.ada-dev.ru/book/${book.id}`)}`,
`cover: ${yamlString(cover)}`,
`pages: ${metadata.pageCount ?? ""}`,
`started: ${firstSessionDate(sessions)}`,
`finished: ${dateOnly(book.dateFinished)}`,
"related: []",
`grimmory_id: ${book.id}`,
`grimmory_library_id: ${book.libraryId}`,
`grimmory_read_status: ${yamlString(book.readStatus || progress.readStatus || "UNSET")}`,
`grimmory_progress: ${percent ?? ""}`,
`grimmory_rating: ${book.personalRating ?? ""}`,
`grimmory_synced_at: ${yamlString(new Date().toISOString())}`,
"---",
].join("\n");
const info = [
`# ${title}`,
"",
"<!-- grimmory:generated:start -->",
"## Информация",
`- **Авторы:** ${authors.join(", ") || "—"}`,
`- **Серия:** ${metadata.seriesName || metadata.series || "—"}${metadata.seriesNumber ? ` #${metadata.seriesNumber}` : ""}`,
`- **Издатель:** ${metadata.publisher || "—"}`,
`- **Дата публикации:** ${metadata.publishedDate || "—"}`,
`- **ISBN:** ${[metadata.isbn13, metadata.isbn10].filter(Boolean).join(", ") || "—"}`,
`- **Язык:** ${metadata.language || "—"}`,
`- **Страниц:** ${metadata.pageCount || "—"}`,
`- **Категории:** ${joinList(metadata.categories)}`,
`- **Теги:** ${joinList(metadata.tags)}`,
`- **Настроения:** ${joinList(metadata.moods)}`,
`- **Полки:** ${joinList((book.shelves || []).map((shelf) => shelf.name))}`,
`- **Статус:** ${book.readStatus || progress.readStatus || "UNSET"}`,
`- **Прогресс:** ${percent === null ? "—" : `${percent}%`}`,
`- **Личная оценка:** ${book.personalRating || "—"}`,
"",
...(coverPath ? [`![[${coverPath}]]`, ""] : []),
"## Описание",
metadata.description || book.description || "Описание отсутствует.",
"",
renderProgress(progress),
renderNotebook(notebook, annotations, bookmarks, notes, cfiNotes),
renderSessions(sessions),
renderReviews(reviews),
"<!-- grimmory:generated:end -->",
"",
userSection.trim(),
"",
];
return `${frontmatter}\n\n${info.join("\n")}`;
}
function renderProgress(progress) {
const entries = [
["Общий", progress.readProgress],
["EPUB", progress.epubProgress?.percentage],
["PDF", progress.pdfProgress?.percentage],
["CBX", progress.cbxProgress?.percentage],
["Аудиокнига", progress.audiobookProgress?.percentage],
["KOReader", progress.koreaderProgress?.percentage],
].filter(([, value]) => value !== undefined && value !== null);
const lines = ["## Прогресс чтения"];
if (!entries.length) lines.push("Прогресс отсутствует.");
for (const [label, value] of entries) {
lines.push(`- **${label}:** ${progressPercent(value)}%`);
}
if (progress.koreaderProgress?.device) {
lines.push(`- **Устройство KOReader:** ${progress.koreaderProgress.device}`);
}
if (progress.lastReadTime) lines.push(`- **Последнее чтение:** ${progress.lastReadTime}`);
return lines.join("\n");
}
function renderNotebook(notebook, annotations, bookmarks, notes, cfiNotes) {
const lines = ["## Аннотации и заметки"];
const entries = notebook.length
? notebook.map((entry) => ({
type: entry.type,
chapter: entry.chapterTitle,
text: entry.text,
note: entry.note,
createdAt: entry.createdAt,
}))
: [
...annotations.map((entry) => ({ type: "annotation", chapter: entry.chapterTitle, text: entry.text, note: entry.note, createdAt: entry.createdAt })),
...bookmarks.map((entry) => ({ type: "bookmark", text: entry.title, note: entry.notes, createdAt: entry.createdAt })),
...notes.map((entry) => ({ type: "note", text: entry.title, note: entry.content, createdAt: entry.createdAt })),
...cfiNotes.map((entry) => ({ type: "cfi-note", chapter: entry.chapterTitle, text: entry.selectedText, note: entry.noteContent, createdAt: entry.createdAt })),
];
if (!entries.length) lines.push("Аннотации отсутствуют.");
for (const entry of entries) {
lines.push(`### ${entry.chapter || entry.type || "Запись"}`);
if (entry.text) lines.push(`> ${String(entry.text).replace(/\n/g, "\n> ")}`);
if (entry.note) lines.push("", entry.note);
if (entry.createdAt) lines.push("", `_${entry.createdAt}_`);
}
return lines.join("\n");
}
function renderSessions(sessions) {
const lines = ["## Сессии чтения"];
if (!sessions.length) lines.push("Сессии отсутствуют.");
for (const session of sessions) {
lines.push(
`- ${session.startTime}${Math.round((session.durationSeconds || 0) / 60)} мин, ${progressPercent(session.startProgress)}% → ${progressPercent(session.endProgress)}%`,
);
}
return lines.join("\n");
}
function renderReviews(reviews) {
const lines = ["## Внешние отзывы"];
if (!reviews.length) lines.push("Отзывы отсутствуют.");
for (const review of reviews) {
lines.push(`### ${review.title || review.reviewerName || review.metadataProvider || "Отзыв"}`);
lines.push(`- **Источник:** ${review.metadataProvider || "—"}`);
if (review.rating) lines.push(`- **Оценка:** ${review.rating}`);
if (review.body) lines.push("", review.body);
}
return lines.join("\n");
}
export function extractUserSection(content) {
const start = content.indexOf(USER_START);
const end = content.indexOf(USER_END);
if (start === -1 || end === -1 || end < start) return defaultUserSection();
return content.slice(start, end + USER_END.length);
}
export function hasValidUserSection(content) {
const start = content.indexOf(USER_START);
const end = content.indexOf(USER_END);
return start !== -1 && end !== -1 && end > start && content.indexOf(USER_START, start + 1) === -1 && content.indexOf(USER_END, end + 1) === -1;
}
export function defaultUserSection() {
return `${USER_START}\n## Заметки\n\n## Связанные заметки\n\n${USER_END}`;
}
export function sanitizeFilename(value) {
const result = String(value)
.replace(/[\\/:*?"<>|]/g, "-")
.replace(/\s+/g, " ")
.replace(/[. ]+$/g, "")
.trim();
return (result || "Без названия").slice(0, 160);
}
export function progressPercent(value) {
if (value === undefined || value === null || value === "") return null;
const number = Number(value);
if (!Number.isFinite(number)) return null;
return Math.round((number <= 1 ? number * 100 : number) * 10) / 10;
}
function studyStatus(status) {
return {
READING: "active",
RE_READING: "active",
PAUSED: "paused",
READ: "done",
WONT_READ: "dropped",
ABANDONED: "dropped",
}[status] || "planned";
}
function bookTitle(book) {
return book.metadata?.title || book.title || `Книга ${book.id}`;
}
function yamlString(value) {
return JSON.stringify(value ?? "");
}
function yamlArray(values) {
return JSON.stringify(values.filter(Boolean));
}
function joinList(values) {
return Array.isArray(values) && values.length ? values.join(", ") : "—";
}
function dateOnly(value) {
return value ? String(value).slice(0, 10) : "";
}
function firstSessionDate(sessions) {
const dates = sessions.map((session) => session.startTime).filter(Boolean).sort();
return dateOnly(dates[0]);
}
function coverExtension(bytes) {
if (bytes[0] === 0xff && bytes[1] === 0xd8) return "jpg";
if (bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "png";
if (bytes.subarray(0, 6).toString("ascii").startsWith("GIF8")) return "gif";
if (bytes.subarray(0, 4).toString("ascii") === "RIFF" && bytes.subarray(8, 12).toString("ascii") === "WEBP") return "webp";
return null;
}
function safeJoin(root, relative) {
const result = path.resolve(root, relative);
if (result !== root && !result.startsWith(`${root}${path.sep}`)) {
throw new Error(`Path escapes Obsidian vault: ${relative}`);
}
return result;
}
function assertWithin(root, candidate) {
if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) {
throw new Error(`Resolved path escapes Obsidian vault: ${candidate}`);
}
}
function frontmatterId(content) {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
if (!match) return null;
const markers = [...match[1].matchAll(/^grimmory_id:\s*(.*?)\s*$/gm)];
if (!markers.length) return null;
if (markers.length !== 1 || !/^\d+$/.test(markers[0][1])) {
throw new Error("Ambiguous or malformed grimmory_id in Obsidian frontmatter");
}
return Number(markers[0][1]);
}
async function atomicWrite(destination, content) {
await mkdir(path.dirname(destination), { recursive: true });
const temporary = `${destination}.tmp-${randomUUID()}`;
let handle;
try {
handle = await open(
temporary,
constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW,
0o600,
);
await handle.writeFile(content);
await handle.close();
handle = null;
await rename(temporary, destination);
} finally {
await handle?.close();
await rm(temporary, { force: true });
}
}
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { GrimmoryClient } from "./client.js";
import { ObsidianSync } from "./obsidian.js";
const config = {
baseUrl: process.env.GRIMMORY_BASE_URL || "https://books.ada-dev.ru",
username: process.env.GRIMMORY_USERNAME || "ada",
passwordFile:
process.env.GRIMMORY_PASSWORD_FILE ||
`${process.env.HOME}/.config/opencode/secrets/grimmory_password`,
vaultPath:
process.env.OBSIDIAN_VAULT ||
`${process.env.HOME}/Documents/Vaults/SecondBrain`,
booksDir: process.env.OBSIDIAN_BOOKS_DIR || "90 Library/Books",
coversDir:
process.env.OBSIDIAN_COVERS_DIR ||
"99 System/Export/Grimmory/Covers",
indexScript:
process.env.OBSIDIAN_INDEX_SCRIPT ||
"99 System/Tools/agent-tools/vault_index.py",
};
if (!config.baseUrl.startsWith("https://")) {
throw new Error("GRIMMORY_BASE_URL must use HTTPS");
}
const client = new GrimmoryClient(config);
const obsidian = new ObsidianSync(config);
const server = new McpServer({ name: "grimmory", version: "0.1.0" });
const jsonResult = (data, isError = false) => ({
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
structuredContent: { result: data },
isError,
});
server.registerTool(
"current_user",
{ description: "Return the current Grimmory user, permissions and assigned libraries." },
async () => jsonResult(await client.currentUser()),
);
server.registerTool(
"list_libraries",
{ description: "List Grimmory libraries accessible to the current user." },
async () => jsonResult(await client.libraries()),
);
server.registerTool(
"list_shelves",
{ description: "List Grimmory shelves accessible to the current user." },
async () => jsonResult(await client.shelves()),
);
server.registerTool(
"list_books",
{
description: "List a page of Grimmory books with metadata and reading state.",
inputSchema: {
page: z.number().int().min(0).default(0),
size: z.number().int().min(1).max(50).default(20),
libraryId: z.number().int().positive().optional(),
status: z.array(z.string()).optional(),
sort: z.string().default("title"),
dir: z.enum(["asc", "desc"]).default("asc"),
},
},
async (input) => jsonResult(await client.books(input)),
);
server.registerTool(
"search_books",
{
description: "Search Grimmory books by title, author or metadata.",
inputSchema: {
query: z.string().min(1),
page: z.number().int().min(0).default(0),
size: z.number().int().min(1).max(50).default(20),
},
},
async ({ query, ...params }) =>
jsonResult(await client.searchBooks(query, params)),
);
server.registerTool(
"get_book",
{
description:
"Return a rich book bundle: metadata, progress, annotations, bookmarks, notes, reviews and reading sessions.",
inputSchema: { bookId: z.number().int().positive() },
},
async ({ bookId }) => jsonResult(await client.bookBundle(bookId)),
);
server.registerTool(
"list_authors",
{
description: "List a page of Grimmory authors.",
inputSchema: {
page: z.number().int().min(0).default(0),
size: z.number().int().min(1).max(50).default(30),
search: z.string().optional(),
},
},
async (input) => jsonResult(await client.authors(input)),
);
server.registerTool(
"get_filter_options",
{
description:
"Return available authors, categories, tags, moods, statuses and other catalog filter values.",
inputSchema: { libraryId: z.number().int().positive().optional() },
},
async (input) => jsonResult(await client.filterOptions(input)),
);
server.registerTool(
"sync_book_to_obsidian",
{
description:
"Fetch one Grimmory book and update its Obsidian note while preserving the user notes/links section.",
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
inputSchema: {
bookId: z.number().int().positive(),
includeCover: z.boolean().default(true),
rebuildIndex: z.boolean().default(true),
},
},
async ({ bookId, includeCover, rebuildIndex }) => {
const bundle = await client.bookBundle(bookId);
const cover = includeCover ? await obsidian.saveCover(client, bookId) : null;
const note = await obsidian.syncBook(bundle, cover);
try {
const index = rebuildIndex ? await obsidian.rebuildIndex() : null;
return jsonResult({ status: "complete", bookId, note, cover, indexRebuilt: rebuildIndex, index });
} catch (error) {
return jsonResult({ status: "index_failed", bookId, note, cover, indexRebuilt: false, error: error.message }, true);
}
},
);
server.registerTool(
"sync_all_to_obsidian",
{
description:
"Manually synchronize every accessible Grimmory book to an individual Obsidian note, then rebuild the vault index.",
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
inputSchema: { includeCovers: z.boolean().default(true) },
},
async ({ includeCovers }) => {
const books = await client.allBooks();
const results = [];
const failures = [];
for (const book of books) {
try {
const bundle = await client.bookBundle(book.id);
const cover = includeCovers
? await obsidian.saveCover(client, book.id)
: null;
const note = await obsidian.syncBook(bundle, cover);
results.push({ bookId: book.id, title: book.title, note, cover });
} catch (error) {
failures.push({ bookId: book.id, title: book.title, error: error.message });
}
}
let index = null;
let indexError = null;
if (results.length || failures.length) {
try {
index = await obsidian.rebuildIndex();
} catch (error) {
indexError = error.message;
}
}
const status = indexError ? "index_failed" : failures.length ? "partial" : "complete";
return jsonResult({
status,
total: books.length,
synchronized: results.length,
failed: failures.length,
results,
failures,
indexRebuilt: Boolean(index),
index,
indexError,
}, status !== "complete");
},
);
await server.connect(new StdioServerTransport());
+102
View File
@@ -0,0 +1,102 @@
import assert from "node:assert/strict";
import test from "node:test";
import { GrimmoryClient, query, unwrap } from "../src/client.js";
test("unwrap handles Grimmory API response envelope", () => {
assert.deepEqual(unwrap({ status: 200, data: { id: 7 } }), { id: 7 });
assert.deepEqual(unwrap([1, 2]), [1, 2]);
});
test("query repeats array values and skips empty values", () => {
assert.equal(
query({ page: 0, status: ["READING", "READ"], search: "" }),
"page=0&status=READING&status=READ",
);
});
test("libraries falls back to the current user on the v3.2.4 endpoint bug", async () => {
const responses = [
new Response(JSON.stringify({ message: "broken" }), { status: 500 }),
new Response(JSON.stringify({ data: { assignedLibraries: [{ id: 3 }] } })),
];
const client = new GrimmoryClient({
baseUrl: "https://example.test",
username: "ada",
passwordFile: "/unused",
fetchImpl: async () => responses.shift(),
});
client.accessToken = "test";
assert.deepEqual(await client.libraries(), [{ id: 3 }]);
});
test("springPage supports the nested Spring page metadata", async () => {
let request = 0;
const client = new GrimmoryClient({
baseUrl: "https://example.test",
username: "ada",
passwordFile: "/unused",
fetchImpl: async () => {
const page = request++;
return new Response(
JSON.stringify({
data: {
content: page === 0 ? Array.from({ length: 100 }, (_, id) => id) : [100],
page: { number: page, totalPages: 2 },
},
}),
);
},
});
client.accessToken = "test";
assert.equal((await client.springPage("/sessions")).length, 101);
assert.equal(request, 2);
});
test("allBooks follows nested pagination and rejects missing metadata", async () => {
let request = 0;
const client = new GrimmoryClient({
baseUrl: "https://example.test",
username: "ada",
passwordFile: "/unused",
fetchImpl: async () => {
const page = request++;
return new Response(JSON.stringify({ data: {
content: page === 0 ? Array.from({ length: 50 }, (_, id) => ({ id })) : [{ id: 50 }],
page: { number: page, totalPages: 2 },
} }));
},
});
client.accessToken = "test";
assert.equal((await client.allBooks()).length, 51);
assert.equal(request, 2);
});
test("concurrent first requests share one login", async () => {
let logins = 0;
const client = new GrimmoryClient({
baseUrl: "https://example.test",
username: "ada",
passwordFile: "/unused",
fetchImpl: async () => new Response(JSON.stringify({ data: { ok: true } })),
});
client.login = async () => {
logins += 1;
await new Promise((resolve) => setTimeout(resolve, 10));
client.accessToken = "test";
};
await Promise.all([client.request("/one"), client.request("/two")]);
assert.equal(logins, 1);
});
test("springPage rejects missing pagination metadata on a full page", async () => {
const client = new GrimmoryClient({
baseUrl: "https://example.test",
username: "ada",
passwordFile: "/unused",
fetchImpl: async () => new Response(JSON.stringify({ data: {
content: Array.from({ length: 100 }, (_, id) => ({ id })),
} })),
});
client.accessToken = "test";
await assert.rejects(() => client.springPage("/sessions"), /no pagination metadata/);
});
+192
View File
@@ -0,0 +1,192 @@
import assert from "node:assert/strict";
import { chmod, mkdir, mkdtemp, readFile, readdir, symlink, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import {
ObsidianSync,
extractUserSection,
progressPercent,
renderBookNote,
sanitizeFilename,
} from "../src/obsidian.js";
const bundle = {
book: {
id: 42,
libraryId: 1,
title: "Test Book",
readStatus: "READING",
personalRating: 4,
metadata: {
title: "Test Book",
authors: ["Ada Author"],
categories: ["Science"],
tags: ["AI"],
moods: [],
pageCount: 300,
description: "Description",
},
shelves: [{ name: "Reading" }],
},
progress: { readProgress: 0.425, koreaderProgress: { percentage: 42.5 } },
notebook: [{ type: "annotation", text: "Quote", note: "Comment" }],
annotations: [],
bookmarks: [],
notes: [],
cfiNotes: [],
reviews: [],
sessions: [],
};
test("render creates a study note with normalized progress", () => {
const note = renderBookNote(bundle, "99 System/Export/Grimmory/Covers/42.jpg");
assert.match(note, /grimmory_id: 42/);
assert.match(note, /status: active/);
assert.match(note, /grimmory_progress: 42.5/);
assert.match(note, /> Quote/);
});
test("user section survives extraction", () => {
const note = renderBookNote(bundle);
const edited = note.replace("## Связанные заметки", "## Связанные заметки\n\n- [[AI]]");
assert.match(extractUserSection(edited), /\[\[AI\]\]/);
});
test("filename and progress normalization are safe", () => {
assert.equal(sanitizeFilename('A/B: C?'), "A-B- C-");
assert.equal(progressPercent(0.5), 50);
assert.equal(progressPercent(75), 75);
});
test("syncBook creates one note in the configured vault directory", async () => {
const { vault, sync } = await tempSync();
const relative = await sync.syncBook(bundle);
const files = await readdir(path.join(vault, "Books"));
const content = await readFile(path.join(vault, relative), "utf8");
assert.equal(relative, "Books/Test Book — grimmory-42.md");
assert.deepEqual(files, ["Test Book — grimmory-42.md"]);
assert.match(content, /grimmory_id: 42/);
assert.match(content, /<!-- grimmory:user:start -->/);
});
test("syncBook preserves the user section and renames title changes without duplicates", async () => {
const { vault, sync } = await tempSync();
const first = await sync.syncBook({
...bundle,
book: { ...bundle.book, title: "Old Title", metadata: { ...bundle.book.metadata, title: "Old Title" } },
});
const edited = (await readFile(path.join(vault, first), "utf8")).replace(
"## Связанные заметки",
"## Связанные заметки\n\n- [[User Link]]",
);
await writeFile(path.join(vault, first), edited);
const second = await sync.syncBook({
...bundle,
book: { ...bundle.book, title: "New Title", metadata: { ...bundle.book.metadata, title: "New Title" } },
});
const files = await readdir(path.join(vault, "Books"));
const content = await readFile(path.join(vault, second), "utf8");
assert.equal(second, "Books/New Title — grimmory-42.md");
assert.deepEqual(files, ["New Title — grimmory-42.md"]);
assert.match(content, /\[\[User Link\]\]/);
});
test("saveCover writes only under the configured covers directory", async () => {
const { vault, sync } = await tempSync();
const client = {
cover: async () =>
new Response(Uint8Array.from([0xff, 0xd8, 0xff]), {
headers: { "content-type": "application/json" },
}),
};
const relative = await sync.saveCover(client, 42);
const bytes = await readFile(path.join(vault, relative));
assert.equal(relative, "Covers/42.jpg");
assert.deepEqual([...bytes], [0xff, 0xd8, 0xff]);
assert.throws(
() => new ObsidianSync({ vaultPath: vault, booksDir: "Books", coversDir: "../Covers", indexScript: "index.py" }),
/Path escapes Obsidian vault/,
);
});
test("syncBook ignores body IDs and refuses duplicate frontmatter IDs", async () => {
const { vault, sync } = await tempSync();
await mkdir(path.join(vault, "Books"), { recursive: true });
await writeFile(path.join(vault, "Books/unrelated.md"), "Body mentions\ngrimmory_id: 42\n");
await sync.syncBook(bundle);
assert.equal((await readdir(path.join(vault, "Books"))).length, 2);
await writeFile(
path.join(vault, "Books/duplicate.md"),
"---\ntitle: duplicate\ngrimmory_id: 42\n---\n",
);
await assert.rejects(() => sync.syncBook(bundle), /Duplicate Obsidian notes/);
});
test("syncBook refuses to overwrite a generated note with broken user markers", async () => {
const { vault, sync } = await tempSync();
const relative = await sync.syncBook(bundle);
const file = path.join(vault, relative);
const broken = (await readFile(file, "utf8")).replace("<!-- grimmory:user:end -->", "");
await writeFile(file, broken);
await assert.rejects(() => sync.syncBook(bundle), /invalid user markers/);
assert.equal(await readFile(file, "utf8"), broken);
});
test("syncBook rejects malformed or repeated frontmatter IDs", async () => {
for (const marker of ["grimmory_id: 42 # comment", "grimmory_id: 42\ngrimmory_id: 42"]) {
const { vault, sync } = await tempSync();
await mkdir(path.join(vault, "Books"), { recursive: true });
await writeFile(path.join(vault, "Books/bad.md"), `---\ntitle: Bad\n${marker}\n---\n`);
await assert.rejects(() => sync.syncBook(bundle), /Ambiguous or malformed/);
}
});
test("saveCover rejects a covers-directory symlink outside the vault", async () => {
const { vault, sync } = await tempSync();
const outside = await mkdtemp(path.join(os.tmpdir(), "grimmory-cover-outside-"));
await symlink(outside, path.join(vault, "Covers"));
let requested = false;
await assert.rejects(
() => sync.saveCover({ cover: async () => { requested = true; } }, 42),
/Resolved path escapes Obsidian vault/,
);
assert.equal(requested, false);
});
test("rebuildIndex invokes the configured script inside the temporary vault", async () => {
const { vault, sync } = await tempSync();
await writeFile(
path.join(vault, "index.py"),
[
"import os, pathlib, sys",
"pathlib.Path('index-called.txt').write_text(sys.argv[1] + '\\n' + os.getcwd())",
"print('rebuilt')",
].join("\n"),
);
await chmod(path.join(vault, "index.py"), 0o700);
assert.equal(await sync.rebuildIndex(), "rebuilt");
const marker = await readFile(path.join(vault, "index-called.txt"), "utf8");
assert.equal(marker, `rebuild\n${vault}`);
});
async function tempSync() {
const vault = await mkdtemp(path.join(os.tmpdir(), "grimmory-obsidian-test-"));
return {
vault,
sync: new ObsidianSync({
vaultPath: vault,
booksDir: "Books",
coversDir: "Covers",
indexScript: "index.py",
}),
};
}
+27
View File
@@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
test("MCP server exposes the read and manual sync tools", async () => {
const transport = new StdioClientTransport({
command: process.execPath,
args: [path.join(projectRoot, "src/server.js")],
env: { ...process.env },
});
const client = new Client({ name: "grimmory-test", version: "0.1.0" });
try {
await client.connect(transport);
const { tools } = await client.listTools();
const names = tools.map((tool) => tool.name);
assert.ok(names.includes("get_book"));
assert.ok(names.includes("sync_all_to_obsidian"));
assert.equal(names.length, 10);
} finally {
await client.close();
}
});