diff --git a/apps/mcp-server/tool-dispatcher.ts b/apps/mcp-server/tool-dispatcher.ts index 75b9b2d..febd92e 100644 --- a/apps/mcp-server/tool-dispatcher.ts +++ b/apps/mcp-server/tool-dispatcher.ts @@ -40,6 +40,9 @@ export function createDispatcher( content_generate: (args) => tools.contentGenerate(args), content_publish: (args) => tools.contentPublish(args), + // Retrieval pack + mindbase_draft: (args) => tools.mindbaseDraft(args), + // Memory memory_write: (args) => memoryTools.memoryWrite(args), memory_read: (args) => memoryTools.memoryRead(args), diff --git a/apps/mcp-server/tool-registry.ts b/apps/mcp-server/tool-registry.ts index 396184d..4b55f7d 100644 --- a/apps/mcp-server/tool-registry.ts +++ b/apps/mcp-server/tool-registry.ts @@ -421,6 +421,55 @@ const CONTENT_TOOLS: Tool[] = [ }, ]; +// Retrieval tools (context packs — no LLM calls) +const RETRIEVAL_TOOLS: Tool[] = [ + { + name: 'mindbase_draft', + description: + 'Assemble a structured context pack from past conversations for writing an article/post — returns sources, chronological excerpts, a platform frontmatter skeleton, and writing notes; the caller writes the content. Provide exactly one of "topic" (semantic search) or "conversationIds" (explicit selection).', + inputSchema: { + type: 'object', + properties: { + topic: { + type: 'string', + description: 'Semantic search query for relevant past conversations (exactly one of topic/conversationIds required)', + }, + conversationIds: { + type: 'array', + items: { type: 'string' }, + description: 'Explicit conversation IDs to include (exactly one of topic/conversationIds required)', + }, + project: { + type: 'string', + description: 'Filter search results by project (only valid with topic)', + }, + source: { + type: 'string', + enum: ['claude-code', 'claude-desktop', 'chatgpt', 'cursor', 'windsurf', 'gemini'], + description: 'Filter search results by source platform (only valid with topic)', + }, + limit: { + type: 'number', + description: 'Maximum number of conversations to include (default: 8, max: 20)', + }, + platform: { + type: 'string', + enum: ['zenn', 'qiita', 'note', 'blog'], + description: 'Target platform for the frontmatter skeleton and writing notes (default: "blog")', + }, + maxExcerptChars: { + type: 'number', + description: 'Maximum characters per conversation excerpt (default: 1200)', + }, + maxTotalChars: { + type: 'number', + description: 'Total character budget across all excerpts (default: 24000)', + }, + }, + }, + }, +]; + // Memory tools const MEMORY_TOOLS: Tool[] = [ { @@ -555,5 +604,6 @@ export const TOOLS: Tool[] = [ ...SESSION_TOOLS, ...CROSS_SOURCE_TOOLS, ...CONTENT_TOOLS, + ...RETRIEVAL_TOOLS, ...MEMORY_TOOLS, ]; diff --git a/apps/mcp-server/tools/conversation.ts b/apps/mcp-server/tools/conversation.ts index 0bc57e5..b9c21e9 100644 --- a/apps/mcp-server/tools/conversation.ts +++ b/apps/mcp-server/tools/conversation.ts @@ -5,22 +5,26 @@ * - SessionTools: session management * - SearchTools: semantic and hybrid search * - ContentTools: article generation and publishing + * - RetrievalTools: retrieval-pack assembly (no LLM calls) */ import type { StorageBackend, ConversationItem, QueryFilters, TimelineOptions } from '../storage/interface.js'; import { SessionTools } from './session-tools.js'; import { SearchTools } from './search-tools.js'; import { ContentTools } from './content-tools.js'; +import { RetrievalTools } from './retrieval-tools.js'; export class ConversationTools { private sessionTools: SessionTools; private searchTools: SearchTools; private contentTools: ContentTools; + private retrievalTools: RetrievalTools; constructor(private storage: StorageBackend) { this.sessionTools = new SessionTools(storage); this.searchTools = new SearchTools(storage, this.formatItem); this.contentTools = new ContentTools(storage); + this.retrievalTools = new RetrievalTools(storage); } setCurrentSession(sessionId: string | undefined) { @@ -116,6 +120,9 @@ export class ConversationTools { contentGenerate = (...a: Parameters) => this.contentTools.contentGenerate(...a); contentPublish = (...a: Parameters) => this.contentTools.contentPublish(...a); + // --- Delegated: Retrieval --- + mindbaseDraft = (...a: Parameters) => this.retrievalTools.mindbaseDraft(...a); + // --- Cross-source --- async conversationTimeline(args: { diff --git a/apps/mcp-server/tools/retrieval-tools.ts b/apps/mcp-server/tools/retrieval-tools.ts new file mode 100644 index 0000000..6e71ac0 --- /dev/null +++ b/apps/mcp-server/tools/retrieval-tools.ts @@ -0,0 +1,263 @@ +/** + * MindBase MCP Server - Retrieval Tools + * + * Retrieval-pack assembly for article writing. NO LLM calls — this only + * gathers, organizes, and budgets past conversations into a structured + * context pack. The calling agent writes the actual content. + */ + +import type { StorageBackend, ConversationItem } from '../storage/interface.js'; + +export type DraftPlatform = 'zenn' | 'qiita' | 'note' | 'blog'; + +export interface MindbaseDraftArgs { + topic?: string; + conversationIds?: string[]; + project?: string; + source?: string; + limit?: number; + platform?: DraftPlatform; + maxExcerptChars?: number; + maxTotalChars?: number; +} + +export interface DraftSource { + id: string; + title: string; + source: string; + project?: string; + occurredAt: string; + similarity?: number; +} + +export interface DraftExcerpt { + conversationId: string; + occurredAt: string; + text: string; + truncated: boolean; +} + +export interface DraftCoverage { + found: number; + included: number; + truncatedCount: number; + droppedIds: string[]; +} + +export interface DraftPack { + frontmatterSkeleton: string; + writingNotes: string[]; + sources: DraftSource[]; + excerpts: DraftExcerpt[]; + coverage: DraftCoverage; +} + +const DEFAULT_LIMIT = 8; +const MAX_LIMIT = 20; +const DEFAULT_MAX_EXCERPT_CHARS = 1200; +const DEFAULT_MAX_TOTAL_CHARS = 24000; +/** Same threshold as content_generate's semantic search. */ +const SEARCH_THRESHOLD = 0.5; + +/** + * Frontmatter skeletons mirror the field sets used by the publishers in + * libs/generators/publishers/ (zenn-publisher.ts formatZennArticle, + * qiita-publisher.ts preview, note-publisher.ts preview). The publishers do + * not export their frontmatter builders, so the field sets are minimally + * duplicated here — keep them in sync with those files. + */ +const FRONTMATTER_SKELETONS: Record = { + zenn: [ + '---', + 'title: "TODO: 記事タイトル(50文字以内、検索を意識した具体的な形)"', + 'emoji: "📝"', + 'type: "tech"', + 'topics: ["TODO-topic1", "TODO-topic2"]', + 'published: false', + '---', + ].join('\n'), + qiita: [ + '---', + 'title: "TODO: 「〇〇で△△する方法」形式のタイトル"', + 'tags:', + ' - name: "TODO-tag1"', + ' - name: "TODO-tag2"', + 'private: true', + '---', + ].join('\n'), + // note.com articles do not use YAML frontmatter (see note-publisher.ts + // preview and platform-prompts.ts NOTE_PROMPT): title heading + hashtags. + note: [ + '# TODO: タイトル(30文字以内、好奇心を刺激する形)', + '', + '#TODOタグ1 #TODOタグ2', + ].join('\n'), + blog: [ + '---', + 'title: "TODO: article title"', + 'date: "TODO: YYYY-MM-DD"', + 'tags: ["TODO-tag1", "TODO-tag2"]', + 'draft: true', + '---', + ].join('\n'), +}; + +/** + * Platform conventions distilled from libs/generators/platform-prompts.ts + * (source of truth for the full prompts). + */ +const WRITING_NOTES: Record = { + zenn: [ + '中〜上級エンジニア向けに正確で再現可能な手順を書く。環境情報(OS、言語バージョン等)を冒頭に明記する', + 'Zenn独自記法(:::message, :::details, :::warning)と言語指定・コメント付きコードブロックを活用する', + '「問題 → 調査 → 解決 → 学び」の流れでまとめ、最後に要点を箇条書きにする', + ], + qiita: [ + '冒頭に「TL;DR」で結論を先出しし、環境・前提条件セクションを必ず置く', + '手順は番号付きでコマンドはコピペ可能に。コードブロックにはファイル名を付ける', + 'エラーメッセージとその解決策をセットで示し、「参考リンク」セクションで締める', + ], + note: [ + '非エンジニア読者も想定し、専門用語は噛み砕いて「〜してみました」など柔らかい語尾で語りかける', + 'パラグラフは3行以内・空行多め・コードブロックは最小限にし、個人的な体験や感想を織り交ぜる', + 'リード文で「何が得られるか」を明示し、最後に「まとめ」と次のアクションを提示する', + ], + blog: [ + '対象読者と記事から得られるものを冒頭で明示する', + '見出しで構造化し、コードブロックには言語指定を付ける', + ], +}; + +function toIso(value: Date | string): string { + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); +} + +function toTime(value: Date | string): number { + return value instanceof Date ? value.getTime() : new Date(value).getTime(); +} + +/** + * Extract full text from conversation content, following the same content + * shape handling as content-tools.ts (content.messages with role/content). + */ +function extractText(item: ConversationItem): string { + const rawMessages = item.content?.messages; + if (Array.isArray(rawMessages) && rawMessages.length > 0) { + return rawMessages + .filter((m: any) => m.role === 'user' || m.role === 'assistant') + .map((m: any) => `${m.role}: ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`) + .join('\n'); + } + return typeof item.content === 'string' ? item.content : JSON.stringify(item.content ?? ''); +} + +export class RetrievalTools { + constructor(private storage: StorageBackend) {} + + async mindbaseDraft(args: MindbaseDraftArgs): Promise { + const hasTopic = typeof args.topic === 'string' && args.topic.trim().length > 0; + const hasIds = Array.isArray(args.conversationIds) && args.conversationIds.length > 0; + if (hasTopic === hasIds) { + throw new Error('Exactly one of "topic" or "conversationIds" is required'); + } + if (hasIds && (args.project || args.source)) { + throw new Error('"project" and "source" filters are only valid with "topic"'); + } + + const limit = Math.min(Math.max(args.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT); + const platform = args.platform ?? 'blog'; + if (!(platform in FRONTMATTER_SKELETONS)) { + throw new Error(`Unknown platform: ${platform}. Supported: ${Object.keys(FRONTMATTER_SKELETONS).join(', ')}`); + } + const maxExcerptChars = args.maxExcerptChars ?? DEFAULT_MAX_EXCERPT_CHARS; + const maxTotalChars = args.maxTotalChars ?? DEFAULT_MAX_TOTAL_CHARS; + + const droppedIds: string[] = []; + let selected: Array<{ item: ConversationItem; similarity?: number }> = []; + let found = 0; + + if (hasIds) { + for (const id of args.conversationIds!) { + const item = await this.storage.getById(id); + if (item) { + selected.push({ item }); + } else { + // Requested but not found — surface instead of silently skipping. + droppedIds.push(id); + } + } + found = selected.length; + } else { + // Over-fetch when post-hoc filters apply (semanticSearch has no + // project/source filters), so filtering does not starve the result set. + const fetchLimit = args.project || args.source ? Math.min(limit * 3, 60) : limit; + const results = await this.storage.semanticSearch(args.topic!, fetchLimit, SEARCH_THRESHOLD); + + let filtered = results; + if (args.source) { + filtered = filtered.filter((r) => r.item.source === args.source); + } + if (args.project) { + filtered = filtered.filter((r) => r.item.metadata?.project === args.project); + } + found = filtered.length; + selected = filtered.map((r) => ({ item: r.item, similarity: r.similarity })); + } + + // Enforce limit visibly: anything beyond it goes to droppedIds. + if (selected.length > limit) { + for (const extra of selected.slice(limit)) { + droppedIds.push(extra.item.id); + } + selected = selected.slice(0, limit); + } + + // Chronological order (oldest first). The schema has no separate + // occurred_at column; created_at is the conversation's occurrence time. + selected.sort((a, b) => toTime(a.item.createdAt) - toTime(b.item.createdAt)); + + const sources: DraftSource[] = []; + const excerpts: DraftExcerpt[] = []; + let truncatedCount = 0; + let remaining = maxTotalChars; + + for (const { item, similarity } of selected) { + if (remaining <= 0) { + // Total budget exhausted — drop visibly. + droppedIds.push(item.id); + continue; + } + + const fullText = extractText(item); + const cap = Math.min(maxExcerptChars, remaining); + const truncated = fullText.length > cap; + const text = truncated ? fullText.slice(0, cap) : fullText; + remaining -= text.length; + if (truncated) truncatedCount++; + + const occurredAt = toIso(item.createdAt); + sources.push({ + id: item.id, + title: item.title, + source: item.source, + project: item.metadata?.project, + occurredAt, + similarity, + }); + excerpts.push({ conversationId: item.id, occurredAt, text, truncated }); + } + + return { + frontmatterSkeleton: FRONTMATTER_SKELETONS[platform], + writingNotes: WRITING_NOTES[platform], + sources, + excerpts, + coverage: { + found, + included: excerpts.length, + truncatedCount, + droppedIds, + }, + }; + } +}