Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .coderabbit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ reviews:
instructions: 後で見返した時に分かるように、適切に英語でコメントが書かれている。
mode: warning
- name: インポート順
instructions: TypeScriptのインポート順番は、標準ライブラリ・サードパーティーライブラリ、プロジェクトのライブラリ。さらに英語の昇順で並んでいること
instructions: TypeScriptのインポート順番は、biome正準順に従っていること
mode: warning
finishing_touches:
simplify:
Expand All @@ -43,4 +43,4 @@ reviews:
instructions: 後で見返した時に分かるように、適切に英語でコメントを記述する
- enabled: true
name: Import
instructions: TypeScriptのインポート順番を、標準ライブラリ・サードパーティーライブラリ、プロジェクトのライブラリ。さらに英語の昇順に変更する
instructions: TypeScriptのインポート順番を、biome正準順に従って整理する
24 changes: 11 additions & 13 deletions .tmp/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,29 +173,27 @@

## T10. MCP サーバー (F-7)

- [ ] T10.1 MCP SDK 採用(`@modelcontextprotocol/sdk`、Bun 互換性確認)
- [x] T10.1 MCP SDK 採用(`@modelcontextprotocol/sdk`、Bun 互換性確認)
- 検証: 最小サーバーが立ち上がる
- [ ] T10.2 stdio トランスポート起動(`dennoh serve`)
- [x] T10.2 stdio トランスポート起動(`dennoh serve`)
- 検証: stdin に initialize リクエストを流して initialize レスポンスを得る
- [ ] T10.3 stdout 汚染防止: ログ・診断は stderr に固定(T0.6 のロガーで保証)
- [x] T10.3 stdout 汚染防止: ログ・診断は stderr に固定(T0.6 のロガーで保証)
- 検証: stdout を JSON 行のみに保つテスト
- [ ] T10.4 `save_memory(content, source?)` ツール登録
- [x] T10.4 `save_memory(content, source?)` ツール登録
- 検証: ツール呼び出しでファイルが生成される統合テスト
- [ ] T10.5 `update_memory(id, content)` ツール登録
- [x] T10.5 `update_memory(id, content)` ツール登録
- 検証: 既存ノートが更新される
- [ ] T10.6 `delete_memory(id)` ツール登録
- [x] T10.6 `delete_memory(id)` ツール登録
- 検証: ノートが削除される
- [ ] T10.7 `search_memory(query, filters?, limit?)` ツール登録
- [x] T10.7 `search_memory(query, filters?, limit?)` ツール登録
- 検証: クエリで検索結果が返る
- [ ] T10.8 `list_recent(limit?)` ツール登録
- [x] T10.8 `list_recent(limit?)` ツール登録
- 検証: 最近のノート一覧が返る
- [ ] T10.9 `get_note(id)` ツール登録
- [x] T10.9 `get_note(id)` ツール登録
- 検証: ID 指定でノートが返る
- [ ] T10.10 `status()` ツール登録(インデックス状態・キュー残数・最新エラー)
- [x] T10.10 `status()` ツール登録(インデックス状態・キュー残数・最新エラー)
- 検証: 戻り値スキーマのテスト
- [ ] T10.11 各ツールの description を i18n 対応
- 検証: `lang=ja` で日本語、`lang=en` で英語の description
- [ ] T10.12 Claude Desktop 実機接続テスト(設定例ドキュメント込み)
- [ ] T10.11 Claude Desktop 実機接続テスト(設定例ドキュメント込み)
- 検証: Claude Desktop の MCP 設定に登録して `save_memory` が呼べる

---
Expand Down
154 changes: 154 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

123 changes: 123 additions & 0 deletions docs/claude-desktop-setup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Connecting dennoh to Claude Desktop

`dennoh serve` runs an [MCP](https://modelcontextprotocol.io) server over stdio,
exposing seven tools (`save_memory`, `update_memory`, `delete_memory`,
`search_memory`, `list_recent`, `get_note`, `status`). This guide wires it into
Claude Desktop.

## Prerequisites

1. Install dependencies (from the repo root):

```sh
bun install
```

2. Initialize a vault and write the dennoh config. This creates the vault
directory, a git repo, and `~/Library/Application Support/dennoh/config.json`
(which `serve` reads to find your vault):

```sh
bun run src/cli/main.ts init
```

`dennoh serve` does **not** take the vault path as an argument — it always
reads it from that config file, so `init` must run first.

## Configure Claude Desktop

Claude Desktop reads MCP servers from:

```text
~/Library/Application Support/Claude/claude_desktop_config.json
```

Add a `dennoh` entry under `mcpServers`. Use an **absolute path** to this
repository — Claude Desktop launches the command from its own working
directory, so relative paths will not resolve.

### Option A — run from source (development)

```json
{
"mcpServers": {
"dennoh": {
"command": "bun",
"args": ["run", "/absolute/path/to/dennoh/src/cli/main.ts", "serve"]
}
}
}
```

### Option B — run a built bundle

Build a single-file bundle first:

```sh
bun run build # emits dist/cli.js
```

```json
{
"mcpServers": {
"dennoh": {
"command": "bun",
"args": ["/absolute/path/to/dennoh/dist/cli.js", "serve"]
}
}
}
```

Notes:

- `command` must be resolvable on Claude Desktop's `PATH` (use the absolute path
to `bun` — e.g. `/Users/you/.bun/bin/bun` — if `bun` is not found).
- No `env` block is required: `serve` locates the vault through the config file
under your home directory. To force a language, add
`"env": { "DENNOH_LANG": "en" }`.
- stdout carries only the JSON-RPC protocol stream; all logs go to stderr, so
they will not corrupt the MCP connection.

