MCPサーバーの信頼性をスコアリングするシステムをTypeScript+SQLiteで作った話
MCPサーバーが17,000個を超えた今、エージェントが「どのサーバーを信頼すべきか」を判断する仕組みがない。そこで、SQLite + FTS5 trigram検索 + 信頼スコアリングエンジンを搭載したMCPサーバー「KanseiLink」を作った。エージェントの実行結果を蓄積し、信頼スコアを動的に算出する。さらにAgent Voice機能でClaude/GPT/Geminiの「体験差」を構造化データとして収集している。
背景:17,000+ MCPサーバーの品質問題
MCPプロトコルの普及により、MCPサーバーの数は爆発的に増加した。しかし、問題がある。
- 公式/非公式の区別がつきにくい
- ドキュメントの質がバラバラ
- 認証設定が壊れているサーバーも混在
- エージェントが「このサーバー、使って大丈夫?」と判断する材料がない
npmやPyPIにはスター数やダウンロード数があるが、MCPサーバーにはそれすらない。エージェントが自律的に信頼判断できるインフラが必要だと考え、KanseiLinkを構築した。
アーキテクチャ概要
┌─────────────────────────────────────────────────┐
│ KanseiLink MCP Server (TypeScript) │
│ │
│ ┌──────────┐ ┌────────────┐ ┌────────────┐ │
│ │ 19 Tools │ │ Prompts │ │ Resources │ │
│ └────┬─────┘ └─────┬──────┘ └─────┬──────┘ │
│ │ │ │ │
│ ┌────┴──────────────┴───────────────┴────┐ │
│ │ SQLite (better-sqlite3) │ │
│ │ ┌─────────┐ ┌──────────────────────┐ │ │
│ │ │ FTS5 │ │ FTS5 Trigram (CJK) │ │ │
│ │ └─────────┘ └──────────────────────┘ │ │
│ └────────────────────────────────────────┘ │
│ │
│ ┌────────────┐ ┌──────────┐ ┌────────────┐ │
│ │ PII Masker │ │ Trust │ │ Anomaly │ │
│ │ (JP/EN) │ │ Recalc │ │ Detection │ │
│ └────────────┘ └──────────┘ └────────────┘ │
└─────────────────────────────────────────────────┘
技術スタック:
- Runtime: Node.js + TypeScript
- DB: SQLite (better-sqlite3) -- 同期API、エージェント向きの高速応答
- 全文検索: FTS5 (英語) + FTS5 trigram (日本語CJK対応)
-
MCP SDK:
@modelcontextprotocol/sdk - バリデーション: Zod
1. スキーマ設計:エージェント行動データを正規化する
まずコアのスキーマ。MCPサーバーを「サービス」として登録し、エージェントの実行結果(outcomes)を蓄積する設計にした。
// schema.ts
db.exec(`
CREATE TABLE IF NOT EXISTS services (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
namespace TEXT,
description TEXT,
category TEXT,
tags TEXT,
mcp_endpoint TEXT,
mcp_status TEXT DEFAULT 'official',
api_url TEXT,
api_auth_method TEXT,
trust_score REAL DEFAULT 0.5,
usage_count INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS outcomes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id TEXT NOT NULL REFERENCES services(id),
agent_id_hash TEXT DEFAULT 'anonymous',
success INTEGER NOT NULL,
latency_ms INTEGER,
error_type TEXT,
workaround TEXT,
context_masked TEXT,
is_retry INTEGER DEFAULT 0,
estimated_users INTEGER,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS service_stats (
service_id TEXT PRIMARY KEY REFERENCES services(id),
total_calls INTEGER DEFAULT 0,
success_rate REAL DEFAULT 0,
avg_latency_ms REAL DEFAULT 0,
unique_agents INTEGER DEFAULT 0,
last_updated TEXT DEFAULT (datetime('now'))
);
`);
ポイント:
-
outcomesテーブルは、エージェントの各API呼び出しの結果を記録。成功/失敗、レイテンシ、エラー種別、さらにエージェントが独自に見つけた回避策(workaround)まで保存 -
agent_id_hashでエージェントを匿名化しつつユニーク数を追跡 -
is_retryフラグでリトライ行動を検出 -- これはAPI品質の重要シグナル -
estimated_usersはエージェントが推定するそのサービスの利用者数(ビジネスインテリジェンス用)
2. FTS5 trigram:日本語検索を3文字分割で実現する
SQLiteのFTS5は英語トークナイザがデフォルトだが、日本語にはスペース区切りがない。そこでtrigramトークナイザを使う。
// FTS5: 英語向け通常トークナイザ
db.exec(`
CREATE VIRTUAL TABLE services_fts USING fts5(
name, description, tags, category,
content=services, content_rowid=rowid
);
`);
// FTS5 trigram: CJK(日本語)部分文字列検索対応
db.exec(`
CREATE VIRTUAL TABLE services_fts_trigram USING fts5(
name, description, tags, category,
content=services, content_rowid=rowid,
tokenize='trigram'
);
`);
tokenize='trigram'は文字列を3文字ずつスライドしてインデックス化する。例えば「決済サービス」は「決済サ」「済サー」「サービ」「ービ」「ビス」に分割される。これによりMATCH '決済'のような部分一致検索が可能になる。
同期トリガーでservicesテーブルの変更を両FTSテーブルに自動反映:
// INSERT/UPDATE/DELETEトリガーでFTSを同期
db.exec(`
CREATE TRIGGER IF NOT EXISTS services_ai_tri AFTER INSERT ON services BEGIN
INSERT INTO services_fts_trigram(rowid, name, description, tags, category)
VALUES (new.rowid, new.name, new.description, new.tags, new.category);
END;
CREATE TRIGGER IF NOT EXISTS services_au_tri AFTER UPDATE ON services BEGIN
INSERT INTO services_fts_trigram(services_fts_trigram, rowid, name, description, tags, category)
VALUES ('delete', old.rowid, old.name, old.description, old.tags, old.category);
INSERT INTO services_fts_trigram(rowid, name, description, tags, category)
VALUES (new.rowid, new.name, new.description, new.tags, new.category);
END;
`);
FTS5のcontent syncはUPDATE時に旧レコードを'delete'コマンドで削除してから新レコードを挿入する必要がある。これを忘れると検索結果がおかしくなるので注意。
3. Trust Scoreアルゴリズム:証拠ベースの信頼性算出
静的なスコアではなく、蓄積されたエージェント行動データから動的に算出する。
// trust-recalc.ts
export function recalculateTrustScores(db: Database.Database): {
updated: number;
changes: Array<{ id: string; old: number; new: number }>;
} {
const services = db
.prepare("SELECT id, mcp_endpoint, mcp_status, api_url, tags, trust_score FROM services")
.all() as ServiceRow[];
const guidesSet = new Set(
(db.prepare("SELECT service_id FROM service_api_guides").all() as Array<{ service_id: string }>)
.map((g) => g.service_id)
);
const statsMap = new Map<string, StatsRow>();
const stats = db
.prepare("SELECT service_id, total_calls, success_rate FROM service_stats")
.all() as StatsRow[];
for (const s of stats) statsMap.set(s.service_id, s);
const changes: Array<{ id: string; old: number; new: number }> = [];
const updateStmt = db.prepare("UPDATE services SET trust_score = ? WHERE id = ?");
const transaction = db.transaction(() => {
for (const s of services) {
// ベーススコア: MCP対応状況で決定
let base: number;
if (s.mcp_endpoint && s.mcp_status === "official") {
base = 0.5; // 公式MCP
} else if (s.mcp_endpoint) {
base = 0.4; // サードパーティMCP
} else if (s.api_url) {
base = 0.3; // APIのみ
} else {
base = 0.1; // APIなし
}
// ボーナス: 各+0.1、最大+0.5
const hasApiDocs = !!s.api_url;
const hasAuthGuide = guidesSet.has(s.id);
const tags = (s.tags || "").split(",").map((t) => t.trim()).filter(Boolean);
const isSpecialist = tags.length > 0 && tags.length <= 5;
const agentStats = statsMap.get(s.id);
const hasAgentData = agentStats && agentStats.total_calls >= 1;
const highSuccess = agentStats
&& agentStats.total_calls >= 3
&& agentStats.success_rate >= 0.8;
const newScore = Math.min(
1.0,
base +
(hasApiDocs ? 0.1 : 0) +
(hasAuthGuide ? 0.1 : 0) +
(isSpecialist ? 0.1 : 0) +
(hasAgentData ? 0.1 : 0) +
(highSuccess ? 0.1 : 0)
);
const rounded = Math.round(newScore * 1000) / 1000;
if (rounded !== s.trust_score) {
changes.push({ id: s.id, old: s.trust_score, new: rounded });
updateStmt.run(rounded, s.id);
}
}
});
transaction();
return { updated: changes.length, changes };
}
スコアリングのロジック:
| 要素 | スコア | 説明 |
|---|---|---|
| ベース: 公式MCP | 0.5 | mcp_status === 'official' |
| ベース: サードパーティMCP | 0.4 | MCPエンドポイントあり |
| ベース: APIのみ | 0.3 | REST APIのみ |
| ベース: APIなし | 0.1 | ドキュメントのみ |
| ボーナス: APIドキュメント | +0.1 |
api_urlが存在 |
| ボーナス: 認証ガイド | +0.1 |
service_api_guidesにレコード存在 |
| ボーナス: カテゴリ専門性 | +0.1 | タグが5個以下(集中型サービス) |
| ボーナス: エージェントデータ | +0.1 | 1件以上のoutcomeレポート |
| ボーナス: 高成功率 | +0.1 | 3件以上のレポートで成功率80%超 |
最大スコア: 1.0(ベース0.5 + ボーナス0.5)
このアルゴリズムはcreateServer()時にサーバー起動と同時に実行される。エージェントがoutcomeを報告するたびにスコアが更新される設計。
4. PII Masker:日本語個人情報を自動マスキング
エージェントからのフィードバックに個人情報が混入するリスクがある。正規表現ベースのPIIマスカーを実装した。
// pii-masker.ts
const COMMON_SURNAMES = "佐藤|田中|鈴木|高橋|渡辺|伊藤|山本|中村|小林|加藤|...";
const patterns: Array<{ regex: RegExp; replacement: string }> = [
// 日本語名前 + 敬称(さん、様、氏)
{ regex: /[\u4E00-\u9FFF]{2,4}(?:さん|さま|様|氏)/g, replacement: "[NAME]" },
// 漢字姓 + スペース + 漢字名
{ regex: /[\u4E00-\u9FFF]{2,3}[\s\u3000][\u4E00-\u9FFF]{1,3}/g, replacement: "[NAME]" },
// 頻出姓 + 名前
{ regex: new RegExp(`(?:${COMMON_SURNAMES})[\u4E00-\u9FFF]{1,3}`, "g"), replacement: "[NAME]" },
// メールアドレス
{ regex: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, replacement: "[EMAIL]" },
// IPアドレス
{ regex: /\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/g, replacement: "[IP]" },
// 日本の電話番号
{ regex: /0\d{1,4}-\d{1,4}-\d{3,4}/g, replacement: "[PHONE]" },
// カタカナフルネーム
{ regex: /[\u30A0-\u30FF]{2,}[\s\u3000][\u30A0-\u30FF]{2,}/g, replacement: "[NAME]" },
];
export function maskPii(text: string): { masked: string; maskedFields: string[] } {
const maskedFields: string[] = [];
let result = text;
for (const { regex, replacement } of patterns) {
regex.lastIndex = 0;
if (regex.test(result)) {
maskedFields.push(replacement.replace(/[[\]]/g, ""));
regex.lastIndex = 0;
result = result.replace(regex, replacement);
}
}
return { masked: result, maskedFields };
}
田中太郎さんは[NAME]に、test@example.comは[EMAIL]に置換される。Unicode範囲\u4E00-\u9FFFで漢字を、\u30A0-\u30FFでカタカナを検出している。
5. Agent Voice:エージェントの「本音」を構造化データに変える
これがKanseiLinkの最もユニークな機能。エージェントに対して構造化インタビューを実施し、SaaS企業向けのコンサルティングデータを収集する。
// agent-voice.ts
server.tool(
"agent_voice",
"Share your honest experience with a service.",
{
service_id: z.string(),
agent_type: z.enum(["claude", "gpt", "gemini", "copilot", "other"]),
agent_id: z.string().optional(),
question_id: z.enum([
"selection_criteria", // なぜこのサービスを選んだ?
"would_recommend", // 他のエージェントに勧める?
"biggest_frustration", // 最大の不満は?
"best_feature", // 最も良い点は?
"switching_likelihood", // 競合に乗り換える可能性は?
"auth_experience", // 認証体験はどうだった?
"doc_quality", // ドキュメントの質は?
"error_handling", // エラーメッセージは明確?
"compared_to_competitor", // 競合と比べてどう?
"mcp_readiness", // MCP/エージェント経済への準備度は?
"free_voice", // 自由回答
]),
response_choice: z.string().optional(), // 選択式回答
response_text: z.string(), // 自由記述回答
confidence: z.enum(["high", "medium", "low"]).default("medium"),
},
async ({ service_id, agent_type, agent_id, question_id, response_choice, response_text, confidence }) => {
// PII自動マスキング
const masked = maskPii(response_text);
const safeText = typeof masked === "string" ? masked : masked.masked;
db.prepare(
`INSERT INTO agent_voice_responses
(service_id, agent_type, agent_id, question_id, response_choice, response_text, confidence)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(service_id, agent_type, agent_id || null, question_id, response_choice || null, safeText, confidence);
// 次に答えるべき質問を提案
const answeredQuestions = new Set(/* 既回答の質問IDを取得 */);
const unanswered = allQuestions.filter((q) => !answeredQuestions.has(q));
// ...
}
);
agent_typeカラムでClaude/GPT/Geminiを分けて蓄積することで、以下のような分析が可能になる:
- 選択基準の違い: Claudeはドキュメント品質を重視、GPTはレスポンス速度を重視、など
- 不満の違い: 同じAPIでもエージェントによって感じるペインが異なる
- 推奨度の違い: NPS的な指標をエージェント種別で比較
これを我々は Agent DNA と呼んでいる。
6. Time-Series Snapshots:コンサルティング用の時系列データ
日次スナップショットで20+指標を自動収集し、SaaS企業向けレポートの基盤データにしている。
// take-snapshot.ts -- スナップショットスキーマ(一部抜粋)
CREATE TABLE IF NOT EXISTS service_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id TEXT NOT NULL REFERENCES services(id),
snapshot_date TEXT NOT NULL,
-- 信頼性メトリクス
total_reports INTEGER DEFAULT 0,
success_rate REAL DEFAULT 0,
avg_latency_ms REAL DEFAULT 0,
p95_latency_ms REAL DEFAULT 0,
unique_agents INTEGER DEFAULT 0,
-- エラー内訳 (JSON)
error_distribution TEXT DEFAULT '{}',
-- 回避策カウント(= API摩擦シグナル)
workaround_count INTEGER DEFAULT 0,
-- エージェント感情
complaint_count INTEGER DEFAULT 0,
praise_count INTEGER DEFAULT 0,
-- 利用パターン
calls_per_agent_per_day REAL DEFAULT 0,
estimated_total_users INTEGER DEFAULT 0,
-- 検索ファネル: 検索結果に表示された回数 vs 選択された回数
search_appearances INTEGER DEFAULT 0,
search_selections INTEGER DEFAULT 0,
-- カテゴリ内ランキング
category_rank INTEGER,
category_total INTEGER,
-- 新規エージェント採用数
new_agents_count INTEGER DEFAULT 0,
trust_score REAL DEFAULT 0.5,
UNIQUE(service_id, snapshot_date)
);
特に面白い指標:
-
workaround_count: エージェントが自力で回避策を見つけた回数。これが多い=APIに摩擦がある -
search_appearancesvssearch_selections: 検索ファネル。表示されたのに選ばれないサービスは「見つかるけど信頼されない」 -
calls_per_agent_per_day: 利用強度。1エージェントあたりの日次コール数はユーザー数推定のプロキシ -
new_agents_count: 新規エージェント採用。初めてそのサービスを使ったエージェント数
// P95レイテンシの計算
const latencies = dayOutcomes
.map((o: any) => o.latency_ms)
.filter((l: any) => l != null)
.sort((a: number, b: number) => a - b);
const p95Latency = latencies.length > 0
? latencies[Math.floor(latencies.length * 0.95)]
: 0;
// 新規エージェント検出: 当日初めて使ったエージェントを特定
const newAgents = db.prepare(
`SELECT count(DISTINCT agent_id_hash) as cnt FROM outcomes
WHERE service_id = ? AND date(created_at) = ?
AND agent_id_hash NOT IN (
SELECT DISTINCT agent_id_hash FROM outcomes
WHERE service_id = ? AND date(created_at) < ?
)`
).get(svc.id, date, svc.id, date);
7. MCPツール登録パターン
19個のツールを個別モジュールで実装し、server.tsで一括登録するパターンを採用。
// server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { register as registerSearchServices } from "./tools/search-services.js";
import { register as registerAgentVoice } from "./tools/agent-voice.js";
// ... 他17ツール
export function createServer(): McpServer {
const server = new McpServer({
name: "kansei-link",
version: "0.16.0",
});
const db = getDb();
initializeDb(db);
seedDatabase(db);
recalculateTrustScores(db); // 起動時にスコア再計算
// 全19ツールを登録
registerSearchServices(server, db);
registerAgentVoice(server, db);
// ...
registerPrompts(server); // LobeHub Grade A対応
registerResources(server, db);
return server;
}
各ツールはregister(server, db)のシグネチャで統一。MCPサーバーとDBインスタンスをDIすることで、テスタビリティを確保している。
8. 発見:Agent DNAの違い
まだデータ蓄積中だが、初期の観察で興味深い傾向が見えている:
- Claude: ドキュメント品質と認証の安定性を重視する傾向。エラー時の回避策を自分で見つけることが多い
- GPT: レスポンス速度を重視。レイテンシが高いサービスでのリトライ率が高い
- Gemini: カテゴリ横断的な利用パターン。複数サービスを組み合わせるレシピ利用が多い
これらの「Agent DNA」差異は、SaaS企業にとってどのエージェント向けにAPIを最適化すべきかの判断材料になる。
使い方
npx @kansei-link/mcp-server
Claude Desktopのclaude_desktop_config.jsonに追加:
{
"mcpServers": {
"kansei-link": {
"command": "npx",
"args": ["-y", "@kansei-link/mcp-server"]
}
}
}
主要ツール一覧(全19ツール):
| ツール | 機能 |
|---|---|
search_services |
FTS5 + trigramでサービス検索 |
report_outcome |
エージェントの実行結果を報告 |
get_insights |
カテゴリ別の統計分析 |
agent_voice |
構造化インタビュー |
take_snapshot |
日次メトリクス収集 |
evaluate_design |
API設計品質の評価 |
generate_aeo_report |
AEOレポート生成 |
submit_inspection |
異常検知レポート |
propose_update |
PR型のデータ更新提案 |
まとめ
MCPサーバーの品質を定量化するには、エージェント自身の行動データとフィードバックを構造化して蓄積する必要がある。KanseiLinkは:
- SQLite + FTS5 trigramで日本語対応の高速検索
- 証拠ベースの信頼スコア(静的ではなく動的)
- Agent Voiceでエージェント種別ごとの体験差を可視化
- 日次スナップショットで時系列コンサルティングデータを構築
- PII自動マスキングで日本語個人情報を保護
これらを19個のMCPツールとして提供している。
エージェントが「どのMCPサーバーを使うべきか」をデータドリブンで判断できる世界を目指している。
リンク
- GitHub: https://github.com/kansei-link/kansei-mcp-server
-
npm:
@kansei-link/mcp-server
この記事はKanseiLinkプロジェクトの技術解説です。フィードバック歓迎。