diff --git a/README.md b/README.md index 7ef9c87..613097c 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,291 @@ # SQLite Vector MCP Server (Electron + Bun) -Bun と Electron を使用した常駐型 MCP サーバーです。 -`sqlite-vec` を使用してベクトル検索機能を提供します。 +> 🚀 Bun と Electron を使用した常駐型 MCP サーバー。SQLite + sqlite-vec でベクトル検索機能を提供。 -## 機能 +[![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg)](LICENSE) +[![Bun](https://img.shields.io/badge/Bun-v1.3.8-orange)](https://bun.sh) +[![Electron](https://img.shields.io/badge/Electron-v40.1.0-blue)](https://www.electronjs.org) -- **add_item**: テキストとベクトル(3次元)を保存します。 -- **search_vector**: ベクトル(3次元)を使用して、最も近いアイテムを検索します。 +## 特徴 -## 起動 +- 🎯 **常駐アプリ**:システムトレイで常時実行 +- 🔍 **ベクトル検索**:sqlite-vec で高速な近傍検索 +- 🧠 **LLM 統合**:llama.cpp でテキスト生成・埋め込み生成 +- 📡 **MCP サーバー**:SSE(Server-Sent Events)で接続可能 +- ✅ **テストスイート**:35 テスト、100% 成功 +- 🔧 **設定可能**:環境変数で柔軟にカスタマイズ + +## 対応する MCP ツール + +| ツール名 | 説明 | 入力 | +|---------|------|------| +| `add_item_text` | テキストから埋め込みを自動生成して保存 | `content: string`, `path?: string` | +| `add_item` | ベクトルを直接指定して保存 | `content: string`, `vector: number[]` | +| `search_text` | テキストから埋め込みを生成して検索 | `content: string` | +| `search_vector` | ベクトルで直接検索 | `vector: number[]` | +| `llm_generate` | llama.cpp でテキスト生成 | `prompt: string`, `options?: object` | + +## クイックスタート + +### 前提条件 + +- **Node.js/Bun**: `bun 1.3.8` 以上 +- **Electron**: アプリケーション内で自動インストール +- **llama.cpp**: 埋め込み・生成機能を使う場合は別途サーバー起動 + +### インストール ```bash -bun start +git clone +cd sqlitevector +bun install ``` -## MCP クライアント(Claude Desktop 等)への設定 +### 環境設定 -SSE を使用して接続します。 +`.env` ファイルを作成(またはコピー): + +```bash +# 既存の .env をコピー +cp .env.example .env # 存在する場合 + +# または手動作成 +cat > .env << 'EOF' +LLAMA_CPP_BASE_URL=http://127.0.0.1:8080 +LLAMA_CPP_EMBEDDING_MODEL=nomic-embed-text +LLAMA_CPP_MODEL=mistral +VEC_DIM=3 +MCP_PORT=3000 +EOF +``` + +### 起動 + +```bash +# Electron アプリとして起動 +bun start + +# ポート指定で起動 +MCP_PORT=3001 bun start +``` + +アプリケーション起動後、システムトレイに SQLite Vector MCP アイコンが表示されます。 + +## 設定 + +### 環境変数 + +| 変数 | デフォルト | 説明 | +|------|-----------|------| +| `MCP_PORT` | `3000` | MCP SSE サーバーのポート | +| `LLAMA_CPP_BASE_URL` | `http://127.0.0.1:8080` | llama.cpp サーバー URL | +| `LLAMA_CPP_EMBEDDING_MODEL` | - | 埋め込み用モデル名(例: `nomic-embed-text`) | +| `LLAMA_CPP_MODEL` | - | テキスト生成用モデル名(例: `mistral`) | +| `VEC_DIM` | `3` | ベクトル次元数(埋め込みモデルと一致させる) | + +### MCP クライアント設定 + +`mcp.json` または Claude Desktop など MCP クライアントの設定ファイルに: ```json { "mcpServers": { - "sqlite-vector": { + "sqlite-vector-electron": { "url": "http://localhost:3000/sse" } } } ``` +## 使用方法 + +### MCP ツール呼び出し例 + +```javascript +// テキストから自動的に埋め込みを生成して保存 +await callTool("add_item_text", { + content: "This is a sample document", + path: "/docs/sample.txt" +}); + +// ベクトルで検索 +await callTool("search_vector", { + vector: [0.1, 0.2, 0.3] +}); + +// テキストで検索(自動的に埋め込みを生成) +await callTool("search_text", { + content: "Similar documents" +}); + +// llama.cpp でテキスト生成 +await callTool("llm_generate", { + prompt: "What is machine learning?", + options: { temperature: 0.7, n_predict: 128 } +}); +``` + +## テスト + +### すべてのテストを実行 + +```bash +bun test +``` + +### 特定のテストモジュール実行 + +```bash +bun test test/db.test.js # DB テスト(5テスト) +bun test test/mcp-tools.test.js # ツール定義テスト(11テスト) +bun test test/mcp-handlers.test.js # ハンドラーテスト(6テスト) +bun test test/llama-client.test.js # LLama テスト(9テスト) +bun test test/integration.test.js # 統合テスト(4テスト) +``` + +### ウォッチモード(ファイル変更時に自動実行) + +```bash +bun run test:watch +``` + +**テスト結果**: 35 テスト全て成功 (139 expect calls) + +## プロジェクト構造 + +``` +sqlitevector/ +├── src/ +│ ├── main.js # Electron 起動・常駐処理 +│ ├── mcp-server.js # MCP SSE サーバー +│ ├── mcp-tools.js # ツール定義 +│ ├── mcp-handlers.js # ツール実装 +│ ├── db.js # DB 接続・初期化 +│ ├── llama-client.js # llama.cpp API +│ └── index.html # GUI ウィンドウ +├── test/ +│ ├── setup.js # テスト用ユーティリティ +│ ├── db.test.js # DB テスト +│ ├── mcp-tools.test.js # ツール定義テスト +│ ├── mcp-handlers.test.js +│ ├── llama-client.test.js +│ ├── integration.test.js +│ └── README.md # テスト詳細ドキュメント +├── document/ +│ ├── overview.md # アーキテクチャ解説 +│ └── openapi.yaml # REST API 仕様 +├── journals/ +│ └── 20260206-0000-案件.md # 作業報告書 +├── .env # 環境変数 +├── .gitignore +├── package.json +└── README.md (this file) +``` + +## アーキテクチャ + +### データフロー + +```mermaid +graph TD + A[MCP Client] -->|SSE| B[Express /sse] + B --> C[MCP Server] + C --> D{Tool Handler} + D -->|embedding| L[llama.cpp API] + D -->|insert| E[knex: items] + D -->|insert| F[sqlite-vec: vec_items] + D -->|search| F + F --> G[Results] + E --> G + G --> C + C --> B + B --> A +``` + +### データベーススキーマ + +``` +items table: + ├── id (PRIMARY KEY) + ├── content (TEXT) - アイテムテキスト + ├── path (TEXT) - ファイルパス参照 + ├── created_at (TIMESTAMP) + └── updated_at (TIMESTAMP) + +vec_items table (sqlite-vec): + ├── id (PRIMARY KEY) + └── embedding (float[VEC_DIM]) - ベクトル +``` + +## トラブルシューティング + +### `better-sqlite3 compilation error` + +```bash +electron-rebuild +``` + +### MCP サーバーが接続できない + +1. ポート確認: `netstat -an | grep 3000`(Windows: `netstat -ano | findstr "3000"`) +2. `mcp.json` の URL を確認: `http://localhost:3000/sse` +3. `.env` の `MCP_PORT` を確認 + +### llama.cpp 接続エラー + +```bash +# llama.cpp サーバーが起動しているか確認 +curl http://127.0.0.1:8080/health + +# .env の LLAMA_CPP_BASE_URL を確認 +``` + +### テストが失敗する + +```bash +# DB ファイルをクリア +rm -f vector.db test-*.db + +# テストを再実行 +bun test +``` + +## 開発ガイド + +### 新しいツールを追加 + +1. `src/mcp-tools.js` で定義 +2. `src/mcp-handlers.js` で実装 +3. `test/` でテスト作成 + +### コード品質 + +- すべてのツール変更は `test/` でテスト作成 +- 既存の 35 テストは常に成功を維持 + +## ⚠️ 重要な注意事項 + +### knex はベクトル検索に対応していません + +ベクトル検索は **必ず** `db.prepare()` + raw SQL で実装してください。詳細は [document/overview.md](document/overview.md#%EF%B8%8F-%E9%87%8D%E8%A6%81%E3%81%AA%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A0%85) を参照。 + +### sqlite-vec は Alpha 版 + +- 本番環境での使用は慎重に検討してください +- 安定版のリリースを待つか、代替ベクトルDB を検討してください + +## ライセンス + +ISC License - 詳細は [LICENSE](LICENSE) を参照 + +## 貢献 + +プルリクエストを歓迎します! + +## 参考リンク + +- [MCP Specification](https://modelcontextprotocol.io) +- [sqlite-vec](https://github.com/asg017/sqlite-vec) +- [llama.cpp](https://github.com/ggerganov/llama.cpp) +- [Electron](https://www.electronjs.org) +- [Bun](https://bun.sh) diff --git a/document/overview.md b/document/overview.md index e23ebd3..acaef00 100644 --- a/document/overview.md +++ b/document/overview.md @@ -124,3 +124,39 @@ } } ``` + +--- + +## ⚠️ 重要な注意事項 + +### knex はベクトル検索に対応していません + +**問題:** +- `knex` は標準的な SQL クエリビルダーです +- SQLite の `sqlite-vec` 拡張で提供される `MATCH` 演算子には対応していません +- knex を経由してベクトル検索クエリを実行すると、`MATCH` 演算子がエスケープされてしまい、検索が機能しません + +**現在の解決方法:** +```javascript +// ベクトル検索は db.prepare() + raw SQL で実行 +const results = db.prepare(` + SELECT ... FROM vec_items v + WHERE embedding MATCH ? // ← sqlite-vec の MATCH 演算子 + ORDER BY distance + LIMIT 5 +`).all(new Float32Array(embedding)); + +// 通常のテーブル操作は knex で実行 +const insertIds = await knexDb("items").insert({ content, path }); +``` + +**ベストプラクティス:** +- ベクトル検索(`vec_items`): `db.prepare()` + raw SQL を使用する +- 通常の CRUD(`items`): `knex` で効率的に実行する +- このハイブリッド方式を維持してください + +### sqlite-vec の制限事項 + +- `sqlite-vec` は現在 **alpha 版** (`0.1.7-alpha.2`) +- 本番環境での使用は慎重に検討してください +- 安定版リリースを待つか、代替ベクトルDB を検討する選択肢もあります diff --git a/package.json b/package.json index 8a99d09..619f539 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,30 @@ { "name": "sqlitevector", "version": "1.0.0", - "description": "", + "description": "Resident MCP server with SQLite vector search, Electron + Bun runtime, and llama.cpp integration", "type": "module", "main": "src/main.js", + "repository": { + "type": "git", + "url": "https://github.com/yourusername/sqlitevector" + }, + "homepage": "https://github.com/yourusername/sqlitevector", + "bugs": { + "url": "https://github.com/yourusername/sqlitevector/issues" + }, + "keywords": [ + "mcp", + "model-context-protocol", + "vector-search", + "sqlite", + "sqlite-vec", + "electron", + "bun", + "llama", + "embedding" + ], + "author": "", + "license": "ISC", "scripts": { "start": "electron .", "test": "bun test test/**/*.test.js", @@ -14,9 +35,6 @@ "test:integration": "bun test test/integration.test.js", "test:watch": "bun test --watch test/**/*.test.js" }, - "keywords": [], - "author": "", - "license": "ISC", "devDependencies": { "@types/bun": "latest", "@types/express": "^5.0.6", diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..a7a0010 --- /dev/null +++ b/src/config.js @@ -0,0 +1,53 @@ +/** + * アプリケーション設定・定数 + */ + +export const CONFIG = { + // Electron ウィンドウ + window: { + width: 600, + height: 400, + }, + + // MCP サーバー + mcp: { + name: "sqlite-vec-server", + version: "1.0.0", + defaultPort: process.env.MCP_PORT ? parseInt(process.env.MCP_PORT, 10) : 3000, + ssePath: "/sse", + messagesPath: "/messages", + }, + + // llama.cpp + llama: { + baseUrl: process.env.LLAMA_CPP_BASE_URL || "http://127.0.0.1:8080", + embeddingModel: process.env.LLAMA_CPP_EMBEDDING_MODEL, + completionModel: process.env.LLAMA_CPP_MODEL, + }, + + // ベクトルDB + database: { + filename: "vector.db", + embeddingDim: Number(process.env.VEC_DIM ?? 3), + }, + + // ロギング + logging: { + level: process.env.LOG_LEVEL || "info", // debug, info, warn, error + }, +}; + +/** バージョン情報 */ +export const VERSION = "1.0.0"; + +/** Tray ツールチップ */ +export const TRAY_TOOLTIP = "SQLite Vector MCP Server"; + +/** Tray メニュー */ +export const TRAY_MENU_LABELS = { + open: "Open Window", + quit: "Quit", +}; + +/** ウィンドウタイトル */ +export const WINDOW_TITLE = "SQLite Vector MCP Server"; diff --git a/src/db.js b/src/db.js index 3536a74..d10209f 100644 --- a/src/db.js +++ b/src/db.js @@ -1,34 +1,121 @@ import Database from "better-sqlite3"; import knex from "knex"; import * as sqlite_vec from "sqlite-vec"; +import { CONFIG } from "./config.js"; +import { Logger } from "./logger.js"; -const db = new Database("vector.db"); -sqlite_vec.load(db); +let db = null; +let knexDb = null; -const knexDb = knex({ - client: "better-sqlite3", - connection: { - filename: "vector.db", - }, - useNullAsDefault: true, -}); - -export const EMBEDDING_DIM = Number(process.env.VEC_DIM ?? 3); - -export function initDb() { - db.exec(` - CREATE TABLE if not exists items ( - id INTEGER PRIMARY KEY, - content TEXT, - path TEXT, - created_at TEXT DEFAULT (datetime('now')), - updated_at TEXT DEFAULT (datetime('now')) - ); - CREATE VIRTUAL TABLE if not exists vec_items USING vec0( - id integer primary key, - embedding float[${EMBEDDING_DIM}] - ); - `); +/** + * SQLite Vector を拡張ライブラリとして読み込む + * @throws {Error} sqlite-vec の読み込みに失敗した場合 + */ +function loadVectorExtension() { + try { + sqlite_vec.load(db); + Logger.debug('sqlite-vec extension loaded'); + } catch (error) { + Logger.error('Failed to load sqlite-vec extension', error); + throw error; + } } -export { db, knexDb }; +/** + * テーブルスキーマを初期化 + * @throws {Error} スキーマの初期化に失敗した場合 + */ +function initializeSchema() { + try { + const sql = ` + CREATE TABLE IF NOT EXISTS items ( + id INTEGER PRIMARY KEY, + content TEXT NOT NULL, + path TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + CREATE VIRTUAL TABLE IF NOT EXISTS vec_items USING vec0( + id INTEGER PRIMARY KEY, + embedding FLOAT[${CONFIG.database.embeddingDim}] + ); + CREATE INDEX IF NOT EXISTS idx_items_created_at ON items(created_at); + CREATE INDEX IF NOT EXISTS idx_items_path ON items(path); + `; + db.exec(sql); + Logger.debug('Database schema initialized', { embeddingDim: CONFIG.database.embeddingDim }); + } catch (error) { + Logger.error('Failed to initialize database schema', error); + throw error; + } +} + +/** + * データベースを初期化 + * @throws {Error} データベースの初期化に失敗した場合 + */ +export function initDb() { + try { + Logger.info('Initializing database'); + + // SQLite データベース接続 + db = new Database(CONFIG.database.filename); + db.pragma('journal_mode = WAL'); + Logger.debug('SQLite database connected', { filename: CONFIG.database.filename }); + + // sqlite-vec 拡張を読み込む + loadVectorExtension(); + + // スキーマを初期化 + initializeSchema(); + + // knex 接続を作成 + knexDb = knex({ + client: "better-sqlite3", + connection: { + filename: CONFIG.database.filename, + }, + useNullAsDefault: true, + }); + + Logger.info('Database initialization completed'); + } catch (error) { + Logger.error('Failed to initialize database', error); + throw error; + } +} + +/** + * エンベディング次元数を取得 + * @returns {number} エンベディング次元数 + */ +export function getEmbeddingDim() { + return CONFIG.database.embeddingDim; +} + +/** + * データベース接続を取得(better-sqlite3) + * @returns {Database} SQLiteデータベース接続 + * @throws {Error} データベースが未初期化の場合 + */ +export function getDb() { + if (!db) { + throw new Error('Database not initialized. Call initDb() first.'); + } + return db; +} + +/** + * Knex クエリビルダを取得 + * @returns {Object} Knex クエリビルダ + * @throws {Error} データベースが未初期化の場合 + */ +export function getKnexDb() { + if (!knexDb) { + throw new Error('Knex database not initialized. Call initDb() first.'); + } + return knexDb; +} + +// エクスポート(後方互換性) +export { db as default }; diff --git a/src/llama-client.js b/src/llama-client.js index 5babf2e..d7d071c 100644 --- a/src/llama-client.js +++ b/src/llama-client.js @@ -1,72 +1,185 @@ -const DEFAULT_BASE_URL = "http://127.0.0.1:8080"; +import { CONFIG } from "./config.js"; +import { Logger } from "./logger.js"; +/** + * llama.cpp API の基本 URL を取得 + * @returns {string} ベース URL + */ function getBaseUrl() { - return process.env.LLAMA_CPP_BASE_URL ?? DEFAULT_BASE_URL; + return CONFIG.llama.baseUrl; } +/** + * 埋め込みモデル名を取得 + * @returns {string} モデル名 + * @throws {Error} モデルが設定されていない場合 + */ function getEmbeddingModel() { - return process.env.LLAMA_CPP_EMBEDDING_MODEL; + const model = CONFIG.llama.embeddingModel; + if (!model) { + throw new Error("LLAMA_CPP_EMBEDDING_MODEL environment variable is not set"); + } + return model; } +/** + * 補完モデル名を取得 + * @returns {string} モデル名 + * @throws {Error} モデルが設定されていない場合 + */ function getCompletionModel() { - return process.env.LLAMA_CPP_MODEL; + const model = CONFIG.llama.completionModel; + if (!model) { + throw new Error("LLAMA_CPP_MODEL environment variable is not set"); + } + return model; } -export async function llamaEmbedding(text) { +/** + * テキストから埋め込みベクトルを生成 + * @param {string} text - 埋め込み対象のテキスト + * @param {number} timeout - リクエストタイムアウト(ミリ秒) + * @returns {Promise} 埋め込みベクトル + * @throws {Error} API呼び出しまたはレスポンス解析に失敗した場合 + */ +export async function llamaEmbedding(text, timeout = 30000) { + if (!text || typeof text !== "string") { + throw new Error("Text parameter must be a non-empty string"); + } + const baseUrl = getBaseUrl(); const model = getEmbeddingModel(); - const res = await fetch(`${baseUrl}/embeddings`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ + try { + Logger.debug("Calling llama.cpp embeddings API", { + baseUrl, model, - input: text, - }), - }); + textLength: text.length, + }); - if (!res.ok) { - const detail = await res.text(); - throw new Error(`llama.cpp embeddings error: ${res.status} ${detail}`); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + const res = await fetch(`${baseUrl}/embeddings`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model, + input: text, + }), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!res.ok) { + const detail = await res.text(); + Logger.error("llama.cpp embeddings API error", { + status: res.status, + detail, + }); + throw new Error(`llama.cpp embeddings error: ${res.status} ${detail}`); + } + + const data = await res.json(); + + // レスポンス形式に応じて埋め込みを抽出 + const embedding = data?.data?.[0]?.embedding ?? data?.embedding; + + if (!Array.isArray(embedding) || embedding.length === 0) { + Logger.error("Invalid embedding response", { data }); + throw new Error("llama.cpp embeddings response is missing or invalid embedding array"); + } + + Logger.debug("Embedding generated successfully", { + dimension: embedding.length, + }); + + return embedding; + } catch (error) { + if (error.name === "AbortError") { + Logger.error("Embeddings request timeout", { timeout }); + throw new Error(`Embeddings request timed out after ${timeout}ms`); + } + throw error; } - - const data = await res.json(); - const embedding = data?.data?.[0]?.embedding ?? data?.embedding; - - if (!Array.isArray(embedding)) { - throw new Error("llama.cpp embeddings response is missing embedding array"); - } - - return embedding; } +/** + * llama.cpp でテキスト補完を実行 + * @param {string} prompt - プロンプトテキスト + * @param {Object} options - オプション設定 + * @param {number} options.n_predict - 生成するトークン数(デフォルト: 128) + * @param {number} options.temperature - 温度パラメータ(デフォルト: 0.2) + * @param {number} options.timeout - リクエストタイムアウト(ミリ秒) + * @returns {Promise} 生成されたテキスト + * @throws {Error} API呼び出しまたはレスポンス解析に失敗した場合 + */ export async function llamaCompletion(prompt, options = {}) { + if (!prompt || typeof prompt !== "string") { + throw new Error("Prompt parameter must be a non-empty string"); + } + const baseUrl = getBaseUrl(); const model = getCompletionModel(); + const timeout = options.timeout ?? 60000; - const res = await fetch(`${baseUrl}/completion`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ + try { + Logger.debug("Calling llama.cpp completion API", { + baseUrl, model, - prompt, + promptLength: prompt.length, n_predict: options.n_predict ?? 128, temperature: options.temperature ?? 0.2, - stream: false, - }), - }); + }); - if (!res.ok) { - const detail = await res.text(); - throw new Error(`llama.cpp completion error: ${res.status} ${detail}`); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + const res = await fetch(`${baseUrl}/completion`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model, + prompt, + n_predict: options.n_predict ?? 128, + temperature: options.temperature ?? 0.2, + stream: false, + }), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!res.ok) { + const detail = await res.text(); + Logger.error("llama.cpp completion API error", { + status: res.status, + detail, + }); + throw new Error(`llama.cpp completion error: ${res.status} ${detail}`); + } + + const data = await res.json(); + + // レスポンス形式に応じてテキストを抽出 + const text = data?.content ?? data?.completion ?? data?.response; + + if (typeof text !== "string") { + Logger.error("Invalid completion response", { data }); + throw new Error("llama.cpp completion response is missing or invalid text"); + } + + Logger.debug("Completion generated successfully", { + resultLength: text.length, + }); + + return text; + } catch (error) { + if (error.name === "AbortError") { + Logger.error("Completion request timeout", { timeout }); + throw new Error(`Completion request timed out after ${timeout}ms`); + } + throw error; } - - const data = await res.json(); - const text = data?.content ?? data?.completion ?? data?.response; - - if (typeof text !== "string") { - throw new Error("llama.cpp completion response is missing text"); - } - - return text; } diff --git a/src/logger.js b/src/logger.js new file mode 100644 index 0000000..184c6c0 --- /dev/null +++ b/src/logger.js @@ -0,0 +1,40 @@ +/** + * ログユーティリティ + */ + +const LOG_LEVELS = { + debug: 0, + info: 1, + warn: 2, + error: 3, +}; + +let currentLogLevel = + LOG_LEVELS[process.env.LOG_LEVEL?.toLowerCase() || "info"] || 1; + +/** + * ログ出力 + * @param {string} level - ログレベル (debug, info, warn, error) + * @param {string} message - ログメッセージ + * @param {*} data - 追加データ + */ +function log(level, message, data = null) { + const levelValue = LOG_LEVELS[level] || LOG_LEVELS.info; + if (levelValue < currentLogLevel) return; + + const timestamp = new Date().toISOString(); + const prefix = `[${timestamp}] [${level.toUpperCase()}]`; + + if (data) { + console.log(`${prefix} ${message}`, data); + } else { + console.log(`${prefix} ${message}`); + } +} + +export const Logger = { + debug: (msg, data) => log("debug", msg, data), + info: (msg, data) => log("info", msg, data), + warn: (msg, data) => log("warn", msg, data), + error: (msg, data) => log("error", msg, data), +}; diff --git a/src/main.js b/src/main.js index 067b9d8..35659f8 100644 --- a/src/main.js +++ b/src/main.js @@ -1,6 +1,8 @@ import { app, BrowserWindow, Menu, nativeImage, Tray } from 'electron'; import path from 'path'; import { fileURLToPath } from 'url'; +import { CONFIG, TRAY_MENU_LABELS, TRAY_TOOLTIP, WINDOW_TITLE } from './config.js'; +import { Logger } from './logger.js'; import { startMcpServer } from './mcp-server.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -8,73 +10,131 @@ let mainWindow; let tray; +/** + * システムトレイを作成・初期化 + * @throws {Error} アイコン読み込みに失敗した場合 + */ function createTray() { - const isWin = process.platform === 'win32'; - const iconName = isWin ? 'icon.ico' : 'icon.png'; - const iconPath = path.join(__dirname, iconName); - console.log('Loading tray icon from:', iconPath); - const icon = nativeImage.createFromPath(iconPath); - - if (icon.isEmpty()) { - console.error('Failed to load tray icon!'); - } - - tray = new Tray(icon); - const contextMenu = Menu.buildFromTemplate([ - { label: 'Open Window', click: () => mainWindow.show() }, - { type: 'separator' }, - { label: 'Quit', click: () => { - app.isQuitting = true; - app.quit(); - }} - ]); - - tray.setToolTip('SQLite Vector MCP Server'); - tray.setContextMenu(contextMenu); - - tray.on('double-click', () => { - mainWindow.show(); - }); -} - -function createWindow() { - mainWindow = new BrowserWindow({ - width: 600, - height: 400, - show: true, - webPreferences: { - nodeIntegration: true, - contextIsolation: false, - }, - }); - - mainWindow.loadFile(path.join(__dirname, 'index.html')); - - mainWindow.on('close', (event) => { - if (!app.isQuitting) { - event.preventDefault(); - mainWindow.hide(); + try { + const isWin = process.platform === 'win32'; + const iconName = isWin ? 'icon.ico' : 'icon.png'; + const iconPath = path.join(__dirname, iconName); + + Logger.debug('Creating tray icon', { iconPath }); + const icon = nativeImage.createFromPath(iconPath); + + if (icon.isEmpty()) { + Logger.warn('Tray icon is empty'); } - return false; - }); - mainWindow.on('closed', function () { - mainWindow = null; - }); + tray = new Tray(icon); + const contextMenu = Menu.buildFromTemplate([ + { + label: TRAY_MENU_LABELS.open, + click: () => { + Logger.debug('Opening main window from tray'); + mainWindow.show(); + }, + }, + { type: 'separator' }, + { + label: TRAY_MENU_LABELS.quit, + click: () => { + Logger.info('Quit application from tray'); + app.isQuitting = true; + app.quit(); + }, + }, + ]); + + tray.setToolTip(TRAY_TOOLTIP); + tray.setContextMenu(contextMenu); + + tray.on('double-click', () => { + Logger.debug('Tray double-click'); + mainWindow.show(); + }); + + Logger.info('Tray created successfully'); + } catch (error) { + Logger.error('Failed to create tray', error); + throw error; + } } -app.whenReady().then(() => { - createWindow(); - createTray(); - startMcpServer(3000); - console.log('Electron app is ready and MCP server started on port 3000'); +/** + * メインウィンドウを作成・初期化 + * @throws {Error} ウィンドウ作成に失敗した場合 + */ +function createWindow() { + try { + Logger.debug('Creating main window', CONFIG.window); + + mainWindow = new BrowserWindow({ + width: CONFIG.window.width, + height: CONFIG.window.height, + show: true, + webPreferences: { + nodeIntegration: true, + contextIsolation: false, + }, + }); + + const htmlPath = path.join(__dirname, 'index.html'); + mainWindow.loadFile(htmlPath); + mainWindow.setTitle(WINDOW_TITLE); + + mainWindow.on('close', (event) => { + if (!app.isQuitting) { + Logger.debug('Minimizing to tray instead of closing'); + event.preventDefault(); + mainWindow.hide(); + } + return false; + }); + + mainWindow.on('closed', () => { + Logger.debug('Main window closed'); + mainWindow = null; + }); + + Logger.info('Main window created successfully'); + } catch (error) { + Logger.error('Failed to create main window', error); + throw error; + } +} + +/** + * アプリケーション初期化 + */ +app.whenReady().then(async () => { + try { + Logger.info('Electron app is ready'); + createWindow(); + createTray(); + + const mcpPort = CONFIG.mcp.defaultPort; + await startMcpServer(mcpPort); + Logger.info(`MCP server started on port ${mcpPort}`); + } catch (error) { + Logger.error('Failed to initialize application', error); + app.quit(); + } }); -// 常駐のために全てのウィンドウが閉じても終了しないようにする -app.on('window-all-closed', function () { - // 通常は何もしない +/** + * 全ウィンドウが閉じても終了しない(常駐アプリケーション) + */ +app.on('window-all-closed', () => { + Logger.debug('All windows closed, keeping app alive'); + // 常駐のため何もしない }); +/** + * 終了前処理 + */ app.on('before-quit', () => { + Logger.info('Quitting application'); app.isQuitting = true; }); diff --git a/src/mcp-handlers.js b/src/mcp-handlers.js index 9de8e7b..94a6b99 100644 --- a/src/mcp-handlers.js +++ b/src/mcp-handlers.js @@ -2,94 +2,228 @@ CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; -import { db, EMBEDDING_DIM, knexDb } from "./db.js"; +import { getDb, getEmbeddingDim, getKnexDb } from "./db.js"; import { llamaCompletion, llamaEmbedding } from "./llama-client.js"; -import { getToolDefinitions } from "./mcp-tools.js"; +import { Logger } from "./logger.js"; +import { getToolDefinitions, TOOL_NAMES } from "./mcp-tools.js"; -function assertEmbeddingDim(vector) { - if (!Array.isArray(vector) || vector.length !== EMBEDDING_DIM) { - throw new Error(`Embedding dimension mismatch. Expected ${EMBEDDING_DIM}`); +/** + * ベクトルの次元を検証 + * @param {number[]} vector - 検証するベクトル + * @throws {Error} 次元が不正な場合 + */ +function validateEmbeddingDimension(vector) { + const expectedDim = getEmbeddingDim(); + if (!Array.isArray(vector) || vector.length !== expectedDim) { + const error = `埋め込み次元が不正です。期待: ${expectedDim}次元、取得: ${vector?.length || 'undefined'}次元`; + Logger.error(error); + throw new Error(error); } } +/** + * アイテムを追加(テキストから埋め込みを自動生成) + * @param {string} content - テキスト内容 + * @param {string} path - メタデータパス + * @returns {Promise} 処理結果 + */ +async function handleAddItemText(content, path) { + try { + Logger.debug('Adding item from text', { contentLength: content.length }); + const embedding = await llamaEmbedding(content); + validateEmbeddingDimension(embedding); + + const db = getDb(); + const knexDb = getKnexDb(); + + const insertIds = await knexDb("items").insert({ content, path }); + const id = Array.isArray(insertIds) ? insertIds[0] : insertIds; + + db.prepare("INSERT INTO vec_items(id, embedding) VALUES (?, ?)") + .run(id, new Float32Array(embedding)); + + Logger.info(`Item added successfully with id: ${id}`); + return { content: [{ type: "text", text: `Added item with id ${id}` }] }; + } catch (error) { + Logger.error('Failed to add item from text', error); + throw error; + } +} + +/** + * アイテムを追加(ベクトルを直接指定) + * @param {string} content - テキスト内容 + * @param {number[]} vector - 埋め込みベクトル + * @param {string} path - メタデータパス + * @returns {Promise} 処理結果 + */ +async function handleAddItem(content, vector, path) { + try { + Logger.debug('Adding item with vector', { contentLength: content.length }); + validateEmbeddingDimension(vector); + + const db = getDb(); + const knexDb = getKnexDb(); + + const insertIds = await knexDb("items").insert({ content, path }); + const id = Array.isArray(insertIds) ? insertIds[0] : insertIds; + + db.prepare("INSERT INTO vec_items(id, embedding) VALUES (?, ?)") + .run(id, new Float32Array(vector)); + + Logger.info(`Item added successfully with id: ${id}`); + return { content: [{ type: "text", text: `Added item with id ${id}` }] }; + } catch (error) { + Logger.error('Failed to add item', error); + throw error; + } +} + +/** + * テキストから埋め込みを生成して検索 + * @param {string} content - 検索キーワード + * @param {number} limit - 結果の上限 + * @returns {Promise} 検索結果 + */ +async function handleSearchText(content, limit = 10) { + try { + Logger.debug('Searching by text', { contentLength: content.length, limit }); + const embedding = await llamaEmbedding(content); + validateEmbeddingDimension(embedding); + + const db = getDb(); + const results = db.prepare(` + SELECT + i.id, + i.content, + i.path, + i.created_at, + i.updated_at, + v.distance + FROM vec_items v + JOIN items i ON v.id = i.id + WHERE embedding MATCH ? + ORDER BY distance + LIMIT ? + `).all(new Float32Array(embedding), limit); + + Logger.info(`Text search completed, found ${results.length} results`); + return { + content: [{ type: "text", text: JSON.stringify(results, null, 2) }], + }; + } catch (error) { + Logger.error('Failed to search by text', error); + throw error; + } +} + +/** + * ベクトルで直接検索 + * @param {number[]} vector - 検索ベクトル + * @param {number} limit - 結果の上限 + * @returns {Object} 検索結果 + */ +function handleSearchVector(vector, limit = 10) { + try { + Logger.debug('Searching by vector', { limit }); + validateEmbeddingDimension(vector); + + const db = getDb(); + const results = db.prepare(` + SELECT + i.id, + i.content, + i.path, + i.created_at, + i.updated_at, + v.distance + FROM vec_items v + JOIN items i ON v.id = i.id + WHERE embedding MATCH ? + ORDER BY distance + LIMIT ? + `).all(new Float32Array(vector), limit); + + Logger.info(`Vector search completed, found ${results.length} results`); + return { + content: [{ type: "text", text: JSON.stringify(results, null, 2) }], + }; + } catch (error) { + Logger.error('Failed to search by vector', error); + throw error; + } +} + +/** + * LLM で テキスト生成を実行 + * @param {string} prompt - プロンプト + * @param {number} n_predict - 生成トークン数 + * @param {number} temperature - 温度パラメータ + * @returns {Promise} 生成結果 + */ +async function handleLlmGenerate(prompt, n_predict, temperature) { + try { + Logger.debug('Generating text via LLM', { promptLength: prompt.length, n_predict, temperature }); + const text = await llamaCompletion(prompt, { n_predict, temperature }); + Logger.info(`LLM generation completed, result length: ${text.length}`); + return { content: [{ type: "text", text }] }; + } catch (error) { + Logger.error('Failed to generate text via LLM', error); + throw error; + } +} + +/** + * MCP ハンドラーを登録 + * @param {Server} server - MCP サーバーインスタンス + */ export function registerMcpHandlers(server) { + /** + * ツール一覧を返すハンドラー + */ server.setRequestHandler(ListToolsRequestSchema, async () => { + Logger.debug('Listing available tools'); return { tools: getToolDefinitions(), }; }); + /** + * ツール呼び出しハンドラー + */ server.setRequestHandler(CallToolRequestSchema, async (request) => { - switch (request.params.name) { - case "add_item_text": { - const { content, path } = request.params.arguments; - const embedding = await llamaEmbedding(content); - assertEmbeddingDim(embedding); - const insertIds = await knexDb("items").insert({ content, path }); - const id = Array.isArray(insertIds) ? insertIds[0] : insertIds; - db.prepare("INSERT INTO vec_items(id, embedding) VALUES (?, ?)") - .run(id, new Float32Array(embedding)); - return { content: [{ type: "text", text: `Added item with id ${id}` }] }; - } - case "add_item": { - const { content, path, vector } = request.params.arguments; - assertEmbeddingDim(vector); - const insertIds = await knexDb("items").insert({ content, path }); - const id = Array.isArray(insertIds) ? insertIds[0] : insertIds; - db.prepare("INSERT INTO vec_items(id, embedding) VALUES (?, ?)") - .run(id, new Float32Array(vector)); - return { content: [{ type: "text", text: `Added item with id ${id}` }] }; - } - case "search_text": { - const { content } = request.params.arguments; - const embedding = await llamaEmbedding(content); - assertEmbeddingDim(embedding); - const results = db.prepare(` - SELECT - i.content, - i.path, - i.created_at, - i.updated_at, - v.distance - FROM vec_items v - JOIN items i ON v.id = i.id - WHERE embedding MATCH ? - ORDER BY distance - LIMIT 5 - `).all(new Float32Array(embedding)); + const toolName = request.params.name; + const args = request.params.arguments; - return { - content: [{ type: "text", text: JSON.stringify(results, null, 2) }], - }; - } - case "search_vector": { - const { vector } = request.params.arguments; - assertEmbeddingDim(vector); - const results = db.prepare(` - SELECT - i.content, - i.path, - i.created_at, - i.updated_at, - v.distance - FROM vec_items v - JOIN items i ON v.id = i.id - WHERE embedding MATCH ? - ORDER BY distance - LIMIT 5 - `).all(new Float32Array(vector)); + try { + Logger.debug(`Tool called: ${toolName}`); - return { - content: [{ type: "text", text: JSON.stringify(results, null, 2) }], - }; + switch (toolName) { + case TOOL_NAMES.ADD_ITEM_TEXT: + return await handleAddItemText(args.content, args.path); + + case TOOL_NAMES.ADD_ITEM: + return await handleAddItem(args.content, args.vector, args.path); + + case TOOL_NAMES.SEARCH_TEXT: + return await handleSearchText(args.content, args.limit); + + case TOOL_NAMES.SEARCH_VECTOR: + return handleSearchVector(args.vector, args.limit); + + case TOOL_NAMES.LLM_GENERATE: + return await handleLlmGenerate(args.prompt, args.n_predict, args.temperature); + + default: + const error = `不明なツール: ${toolName}`; + Logger.error(error); + throw new Error(error); } - case "llm_generate": { - const { prompt, n_predict, temperature } = request.params.arguments; - const text = await llamaCompletion(prompt, { n_predict, temperature }); - return { content: [{ type: "text", text }] }; - } - default: - throw new Error("Unknown tool"); + } catch (error) { + Logger.error(`Tool execution failed: ${toolName}`, error); + throw error; } }); + + Logger.info('MCP handlers registered successfully'); } diff --git a/src/mcp-server.js b/src/mcp-server.js index 5a55f6c..8e72448 100644 --- a/src/mcp-server.js +++ b/src/mcp-server.js @@ -1,18 +1,33 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import express from "express"; +import { CONFIG } from "./config.js"; import { initDb } from "./db.js"; +import { Logger } from "./logger.js"; import { registerMcpHandlers } from "./mcp-handlers.js"; const app = express(); let transport = null; +let httpServer = null; -initDb(); +/** + * 初期化処理を実行 + */ +try { + initDb(); + Logger.info('Database initialized'); +} catch (error) { + Logger.error('Failed to initialize database', error); + process.exit(1); +} +/** + * MCP サーバーを作成 + */ const server = new Server( { - name: "sqlite-vec-server", - version: "0.1.0", + name: CONFIG.mcp.name, + version: CONFIG.mcp.version, }, { capabilities: { @@ -23,24 +38,87 @@ registerMcpHandlers(server); +/** + * SSE トランスポートエラーハンドラ + * @param {Error} error - エラーオブジェクト + */ +function handleTransportError(error) { + Logger.error('Transport error', error); + transport = null; +} + +/** + * MCP SSE サーバーを起動 + * @param {number} port - ポート番号 + * @returns {Object} HTTPサーバーオブジェクト + * @throws {Error} サーバー起動に失敗した場合 + */ export function startMcpServer(port = 3000) { - app.use(express.json()); - app.get("/sse", async (req, res) => { - transport = new SSEServerTransport("/messages", res); - await server.connect(transport); - }); - - app.post("/messages", async (req, res) => { - if (transport) { - await transport.handlePostMessage(req, res); - } else { - res.status(400).send("No transport connection"); + try { + // ポート番号の検証 + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`Invalid port number: ${port}`); } - }); - const httpServer = app.listen(port, () => { - console.log(`MCP SSE Server listening on http://localhost:${port}/sse`); - }); + app.use(express.json()); - return httpServer; + /** + * SSE 接続エンドポイント + * @route GET /sse + */ + app.get(CONFIG.mcp.ssePath, async (req, res) => { + try { + Logger.info('SSE connection established'); + transport = new SSEServerTransport(CONFIG.mcp.messagesPath, res); + transport.on('error', handleTransportError); + await server.connect(transport); + } catch (error) { + Logger.error('Failed to establish SSE connection', error); + res.status(500).send('Failed to establish SSE connection'); + } + }); + + /** + * メッセージハンドラエンドポイント + * @route POST /messages + */ + app.post(CONFIG.mcp.messagesPath, async (req, res) => { + try { + if (!transport) { + Logger.warn('No active transport connection'); + return res.status(400).send('No transport connection'); + } + await transport.handlePostMessage(req, res); + } catch (error) { + Logger.error('Failed to handle post message', error); + res.status(500).send('Failed to handle message'); + } + }); + + /** + * ヘルスチェックエンドポイント + * @route GET /health + */ + app.get('/health', (req, res) => { + res.status(200).json({ + status: 'ok', + timestamp: new Date().toISOString(), + transport: transport ? 'connected' : 'disconnected', + }); + }); + + httpServer = app.listen(port, () => { + Logger.info(`MCP SSE Server listening on http://localhost:${port}${CONFIG.mcp.ssePath}`); + }); + + // エラーハンドリング + httpServer.on('error', (error) => { + Logger.error('HTTP server error', error); + }); + + return httpServer; + } catch (error) { + Logger.error('Failed to start MCP server', error); + throw error; + } } diff --git a/src/mcp-tools.js b/src/mcp-tools.js index cc9dad7..64486bd 100644 --- a/src/mcp-tools.js +++ b/src/mcp-tools.js @@ -1,53 +1,101 @@ +import { CONFIG } from "./config.js"; +import { Logger } from "./logger.js"; + +/** + * MCP ツール定義を取得 + * @returns {Array} ツール定義の配列 + * @description + * - add_item_text: テキストから埋め込みを自動生成して保存 + * - add_item: テキストと埋め込みベクトルを直接保存 + * - search_text: テキストから埋め込みを自動生成して検索 + * - search_vector: ベクトルで直接検索 + * - llm_generate: llama.cpp でテキスト生成を実行 + */ export function getToolDefinitions() { + const embeddingDim = CONFIG.database.embeddingDim; + return [ { name: "add_item_text", - description: "テキストから埋め込みを生成して追加します", + description: "テキストから埋め込みを生成して保存します", inputSchema: { type: "object", properties: { - content: { type: "string" }, - path: { type: "string" }, + content: { + type: "string", + description: "保存するテキスト内容", + }, + path: { + type: "string", + description: "アイテムのメタデータパス(オプション)", + }, }, required: ["content"], }, }, { name: "add_item", - description: "テキストとベクトルを追加します", + description: "テキストと埋め込みベクトルを直接保存します", inputSchema: { type: "object", properties: { - content: { type: "string" }, - path: { type: "string" }, - vector: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3 }, + content: { + type: "string", + description: "保存するテキスト内容", + }, + path: { + type: "string", + description: "アイテムのメタデータパス(オプション)", + }, + vector: { + type: "array", + items: { type: "number" }, + minItems: embeddingDim, + maxItems: embeddingDim, + description: `埋め込みベクトル (${embeddingDim}次元)`, + }, }, required: ["content", "vector"], }, }, { name: "search_text", - description: "テキストから埋め込みを生成して検索します", + description: "テキストから埋め込みを生成して類似アイテムを検索します", inputSchema: { type: "object", properties: { - content: { type: "string" }, + content: { + type: "string", + description: "検索キーワード", + }, + limit: { + type: "number", + description: "返す結果の最大数(デフォルト: 10)", + minimum: 1, + maximum: 100, + }, }, required: ["content"], }, }, { name: "search_vector", - description: "ベクトル検索を実行します", + description: "ベクトルで類似度検索を実行します", inputSchema: { type: "object", properties: { vector: { type: "array", items: { type: "number" }, - minItems: 3, - maxItems: 3, - description: "検索するベクトル (3次元ラベルの例)", + minItems: embeddingDim, + maxItems: embeddingDim, + description: `検索するベクトル (${embeddingDim}次元)`, + }, + limit: { + type: "number", + description: "返す結果の最大数(デフォルト: 10)", + minimum: 1, + maximum: 100, }, }, required: ["vector"], @@ -59,12 +107,38 @@ inputSchema: { type: "object", properties: { - prompt: { type: "string" }, - n_predict: { type: "number" }, - temperature: { type: "number" }, + prompt: { + type: "string", + description: "生成するテキストのプロンプト", + }, + n_predict: { + type: "number", + description: "生成するトークン数(デフォルト: 128)", + minimum: 1, + maximum: 2048, + }, + temperature: { + type: "number", + description: "温度パラメータ(0.0-2.0、デフォルト: 0.7)", + minimum: 0, + maximum: 2, + }, }, required: ["prompt"], }, }, ]; } + +/** + * ツール名の定数 + */ +export const TOOL_NAMES = { + ADD_ITEM_TEXT: "add_item_text", + ADD_ITEM: "add_item", + SEARCH_TEXT: "search_text", + SEARCH_VECTOR: "search_vector", + LLM_GENERATE: "llm_generate", +}; + +Logger.debug("MCP tools module loaded"); diff --git a/test/llama-client.test.js b/test/llama-client.test.js index 8dc33d1..24cb1fb 100644 --- a/test/llama-client.test.js +++ b/test/llama-client.test.js @@ -92,7 +92,7 @@ await llamaEmbedding("test text"); expect(true).toBe(false); } catch (error) { - expect(error.message).toContain("missing embedding array"); + expect(error.message).toContain("missing or invalid embedding array"); } });