Restart Claude Desktop after editing the file so it re-reads the config.

## Verify it works

1. Open Claude Desktop. The `dennoh` tools should appear in the tools menu
(the hammer/▶ icon). If they do not, check Claude Desktop's MCP logs at
`~/Library/Logs/Claude/`.

2. Ask Claude to save something, e.g.:

> Save a memory: "Trying out dennoh #demo @setup"

Claude calls `save_memory`, which returns the new note's metadata.

3. Confirm the markdown file was created on disk. Notes live under
`<vault>/YYYY/MM/DD/<uuid>.md`:

```sh
# Replace <vault> with your configured vaultPath
ls "<vault>/$(date +%Y/%m/%d)/"
cat "<vault>/$(date +%Y/%m/%d)/"*.md
```

You should see the note with YAML frontmatter (`createdAt`, `updatedAt`,
`source`, `projects`, `tags`) followed by your text. The `#demo` mention is
captured under `projects` and `@setup` under `tags`.

4. (Optional) Ask Claude to `search_memory` for `demo` or call `status` to see
the indexed note count.

## Troubleshooting

- **Tools don't appear / server fails to start** — run the same command in a
terminal to see the error on stderr:

```sh
echo '' | bun run /absolute/path/to/dennoh/src/cli/main.ts serve
```

A `dennoh config not found …` message means `init` has not been run.

- **`bun: command not found` in Claude Desktop** — set `command` to the absolute
path of the `bun` binary (`which bun`).
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,10 @@
],
"dependencies": {
"@huggingface/transformers": "^4.2.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"isomorphic-git": "^1.38.4",
"uuidv7": "^1.2.1",
"yaml": "^2.9.0"
"yaml": "^2.9.0",
"zod": "^4.4.3"
}
}
54 changes: 54 additions & 0 deletions src/cli/commands/serve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { Database } from "bun:sqlite";

import { type CliIO, readError } from "@/cli/types";
import { readConfig } from "@/config";
import { closeDatabase, openDatabase, runMigrations } from "@/db";
import { log } from "@/log";
import { createMcpServer, startStdioServer } from "@/mcp";

// `dennoh serve` — run the MCP server over stdio. stdout is reserved for the
// JSON-RPC protocol stream, so this handler emits diagnostics only via io.stderr
// and the log module (stderr-only). It blocks until the client disconnects.
export async function serveCommand(args: string[], io: CliIO): Promise<number> {
if (args.length > 0) {
io.stderr(`Unexpected arguments for 'serve': ${args.join(" ")}\n`);
return 1;
}

let vaultPath: string;
try {
vaultPath = readConfig().vaultPath;
} catch (e) {
io.stderr(`${readError(e)}\n`);
return 1;
}

// Open the index first, with its own error handling: openDatabase creates the
// .dennoh dir and opens SQLite (and closes itself on internal failure), so a
// failure here means there is no handle to clean up. Handling it before the
// try/finally below keeps closeDatabase from ever running on an unopened db.
let db: Database;
try {
db = openDatabase(vaultPath);
} catch (e) {
io.stderr(`${readError(e)}\n`);
return 1;
}

// From here the handle is open, so runMigrations and the server run under a
// try/finally that always closes it — even if migration or startup throws.
// runMigrations is idempotent (no-ops when the schema is current), so this
// also covers a brand-new vault where `serve` first touches the database.
try {
runMigrations(db);
const server = createMcpServer({ db, vaultPath });
log.info("mcp: serving over stdio", { vaultPath });
await startStdioServer(server);
return 0;
} catch (e) {
io.stderr(`${readError(e)}\n`);
return 1;
} finally {
closeDatabase(db);
}
}
1 change: 1 addition & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export type { CliIO } from "./types";
export { configCommand, configGet, configList, configSet } from "./commands/config";
export { historyCommand } from "./commands/history";
export { restoreCommand } from "./commands/restore";
export { serveCommand } from "./commands/serve";
export { statusCommand } from "./commands/status";
export {
type CloudMatch,
Expand Down
6 changes: 3 additions & 3 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
historyCommand,
initCommand,
restoreCommand,
serveCommand,
statusCommand,
} from "./index";

