UNPKG

minimal-think-mcp

Version:

A minimal MCP server implementing a persistent thinking workspace tool with zero cognitive interference and default session support

828 lines (737 loc) 28 kB
#!/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 fs from 'fs/promises'; import path from 'path'; import os from 'os'; /** * Minimal Think MCP Server with Persistent Sessions * * Implements a pure thinking workspace tool with zero cognitive interference. * Based on Anthropic's research on the "think" tool approach for enhanced * problem-solving in complex tool use situations. * * The tool preserves reasoning text without modification, creating a dedicated * space for structured thinking during complex tasks. * * Enhanced with: * - Persistent session storage to maintain thinking context across device restarts * - Smart Context Injection for builds_on relationships (automatically surfaces * reasoning chains, conflicting thoughts, and supporting evidence) */ // Session storage directory - allow override via environment variable for testing const SESSION_DIR = process.env.SESSION_DIR || path.join(os.homedir(), '.minimal-think-sessions'); // Ensure session directory exists async function ensureSessionDir() { try { await fs.mkdir(SESSION_DIR, { recursive: true }); } catch (error) { console.error('Failed to create session directory:', error); } } // Load a session from disk async function loadSession(sessionId) { try { const sessionPath = path.join(SESSION_DIR, `${sessionId}.json`); const data = await fs.readFile(sessionPath, 'utf8'); return JSON.parse(data); } catch (error) { // Return empty array if session doesn't exist yet return []; } } // Save a session to disk async function saveSession(sessionId, thoughts) { try { const sessionPath = path.join(SESSION_DIR, `${sessionId}.json`); await fs.writeFile(sessionPath, JSON.stringify(thoughts, null, 2), 'utf8'); } catch (error) { console.error(`Failed to save session ${sessionId}:`, error); } } // Get the default session ID async function getDefaultSession() { try { const defaultSessionPath = path.join(SESSION_DIR, 'defaultSession.json'); const data = await fs.readFile(defaultSessionPath, 'utf8'); const { defaultSessionId } = JSON.parse(data); return defaultSessionId; } catch (error) { // No default session yet return null; } } // Set the default session ID async function setDefaultSession(sessionId) { try { const defaultSessionPath = path.join(SESSION_DIR, 'defaultSession.json'); await fs.writeFile(defaultSessionPath, JSON.stringify({ defaultSessionId: sessionId }), 'utf8'); } catch (error) { console.error(`Failed to set default session ${sessionId}:`, error); } } // Manual cleanup utility for old sessions async function cleanupOldSessions(maxAgeDays = 90) { try { await ensureSessionDir(); const files = await fs.readdir(SESSION_DIR); const now = new Date(); let deletedCount = 0; for (const file of files) { if (!file.endsWith('.json') || file === 'defaultSession.json') continue; const filePath = path.join(SESSION_DIR, file); const stats = await fs.stat(filePath); const fileAge = (now - stats.mtime) / (1000 * 60 * 60 * 24); // age in days if (fileAge > maxAgeDays) { await fs.unlink(filePath); console.error(`Deleted old session: ${file} (${fileAge.toFixed(1)} days old)`); deletedCount++; } } return { deletedCount, maxAgeDays }; } catch (error) { console.error('Session cleanup failed:', error); throw error; } } // Create MCP server instance const server = new McpServer({ name: "minimal-think-mcp", version: "1.2.4" }); // Register the enhanced think tool with persistent sessions and relationship tracking server.registerTool( "think", { title: "Think Tool", description: "A persistent thinking workspace that preserves reasoning across sessions. Creates dedicated space for structured thinking during complex tasks with relationship tracking.", inputSchema: { reasoning: z.string().describe("Your thinking, reasoning, or analysis text"), sessionId: z.string().optional().describe("Session ID to continue an existing thinking process"), useDefaultSession: z.boolean().optional().default(false).describe("Use the default session automatically"), setAsDefault: z.boolean().optional().default(false).describe("Set this session as the default for future thinking"), mode: z.enum(["linear", "creative", "critical", "strategic", "empathetic"]).optional() .describe("Optional thinking mode to structure your reasoning"), tags: z.array(z.string()).optional().describe("Optional tags for categorizing thoughts"), newChat: z.boolean().optional().default(false).describe("Force a new session even if sessionId is provided"), relates_to: z.string().optional().describe("ID of thought this relates to"), relationship_type: z.enum(["builds_on", "supports", "contradicts", "refines", "synthesizes"]).optional().describe("Type of relationship to the referenced thought") } }, async ({ reasoning, sessionId, useDefaultSession, setAsDefault, mode, tags, newChat, relates_to, relationship_type }) => { // Ensure session directory exists await ensureSessionDir(); // Determine session ID logic: // 1. If newChat is true, always create a new session // 2. If sessionId is provided and newChat is false, use that (highest priority) // 3. If useDefaultSession is true, try to get default session // 4. If no default session or useDefaultSession is false, create new session let session = newChat ? null : sessionId; let usedDefaultSession = false; let isNewSession = false; if (!session && useDefaultSession) { session = await getDefaultSession(); usedDefaultSession = !!session; } // If we still don't have a session ID, generate a new one if (!session) { session = `session_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`; isNewSession = true; } // Load existing thoughts for this session const thoughts = await loadSession(session); // Add new thought const thoughtId = `thought_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`; const thoughtObj = { id: thoughtId, content: reasoning, mode: mode || "linear", tags: tags || [], timestamp: new Date().toISOString(), relates_to: null, relationship_type: null, relationships_in: [], // thoughts that reference this thought relationships_out: [] // thoughts this thought references }; // Validate and add relationship tracking if (relates_to && relationship_type) { // Prevent self-reference if (relates_to === thoughtId) { return { content: [{ type: "text", text: JSON.stringify({ error: "Cannot reference self" }) }] }; } const referencedThought = thoughts.find(t => t.id === relates_to); if (!referencedThought) { return { content: [{ type: "text", text: JSON.stringify({ error: "Referenced thought not found", thought_id: relates_to }) }] }; } // Refined temporal check - compare to current thought's timestamp if (new Date(referencedThought.timestamp) > new Date(thoughtObj.timestamp)) { return { content: [{ type: "text", text: JSON.stringify({ error: "Cannot reference future thoughts" }) }] }; } // Now use the same referencedThought for relationship tracking referencedThought.relationships_in.push({ thought_id: thoughtId, relationship_type }); thoughtObj.relationships_out.push({ thought_id: relates_to, relationship_type }); thoughtObj.relates_to = relates_to; thoughtObj.relationship_type = relationship_type; } thoughts.push(thoughtObj); // Save updated session await saveSession(session, thoughts); // Set as default if requested if (setAsDefault) { await setDefaultSession(session); } // Add related thought context for AI let related_context = null; let reasoning_chain = null; if (relates_to && relationship_type) { const related_thought = thoughts.find(t => t.id === relates_to); if (related_thought) { related_context = { relationship: relationship_type, related_thought_id: relates_to, related_content: related_thought.content.substring(0, 200) + "...", related_mode: related_thought.mode }; // context injection for builds_on relationships if (relationship_type === 'builds_on') { const chain = buildReasoningChain(relates_to, thoughts); // Find conflicting thoughts (max 3) const conflicts = thoughts.filter(t => t.relationships_out.some(rel => rel.thought_id === relates_to && rel.relationship_type === 'contradicts' ) ).slice(0, 3); // Find supporting evidence (max 3) const supports = thoughts.filter(t => t.relationships_out.some(rel => rel.thought_id === relates_to && rel.relationship_type === 'supports' ) ).slice(0, 3); // Build enhanced context object related_context = { type: 'builds_on_enhanced', chain_preview: chain.chain.slice(0, 5).map(t => t.content_preview), conflicts: conflicts.map(t => t.content.substring(0, 80) + "..."), supports: supports.map(t => t.content.substring(0, 80) + "...") }; reasoning_chain = chain; // maintain backward compatibility } } } // Generate the response JSON const responseJson = { thinking: reasoning, thoughtId: thoughtId, sessionId: session, mode: mode || "linear", tags: tags || [], timestamp: new Date().toISOString(), thoughtCount: thoughts.length, preserved: true, related_context: related_context, reasoning_chain: reasoning_chain, usingDefaultSession: usedDefaultSession, isDefaultSession: setAsDefault || usedDefaultSession, isNewSession: isNewSession }; // Create a note for Claude about session continuity const claudeNote = ` <!-- Session ID: ${session} In this chat, I should automatically include this session ID in future "think" tool calls unless instructed to start a new session or use a different session. For future think tool calls in this chat, I'll use: { "reasoning": "...", "sessionId": "${session}" } -->`; return { content: [{ type: "text", text: JSON.stringify(responseJson, null, 2) + claudeNote }] }; } ); // List available sessions server.registerTool( "list_sessions", { title: "List Sessions", description: "List all available thinking sessions", inputSchema: {} }, async () => { try { await ensureSessionDir(); const files = await fs.readdir(SESSION_DIR); // Get default session let defaultSessionId = null; try { defaultSessionId = await getDefaultSession(); } catch (error) { // No default session } const sessionInfo = await Promise.all(files .filter(file => file.endsWith('.json') && file !== 'defaultSession.json') .map(async file => { const sessionId = file.replace('.json', ''); const filePath = path.join(SESSION_DIR, file); const stats = await fs.stat(filePath); try { const thoughts = await loadSession(sessionId); return { sessionId, thoughtCount: thoughts.length, firstThought: thoughts[0]?.timestamp || null, lastThought: thoughts[thoughts.length - 1]?.timestamp || null, lastModified: stats.mtime.toISOString(), isDefault: sessionId === defaultSessionId }; } catch (e) { return { sessionId, error: "Could not read session data", lastModified: stats.mtime.toISOString(), isDefault: sessionId === defaultSessionId }; } })); const responseJson = { sessions: sessionInfo, count: sessionInfo.length, defaultSessionId, timestamp: new Date().toISOString() }; return { content: [{ type: "text", text: JSON.stringify(responseJson, null, 2) }] }; } catch (error) { console.error('Failed to list sessions:', error); return { content: [{ type: "text", text: JSON.stringify({ error: "Failed to list sessions", message: error.message }, null, 2) }] }; } } ); // View a specific session server.registerTool( "view_session", { title: "View Session", description: "View the complete contents of a thinking session", inputSchema: { sessionId: z.string().optional().describe("Session ID to view. If not provided, the default session will be used if available.") } }, async ({ sessionId }) => { try { // If no sessionId provided, try to use default session let session = sessionId; let usedDefaultSession = false; if (!session) { session = await getDefaultSession(); usedDefaultSession = !!session; if (!session) { return { content: [{ type: "text", text: JSON.stringify({ error: "No session ID provided and no default session set", message: "Please provide a session ID or set a default session first" }, null, 2) }] }; } } const thoughts = await loadSession(session); const responseJson = { sessionId: session, thoughts, count: thoughts.length, timestamp: new Date().toISOString(), usingDefaultSession: usedDefaultSession }; // Create a note for Claude about session continuity const claudeNote = ` <!-- Session ID: ${session} I now know about this session and should use it if the user wants to continue this thinking process. -->`; return { content: [{ type: "text", text: JSON.stringify(responseJson, null, 2) + claudeNote }] }; } catch (error) { console.error(`Failed to view session ${sessionId}:`, error); return { content: [{ type: "text", text: JSON.stringify({ error: "Failed to view session", message: error.message }, null, 2) }] }; } } ); // Delete a session server.registerTool( "delete_session", { title: "Delete Session", description: "Delete a thinking session", inputSchema: { sessionId: z.string().describe("Session ID to delete") } }, async ({ sessionId }) => { try { const sessionPath = path.join(SESSION_DIR, `${sessionId}.json`); await fs.unlink(sessionPath); // If this was the default session, clear that too const defaultSessionId = await getDefaultSession(); if (defaultSessionId === sessionId) { const defaultSessionPath = path.join(SESSION_DIR, 'defaultSession.json'); try { await fs.unlink(defaultSessionPath); } catch (error) { // Ignore if file doesn't exist } } return { content: [{ type: "text", text: JSON.stringify({ status: "success", message: `Session ${sessionId} deleted successfully`, wasDefault: defaultSessionId === sessionId, timestamp: new Date().toISOString() }, null, 2) }] }; } catch (error) { console.error(`Failed to delete session ${sessionId}:`, error); return { content: [{ type: "text", text: JSON.stringify({ error: "Failed to delete session", message: error.message }, null, 2) }] }; } } ); // Set or reset default session server.registerTool( "set_default_session", { title: "Set Default Session", description: "Set or reset the default thinking session", inputSchema: { sessionId: z.string().optional().describe("Session ID to set as default. If not provided, the default session will be cleared.") } }, async ({ sessionId }) => { try { if (sessionId) { // Verify the session exists before setting it as default const sessionPath = path.join(SESSION_DIR, `${sessionId}.json`); try { await fs.access(sessionPath); } catch (error) { return { content: [{ type: "text", text: JSON.stringify({ error: "Invalid session ID", message: `Session ${sessionId} does not exist` }, null, 2) }] }; } // Set new default session await setDefaultSession(sessionId); return { content: [{ type: "text", text: JSON.stringify({ status: "success", message: `Default session set to ${sessionId}`, timestamp: new Date().toISOString() }, null, 2) }] }; } else { // Clear default session const defaultSessionPath = path.join(SESSION_DIR, 'defaultSession.json'); try { await fs.unlink(defaultSessionPath); } catch (error) { // Ignore if file doesn't exist } return { content: [{ type: "text", text: JSON.stringify({ status: "success", message: "Default session cleared", timestamp: new Date().toISOString() }, null, 2) }] }; } } catch (error) { console.error(`Failed to set default session:`, error); return { content: [{ type: "text", text: JSON.stringify({ error: "Failed to set default session", message: error.message }, null, 2) }] }; } } ); // Manual cleanup of old sessions server.registerTool( "cleanup_sessions", { title: "Cleanup Old Sessions", description: "Manually clean up old thinking sessions", inputSchema: { maxAgeDays: z.number().min(1).default(90).describe("Maximum age in days before sessions are deleted") } }, async ({ maxAgeDays }) => { try { const result = await cleanupOldSessions(maxAgeDays); return { content: [{ type: "text", text: JSON.stringify({ status: "success", deletedCount: result.deletedCount, maxAgeDays: result.maxAgeDays, message: `Deleted ${result.deletedCount} sessions older than ${result.maxAgeDays} days`, timestamp: new Date().toISOString() }, null, 2) }] }; } catch (error) { console.error('Failed to clean up sessions:', error); return { content: [{ type: "text", text: JSON.stringify({ error: "Failed to clean up sessions", message: error.message }, null, 2) }] }; } } ); // Find thought relationships tool - helps AI discover related thoughts efficiently server.registerTool( "find_thought_relationships", { title: "Find Thought Relationships", description: "Search for thoughts that could be related to current reasoning, helping AI build coherent argument chains", inputSchema: { query: z.string().describe("Search query to find related thoughts (searches content, tags, and modes)"), sessionId: z.string().optional().describe("Session ID to search in. If not provided, the default session will be used if available."), relationship_types: z.array(z.enum(["builds_on", "supports", "contradicts", "refines", "synthesizes"])).optional().describe("Filter by specific relationship types"), exclude_thought_id: z.string().optional().describe("Exclude a specific thought ID from results (useful to avoid self-reference)"), limit: z.number().min(1).max(20).default(10).describe("Maximum number of results to return") } }, async ({ query, sessionId, relationship_types, exclude_thought_id, limit }) => { try { await ensureSessionDir(); // Determine session to use let session = sessionId; if (!session) { session = await getDefaultSession(); if (!session) { return { content: [{ type: "text", text: JSON.stringify({ error: "No session ID provided and no default session set" }) }] }; } } const thoughts = await loadSession(session); if (thoughts.length === 0) { return { content: [{ type: "text", text: JSON.stringify({ results: [], total: 0, query: query }) }] }; } // Search logic const queryLower = query.toLowerCase(); const searchResults = thoughts .filter(t => { // Exclude specific thought if requested if (exclude_thought_id && t.id === exclude_thought_id) return false; // Filter by relationship types if specified if (relationship_types && relationship_types.length > 0) { if (!t.relationship_type || !relationship_types.includes(t.relationship_type)) return false; } // Search in content, tags, and mode const contentMatch = t.content.toLowerCase().includes(queryLower); const tagMatch = t.tags && t.tags.some(tag => tag.toLowerCase().includes(queryLower)); const modeMatch = t.mode && t.mode.toLowerCase().includes(queryLower); return contentMatch || tagMatch || modeMatch; }) .map(t => ({ id: t.id, content_preview: t.content.substring(0, 150) + (t.content.length > 150 ? "..." : ""), mode: t.mode, tags: t.tags, timestamp: t.timestamp, relates_to: t.relates_to, relationship_type: t.relationship_type, // Calculate relevance score (simple scoring) relevance_score: calculateRelevanceScore(t, queryLower) })) .sort((a, b) => b.relevance_score - a.relevance_score) .slice(0, limit); const response = { results: searchResults, total: searchResults.length, query: query, sessionId: session, searched_thoughts: thoughts.length }; return { content: [{ type: "text", text: JSON.stringify(response, null, 2) }] }; } catch (error) { console.error('Failed to find thought relationships:', error); return { content: [{ type: "text", text: JSON.stringify({ error: "Failed to find relationships", message: error.message }) }] }; } } ); // Simple relevance scoring function function calculateRelevanceScore(thought, queryLower) { let score = 0; const content = thought.content.toLowerCase(); // Exact phrase match gets highest score if (content.includes(queryLower)) { score += 10; } // Word matches const queryWords = queryLower.split(/\s+/); queryWords.forEach(word => { if (content.includes(word)) { score += 2; } }); // Tag matches if (thought.tags) { thought.tags.forEach(tag => { if (tag.toLowerCase().includes(queryLower)) { score += 5; } }); } // Mode match if (thought.mode && thought.mode.toLowerCase().includes(queryLower)) { score += 3; } // Boost score for thoughts with relationships (they're part of reasoning chains) if (thought.relates_to || thought.relationship_type) { score += 1; } return score; } // Build reasoning chain for "builds_on" relationships // Traces back the chain of thoughts that build on each other // Returns: [foundation_thought] → [building_thought] → [current_thought] function buildReasoningChain(thoughtId, thoughts) { const chain = []; const visited = new Set(); // Prevent infinite loops let currentId = thoughtId; // Trace backwards through the builds_on chain while (currentId && !visited.has(currentId) && chain.length < 20) { visited.add(currentId); const thought = thoughts.find(t => t.id === currentId); if (!thought) break; // Add to front of chain (we're going backwards) chain.unshift({ id: thought.id, content_preview: thought.content.substring(0, 120) + (thought.content.length > 120 ? "..." : ""), mode: thought.mode, timestamp: thought.timestamp, relationship_type: thought.relationship_type }); // Continue tracing if this thought builds on another if (thought.relationship_type === 'builds_on' && thought.relates_to) { currentId = thought.relates_to; } else { break; } } // Limit chain length to respect working memory constraints (7±2 items) const maxChainLength = 7; if (chain.length > maxChainLength) { // Keep the most recent items and add an indicator for truncation const truncatedChain = chain.slice(-maxChainLength); truncatedChain[0] = { ...truncatedChain[0], truncated: true, note: `... (${chain.length - maxChainLength} earlier thoughts in chain)` }; return { chain: truncatedChain, total_length: chain.length, truncated: true }; } return { chain: chain, total_length: chain.length, truncated: false }; } // Error handling process.on('uncaughtException', (error) => { console.error('Uncaught exception:', error); process.exit(1); }); process.on('unhandledRejection', (reason, promise) => { console.error('Unhandled rejection at:', promise, 'reason:', reason); process.exit(1); }); // Initialize and start server async function main() { try { // Ensure session directory exists on startup await ensureSessionDir(); // Use stdio transport for npx compatibility const transport = new StdioServerTransport(); // Connect server to transport await server.connect(transport); // Server is now running and listening for MCP messages console.error('Minimal Think MCP Server with persistent sessions started successfully'); console.error(`Session storage: ${SESSION_DIR}`); } catch (error) { console.error('Failed to start server:', error); process.exit(1); } } // Start the server main().catch((error) => { console.error('Server startup failed:', error); process.exit(1); });