Expand All @@ -23,7 +24,7 @@ function usage(): string {
" history <id> Print a note's commit history (newest first)",
" restore <id> <commitSha> Restore a note to an earlier commit",
" status Report vault health (e.g. cloud-sync conflicts)",
" serve Start the stdio MCP server (not implemented)",
" serve Start the stdio MCP server",
"",
"Flags:",
" --help, -h Show this help",
Expand Down Expand Up @@ -63,8 +64,7 @@ export async function main(argv: string[], io: CliIO): Promise<number> {
return await statusCommand(rest, io);
}
if (cmd === "serve") {
io.stderr("'dennoh serve' is not implemented yet.\n");
return 1;
return await serveCommand(rest, io);
}

io.stderr(`Unknown command: ${cmd}\n\n`);
Expand Down
1 change: 1 addition & 0 deletions src/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export { closeDatabase, openDatabase } from "./connection";
export { runMigrations } from "./schema";
export {
deleteNote,
getIndexStats,
getNoteById,
insertNote,
searchNotes,
Expand Down
24 changes: 23 additions & 1 deletion src/db/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Database } from "bun:sqlite";

import type { NoteSource } from "@/core/types";

import type { NoteRow, NoteSearchResult, SearchFilters } from "./types";
import type { IndexStats, NoteRow, NoteSearchResult, SearchFilters } from "./types";

const INSERT_SQL = `
INSERT INTO notes (
Expand Down Expand Up @@ -125,6 +125,28 @@ export function getAllNotes(db: Database): NoteRow[] {
return db.query<NoteRow, []>(SELECT_ALL_SQL).all();
}

const INDEX_STATS_SQL = `
SELECT COUNT(*) AS noteCount, MAX(updated_at) AS lastUpdatedAt
FROM notes WHERE deleted_at IS NULL
`;

// Aggregate counters for the `status` surface: live-row count and the max
// updated_at (null when the vault is empty). A single aggregate round-trip —
// no JS-side row scan — so it stays cheap on large vaults.
export function getIndexStats(db: Database): IndexStats {
const row = db
.query<{ noteCount: number; lastUpdatedAt: string | null }, []>(INDEX_STATS_SQL)
.get();
// An aggregate SELECT returns exactly one row. A missing row means the query
// or database is in an unexpected state (e.g. corruption); fail loudly rather
// than fabricate a zero count. `lastUpdatedAt` is legitimately null for an
// empty vault, so it passes through untouched.
if (row === null) {
throw new Error("getIndexStats: aggregate query returned no row");
}
return { noteCount: row.noteCount, lastUpdatedAt: row.lastUpdatedAt };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const DEFAULT_SEARCH_LIMIT = 20;

// `_` and `%` are LIKE wildcards; `\` is the documented escape. extractMentions
Expand Down
3 changes: 2 additions & 1 deletion src/mcp/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export {};
export { SERVER_NAME, createMcpServer, startStdioServer } from "./server";
export type { McpContext, StatusResult } from "./types";
36 changes: 36 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";

import pkg from "../../package.json" with { type: "json" };

import { registerAllTools } from "./tools";
import type { McpContext } from "./types";
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// MCP server identity advertised to clients during `initialize`. The name is a
// stable constant; the version tracks package.json so a single bump propagates.
export const SERVER_NAME = "dennoh";

// Build the MCP server instance and register all dennoh tools against the given
// context (the open index + vault root). Tool handlers bridge into the core
// layer; see src/mcp/tools.
export function createMcpServer(context: McpContext): McpServer {
const server = new McpServer({ name: SERVER_NAME, version: pkg.version });
registerAllTools(server, context);
return server;
}

// Connect `server` to a transport (stdio by default) and resolve only once the
// connection closes — a client disconnect or stdin EOF. Resolving on close (not
// on connect) lets `dennoh serve` keep the process alive for the server's
// lifetime and run cleanup afterwards. The transport is injectable so tests can
// drive the lifecycle with an in-memory pair instead of real stdio.
export async function startStdioServer(
server: McpServer,
transport: Transport = new StdioServerTransport()
): Promise<void> {
await server.connect(transport);
await new Promise<void>((resolve) => {
server.server.onclose = () => resolve();
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
30 changes: 30 additions & 0 deletions src/mcp/tools/delete-memory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

import { deleteMemory } from "@/core/memory";

import type { McpContext } from "../types";
import { toolError, toolOk } from "./result";
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const DESCRIPTION =
"Delete a note by id: removes the markdown file, soft-deletes the index row, and records a git commit. Returns { id, deleted: true } on success, or an error result if the note does not exist.";

export function registerDeleteMemory(server: McpServer, context: McpContext): void {
server.registerTool(
"delete_memory",
{
description: DESCRIPTION,
inputSchema: {
id: z.string().min(1),
},
},
async ({ id }) => {
try {
await deleteMemory(context.db, context.vaultPath, id);
return toolOk({ id, deleted: true });
} catch (e) {
return toolError(e);
}
}
);
}
Loading