├── global.d.ts ├── .env.example ├── src ├── fetch-override.ts ├── types.ts ├── config.ts ├── validation.ts ├── persistence │ └── qdrant.ts └── index.ts ├── .npmignore ├── tsconfig.json ├── .gitignore ├── Dockerfile ├── config.ts ├── test-auth.mjs ├── smithery.yaml ├── test.mjs ├── package.json ├── test-direct.mjs ├── docs └── PRD.md ├── README.md └── persistence └── qdrant.ts /global.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'dotenv'; -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | OPENAI_API_KEY=your_openai_api_key_here 2 | QDRANT_URL=your_qdrant_url_here 3 | QDRANT_COLLECTION_NAME=your_collection_name_here 4 | QDRANT_API_KEY=your_optional_qdrant_api_key_here -------------------------------------------------------------------------------- /src/fetch-override.ts: -------------------------------------------------------------------------------- 1 | // Configure fetch to always include the api-key header 2 | const originalFetch = globalThis.fetch; 3 | globalThis.fetch = function(input, init = {}) { 4 | const headers = new Headers(init.headers); 5 | headers.set('api-key', process.env.QDRANT_API_KEY!); 6 | return originalFetch(input, { ...init, headers }); 7 | }; 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Source 2 | src/ 3 | 4 | # TypeScript config 5 | tsconfig.json 6 | 7 | # Development configs 8 | .gitignore 9 | .git 10 | 11 | # Editor directories 12 | .idea 13 | .vscode 14 | 15 | # Logs 16 | *.log 17 | 18 | # Environment variables 19 | .env* 20 | 21 | # Dependencies 22 | node_modules/ 23 | 24 | # Test files 25 | test/ 26 | *.test.ts 27 | *.spec.ts 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "esModuleInterop": true, 7 | "strict": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "outDir": "./dist", 11 | "declaration": true 12 | }, 13 | "include": ["src/**/*", "global.d.ts"], 14 | "exclude": ["node_modules", "dist"] 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | npm-debug.log 4 | yarn-debug.log 5 | yarn-error.log 6 | 7 | # Environment variables 8 | .env 9 | .env.local 10 | .env.*.local 11 | 12 | # Build output 13 | dist/ 14 | build/ 15 | 16 | # IDE and editor files 17 | .idea/ 18 | .vscode/ 19 | *.swp 20 | *.swo 21 | .DS_Store 22 | 23 | # Logs 24 | logs/ 25 | *.log 26 | 27 | # Runtime data 28 | pids/ 29 | *.pid 30 | *.seed 31 | *.pid.lock 32 | 33 | # Testing 34 | coverage/ 35 | 36 | # Misc 37 | .tmp/ 38 | .temp/ -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface Entity extends Record { 2 | name: string; 3 | entityType: string; 4 | observations: string[]; 5 | } 6 | 7 | export interface Relation extends Record { 8 | from: string; 9 | to: string; 10 | relationType: string; 11 | } 12 | 13 | export interface KnowledgeGraph { 14 | entities: Entity[]; 15 | relations: Relation[]; 16 | } 17 | 18 | export interface SearchResult { 19 | type: 'entity' | 'relation'; 20 | score: number; 21 | data: Entity | Relation; 22 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine AS builder 2 | 3 | WORKDIR /app 4 | 5 | COPY package*.json ./ 6 | COPY tsconfig.json ./ 7 | COPY src/ ./src/ 8 | COPY global.d.ts ./ 9 | 10 | RUN npm install 11 | RUN npm run build 12 | 13 | FROM node:20-alpine AS release 14 | 15 | WORKDIR /app 16 | 17 | COPY --from=builder /app/dist ./dist 18 | COPY --from=builder /app/package*.json ./ 19 | 20 | ENV NODE_ENV=production 21 | 22 | # Runtime environment variables should be passed when running the container 23 | # Example: docker run -e OPENAI_API_KEY=xxx -e QDRANT_API_KEY=xxx ... 24 | # Install only production dependencies and skip prepare script which tries to run build 25 | RUN npm ci --omit=dev --ignore-scripts 26 | 27 | CMD ["node", "dist/index.js"] 28 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | // Check for required environment variables 2 | const OPENAI_API_KEY = process.env.OPENAI_API_KEY; 3 | if (!OPENAI_API_KEY) { 4 | console.error("Error: OPENAI_API_KEY environment variable is required"); 5 | process.exit(1); 6 | } 7 | 8 | const QDRANT_URL = process.env.QDRANT_URL; 9 | if (!QDRANT_URL) { 10 | console.error("Error: QDRANT_URL environment variable is required"); 11 | process.exit(1); 12 | } 13 | 14 | const COLLECTION_NAME = process.env.QDRANT_COLLECTION_NAME; 15 | if (!COLLECTION_NAME) { 16 | console.error("Error: QDRANT_COLLECTION_NAME environment variable is required"); 17 | process.exit(1); 18 | } 19 | 20 | const QDRANT_API_KEY = process.env.QDRANT_API_KEY; 21 | // Note: QDRANT_API_KEY is optional, so we don't check if it exists 22 | 23 | export { OPENAI_API_KEY, QDRANT_URL, COLLECTION_NAME, QDRANT_API_KEY }; -------------------------------------------------------------------------------- /config.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | dotenv.config(); 3 | 4 | // Check for required environment variables 5 | const OPENAI_API_KEY = process.env.OPENAI_API_KEY!; 6 | if (!OPENAI_API_KEY) { 7 | console.error("Error: OPENAI_API_KEY environment variable is required"); 8 | process.exit(1); 9 | } 10 | 11 | const QDRANT_URL = process.env.QDRANT_URL!; 12 | if (!QDRANT_URL) { 13 | console.error("Error: QDRANT_URL environment variable is required"); 14 | process.exit(1); 15 | } 16 | 17 | const COLLECTION_NAME = process.env.QDRANT_COLLECTION_NAME!; 18 | if (!COLLECTION_NAME) { 19 | console.error("Error: QDRANT_COLLECTION_NAME environment variable is required"); 20 | process.exit(1); 21 | } 22 | 23 | const QDRANT_API_KEY = process.env.QDRANT_API_KEY; 24 | // Note: QDRANT_API_KEY is optional, so we don't check if it exists 25 | 26 | export { OPENAI_API_KEY, QDRANT_URL, COLLECTION_NAME, QDRANT_API_KEY }; 27 | -------------------------------------------------------------------------------- /test-auth.mjs: -------------------------------------------------------------------------------- 1 | import https from 'https'; 2 | import 'dotenv/config'; 3 | 4 | const url = process.env.QDRANT_URL; 5 | const apiKey = process.env.QDRANT_API_KEY; 6 | 7 | // Test both authentication formats 8 | const tests = [ 9 | { 10 | name: "api-key header", 11 | headers: { 'api-key': apiKey } 12 | }, 13 | { 14 | name: "Authorization Bearer", 15 | headers: { 'Authorization': `Bearer ${apiKey}` } 16 | } 17 | ]; 18 | 19 | for (const test of tests) { 20 | console.log(`\nTesting ${test.name}...`); 21 | 22 | const options = { 23 | headers: test.headers, 24 | rejectUnauthorized: false 25 | }; 26 | 27 | https.get(`${url}/collections`, options, (res) => { 28 | console.log('Status Code:', res.statusCode); 29 | console.log('Headers:', res.headers); 30 | 31 | let data = ''; 32 | res.on('data', (chunk) => { data += chunk; }); 33 | res.on('end', () => { 34 | console.log('Response:', data); 35 | }); 36 | -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- 1 | startCommand: 2 | type: stdio 3 | configSchema: 4 | type: object 5 | properties: 6 | openaiApiKey: 7 | type: string 8 | description: "OpenAI API key for generating embeddings" 9 | qdrantApiKey: 10 | type: string 11 | description: "Qdrant API key for vector database access" 12 | qdrantUrl: 13 | type: string 14 | description: "URL of the Qdrant instance" 15 | default: "http://localhost:6333" 16 | required: ["openaiApiKey", "qdrantApiKey"] 17 | additionalProperties: false 18 | commandFunction: | 19 | function getStartCommand(config) { 20 | return { 21 | command: "node", 22 | args: ["dist/index.js"], 23 | env: { 24 | NODE_ENV: "production", 25 | OPENAI_API_KEY: config.openaiApiKey, 26 | QDRANT_API_KEY: config.qdrantApiKey, 27 | QDRANT_URL: config.qdrantUrl 28 | } 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /test.mjs: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import { QdrantPersistence } from './dist/persistence/qdrant.js'; 3 | 4 | async function test() { 5 | console.log('Creating client...'); 6 | const client = new QdrantPersistence(); 7 | 8 | console.log('Initializing...'); 9 | await client.initialize(); 10 | console.log('Successfully initialized!'); 11 | 12 | console.log('\nTesting entity creation...'); 13 | const testEntity = { 14 | name: 'test_entity', 15 | entityType: 'test', 16 | observations: ['This is a test entity to verify the system is working'] 17 | }; 18 | await client.persistEntity(testEntity); 19 | console.log('Entity created successfully'); 20 | 21 | console.log('\nTesting search...'); 22 | const results = await client.searchSimilar('test'); 23 | console.log('Search results:', JSON.stringify(results, null, 2)); 24 | 25 | console.log('\nDeleting test entity...'); 26 | await client.deleteEntity('test_entity'); 27 | console.log('Test entity deleted'); 28 | } 29 | 30 | test().catch(err => { 31 | console.error('Test failed:', err); 32 | process.exit(1); 33 | }); 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@delorenj/mcp-qdrant-memory", 3 | "version": "0.2.4", 4 | "description": "MCP server for enabling the named memory graphs to be persisted to a qdrant instance.", 5 | "license": "MIT", 6 | "author": "Jarad DeLorenzo", 7 | "homepage": "https://github.com/delorenj/mcp-qdrant-memory", 8 | "bugs": "https://github.com//delorenj/mcp-qdrant-memory/issues", 9 | "type": "module", 10 | "bin": { 11 | "mcp-qdrant-memory": "dist/index.js" 12 | }, 13 | "files": [ 14 | "dist" 15 | ], 16 | "scripts": { 17 | "build": "tsc && shx chmod +x dist/*.js", 18 | "prepare": "npm run build", 19 | "watch": "tsc --watch" 20 | }, 21 | "dependencies": { 22 | "@modelcontextprotocol/sdk": "^1.0.1", 23 | "@qdrant/js-client-rest": "^1.13.0", 24 | "axios": "^1.8.1", 25 | "dotenv": "^16.3.1", 26 | "openai": "^4.24.1" 27 | }, 28 | "devDependencies": { 29 | "@types/dotenv": "^8.2.0", 30 | "@types/node": "^20.10.0", 31 | "shx": "^0.3.4", 32 | "typescript": "^5.6.2" 33 | }, 34 | "publishConfig": { 35 | "access": "public" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /test-direct.mjs: -------------------------------------------------------------------------------- 1 | import https from 'https'; 2 | import 'dotenv/config'; 3 | 4 | const options = { 5 | hostname: '3b4c5b59-0324-48dd-bf38-4c24cb59d805.us-east4-0.gcp.cloud.qdrant.io', 6 | port: 6333, 7 | path: '/collections', 8 | method: 'GET', 9 | headers: { 10 | // Exact same header format as curl 11 | 'api-key': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3MiOiJtIn0.x6NrWBMMtPqcep5dNxOqjXT42sQhATAMdxEqVFDJKew' 12 | }, 13 | rejectUnauthorized: false 14 | }; 15 | 16 | console.log('Making request with options:', { 17 | ...options, 18 | headers: { ...options.headers, 'api-key': '[REDACTED]' } 19 | }); 20 | 21 | const req = https.request(options, (res) => { 22 | console.log('Status:', res.statusCode); 23 | console.log('Headers:', res.headers); 24 | 25 | let data = ''; 26 | res.on('data', (chunk) => { data += chunk; }); 27 | res.on('end', () => { 28 | try { 29 | console.log('Response:', JSON.parse(data)); 30 | } catch { 31 | console.log('Raw response:', data); 32 | } 33 | }); 34 | }); 35 | 36 | req.on('error', (e) => { 37 | console.error('Error:', e); 38 | }); 39 | 40 | req.end(); 41 | -------------------------------------------------------------------------------- /docs/PRD.md: -------------------------------------------------------------------------------- 1 | # Memory Management Plan (Knowledge Graph) 2 | 3 | This document outlines the plan for managing memories within the `mcp-qdrant-memory` server, focusing on the knowledge graph functionality. 4 | 5 | ## 1. Collection Naming Convention 6 | 7 | We will use the following naming convention for knowledge graph collections: 8 | 9 | * `project-name-knowledge-graph`: For the knowledge graph of a specific project. For example, `website-knowledge-graph` for a project named "website". 10 | * `personal-knowledge-graph`: For personal knowledge graph data that is not tied to a specific project. 11 | 12 | ## 2. Automatic Context Switching (Directory-Based) 13 | 14 | The `mcp-qdrant-memory` server will automatically infer the appropriate knowledge graph collection based on the current working directory: 15 | 16 | * If the current working directory is within a recognized project directory (e.g., `/home/delorenj/projects/my-project`), the server will use the corresponding `project-name-knowledge-graph` collection (e.g., `my-project-knowledge-graph`). 17 | * If the current working directory is *not* within a recognized project directory, the server will default to the `personal-knowledge-graph` collection. 18 | 19 | ## 3. Explicit Context Switching (Tool-Based) 20 | 21 | The `mcp-qdrant-memory` server will provide a tool called `set_knowledge_graph_collection` to explicitly set the active knowledge graph collection. 22 | 23 | * **Tool Name:** `set_knowledge_graph_collection` 24 | * **Argument:** `collection_name` (string) - The name of the collection to switch to. 25 | * **Usage:** This tool is particularly useful when the working directory is static (e.g., in Claude Desktop) and cannot be used for automatic context switching. It allows the user to explicitly specify which knowledge graph collection should be used. The server will prioritize the explicitly set collection over the directory-based default. 26 | 27 | ## 4. Implementation 28 | 29 | The `mcp-qdrant-memory` server will be modified to: 30 | 31 | * Accept a `collection_name` argument in its configuration, allowing for a default collection to be specified. 32 | * Implement the `set_knowledge_graph_collection` tool. 33 | * Implement the automatic context switching logic, prioritizing any explicitly set collection (via the tool) over the directory-based inference. 34 | -------------------------------------------------------------------------------- /src/validation.ts: -------------------------------------------------------------------------------- 1 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 2 | import { Entity, Relation } from "./types.js"; 3 | 4 | export interface CreateEntitiesRequest { 5 | entities: Entity[]; 6 | } 7 | 8 | export interface CreateRelationsRequest { 9 | relations: Relation[]; 10 | } 11 | 12 | export interface AddObservationsRequest { 13 | observations: Array<{ 14 | entityName: string; 15 | contents: string[]; 16 | }>; 17 | } 18 | 19 | export interface DeleteEntitiesRequest { 20 | entityNames: string[]; 21 | } 22 | 23 | export interface DeleteObservationsRequest { 24 | deletions: Array<{ 25 | entityName: string; 26 | observations: string[]; 27 | }>; 28 | } 29 | 30 | export interface DeleteRelationsRequest { 31 | relations: Relation[]; 32 | } 33 | 34 | export interface SearchSimilarRequest { 35 | query: string; 36 | limit?: number; 37 | } 38 | 39 | function isRecord(value: unknown): value is Record { 40 | return typeof value === 'object' && value !== null; 41 | } 42 | 43 | function isStringArray(value: unknown): value is string[] { 44 | return Array.isArray(value) && value.every(item => typeof item === 'string'); 45 | } 46 | 47 | function isEntity(value: unknown): value is Entity { 48 | if (!isRecord(value)) return false; 49 | return ( 50 | typeof value.name === 'string' && 51 | typeof value.entityType === 'string' && 52 | Array.isArray(value.observations) && 53 | value.observations.every(obs => typeof obs === 'string') 54 | ); 55 | } 56 | 57 | function isRelation(value: unknown): value is Relation { 58 | if (!isRecord(value)) return false; 59 | return ( 60 | typeof value.from === 'string' && 61 | typeof value.to === 'string' && 62 | typeof value.relationType === 'string' 63 | ); 64 | } 65 | 66 | export function validateCreateEntitiesRequest(args: unknown): CreateEntitiesRequest { 67 | if (!isRecord(args)) { 68 | throw new McpError(ErrorCode.InvalidParams, "Invalid request format"); 69 | } 70 | 71 | const { entities } = args; 72 | if (!Array.isArray(entities) || !entities.every(isEntity)) { 73 | throw new McpError(ErrorCode.InvalidParams, "Invalid entities array"); 74 | } 75 | 76 | return { entities }; 77 | } 78 | 79 | export function validateCreateRelationsRequest(args: unknown): CreateRelationsRequest { 80 | if (!isRecord(args)) { 81 | throw new McpError(ErrorCode.InvalidParams, "Invalid request format"); 82 | } 83 | 84 | const { relations } = args; 85 | if (!Array.isArray(relations) || !relations.every(isRelation)) { 86 | throw new McpError(ErrorCode.InvalidParams, "Invalid relations array"); 87 | } 88 | 89 | return { relations }; 90 | } 91 | 92 | export function validateAddObservationsRequest(args: unknown): AddObservationsRequest { 93 | if (!isRecord(args)) { 94 | throw new McpError(ErrorCode.InvalidParams, "Invalid request format"); 95 | } 96 | 97 | const { observations } = args; 98 | if (!Array.isArray(observations)) { 99 | throw new McpError(ErrorCode.InvalidParams, "Invalid observations array"); 100 | } 101 | 102 | for (const obs of observations) { 103 | if (!isRecord(obs) || typeof obs.entityName !== 'string' || !isStringArray(obs.contents)) { 104 | throw new McpError(ErrorCode.InvalidParams, "Invalid observation format"); 105 | } 106 | } 107 | 108 | return { observations: observations as AddObservationsRequest['observations'] }; 109 | } 110 | 111 | export function validateDeleteEntitiesRequest(args: unknown): DeleteEntitiesRequest { 112 | if (!isRecord(args)) { 113 | throw new McpError(ErrorCode.InvalidParams, "Invalid request format"); 114 | } 115 | 116 | const { entityNames } = args; 117 | if (!isStringArray(entityNames)) { 118 | throw new McpError(ErrorCode.InvalidParams, "Invalid entityNames array"); 119 | } 120 | 121 | return { entityNames }; 122 | } 123 | 124 | export function validateDeleteObservationsRequest(args: unknown): DeleteObservationsRequest { 125 | if (!isRecord(args)) { 126 | throw new McpError(ErrorCode.InvalidParams, "Invalid request format"); 127 | } 128 | 129 | const { deletions } = args; 130 | if (!Array.isArray(deletions)) { 131 | throw new McpError(ErrorCode.InvalidParams, "Invalid deletions array"); 132 | } 133 | 134 | for (const del of deletions) { 135 | if (!isRecord(del) || typeof del.entityName !== 'string' || !isStringArray(del.observations)) { 136 | throw new McpError(ErrorCode.InvalidParams, "Invalid deletion format"); 137 | } 138 | } 139 | 140 | return { deletions: deletions as DeleteObservationsRequest['deletions'] }; 141 | } 142 | 143 | export function validateDeleteRelationsRequest(args: unknown): DeleteRelationsRequest { 144 | if (!isRecord(args)) { 145 | throw new McpError(ErrorCode.InvalidParams, "Invalid request format"); 146 | } 147 | 148 | const { relations } = args; 149 | if (!Array.isArray(relations) || !relations.every(isRelation)) { 150 | throw new McpError(ErrorCode.InvalidParams, "Invalid relations array"); 151 | } 152 | 153 | return { relations }; 154 | } 155 | 156 | export function validateSearchSimilarRequest(args: unknown): SearchSimilarRequest { 157 | if (!isRecord(args)) { 158 | throw new McpError(ErrorCode.InvalidParams, "Invalid request format"); 159 | } 160 | 161 | const { query, limit } = args; 162 | if (typeof query !== 'string') { 163 | throw new McpError(ErrorCode.InvalidParams, "Missing or invalid query string"); 164 | } 165 | 166 | if (limit !== undefined && (typeof limit !== 'number' || limit <= 0)) { 167 | throw new McpError(ErrorCode.InvalidParams, "Invalid limit value"); 168 | } 169 | 170 | return { query, limit }; 171 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MCP Memory Server with Qdrant Persistence 2 | [![smithery badge](https://smithery.ai/badge/@delorenj/mcp-qdrant-memory)](https://smithery.ai/server/@delorenj/mcp-qdrant-memory) 3 | 4 | This MCP server provides a knowledge graph implementation with semantic search capabilities powered by Qdrant vector database. 5 | 6 | ## Features 7 | 8 | - Graph-based knowledge representation with entities and relations 9 | - File-based persistence (memory.json) 10 | - Semantic search using Qdrant vector database 11 | - OpenAI embeddings for semantic similarity 12 | - HTTPS support with reverse proxy compatibility 13 | - Docker support for easy deployment 14 | 15 | ## Environment Variables 16 | 17 | The following environment variables are required: 18 | 19 | ```bash 20 | # OpenAI API key for generating embeddings 21 | OPENAI_API_KEY=your-openai-api-key 22 | 23 | # Qdrant server URL (supports both HTTP and HTTPS) 24 | QDRANT_URL=https://your-qdrant-server 25 | 26 | # Qdrant API key (if authentication is enabled) 27 | QDRANT_API_KEY=your-qdrant-api-key 28 | 29 | # Name of the Qdrant collection to use 30 | QDRANT_COLLECTION_NAME=your-collection-name 31 | ``` 32 | 33 | ## Setup 34 | 35 | ### Local Setup 36 | 37 | 1. Install dependencies: 38 | ```bash 39 | npm install 40 | ``` 41 | 42 | 2. Build the server: 43 | ```bash 44 | npm run build 45 | ``` 46 | 47 | ### Docker Setup 48 | 49 | 1. Build the Docker image: 50 | ```bash 51 | docker build -t mcp-qdrant-memory . 52 | ``` 53 | 54 | 2. Run the Docker container with required environment variables: 55 | ```bash 56 | docker run -d \ 57 | -e OPENAI_API_KEY=your-openai-api-key \ 58 | -e QDRANT_URL=http://your-qdrant-server:6333 \ 59 | -e QDRANT_COLLECTION_NAME=your-collection-name \ 60 | -e QDRANT_API_KEY=your-qdrant-api-key \ 61 | --name mcp-qdrant-memory \ 62 | mcp-qdrant-memory 63 | ``` 64 | 65 | ### Add to MCP settings: 66 | ```json 67 | { 68 | "mcpServers": { 69 | "memory": { 70 | "command": "/bin/zsh", 71 | "args": ["-c", "cd /path/to/server && node dist/index.js"], 72 | "env": { 73 | "OPENAI_API_KEY": "your-openai-api-key", 74 | "QDRANT_API_KEY": "your-qdrant-api-key", 75 | "QDRANT_URL": "http://your-qdrant-server:6333", 76 | "QDRANT_COLLECTION_NAME": "your-collection-name" 77 | }, 78 | "alwaysAllow": [ 79 | "create_entities", 80 | "create_relations", 81 | "add_observations", 82 | "delete_entities", 83 | "delete_observations", 84 | "delete_relations", 85 | "read_graph", 86 | "search_similar" 87 | ] 88 | } 89 | } 90 | } 91 | ``` 92 | 93 | ## Tools 94 | 95 | ### Entity Management 96 | - `create_entities`: Create multiple new entities 97 | - `create_relations`: Create relations between entities 98 | - `add_observations`: Add observations to entities 99 | - `delete_entities`: Delete entities and their relations 100 | - `delete_observations`: Delete specific observations 101 | - `delete_relations`: Delete specific relations 102 | - `read_graph`: Get the full knowledge graph 103 | 104 | ### Semantic Search 105 | - `search_similar`: Search for semantically similar entities and relations 106 | ```typescript 107 | interface SearchParams { 108 | query: string; // Search query text 109 | limit?: number; // Max results (default: 10) 110 | } 111 | ``` 112 | 113 | ## Implementation Details 114 | 115 | The server maintains two forms of persistence: 116 | 117 | 1. File-based (memory.json): 118 | - Complete knowledge graph structure 119 | - Fast access to full graph 120 | - Used for graph operations 121 | 122 | 2. Qdrant Vector DB: 123 | - Semantic embeddings of entities and relations 124 | - Enables similarity search 125 | - Automatically synchronized with file storage 126 | 127 | ### Synchronization 128 | 129 | When entities or relations are modified: 130 | 1. Changes are written to memory.json 131 | 2. Embeddings are generated using OpenAI 132 | 3. Vectors are stored in Qdrant 133 | 4. Both storage systems remain consistent 134 | 135 | ### Search Process 136 | 137 | When searching: 138 | 1. Query text is converted to embedding 139 | 2. Qdrant performs similarity search 140 | 3. Results include both entities and relations 141 | 4. Results are ranked by semantic similarity 142 | 143 | ## Example Usage 144 | 145 | ```typescript 146 | // Create entities 147 | await client.callTool("create_entities", { 148 | entities: [{ 149 | name: "Project", 150 | entityType: "Task", 151 | observations: ["A new development project"] 152 | }] 153 | }); 154 | 155 | // Search similar concepts 156 | const results = await client.callTool("search_similar", { 157 | query: "development tasks", 158 | limit: 5 159 | }); 160 | ``` 161 | 162 | ## HTTPS and Reverse Proxy Configuration 163 | 164 | The server supports connecting to Qdrant through HTTPS and reverse proxies. This is particularly useful when: 165 | - Running Qdrant behind a reverse proxy like Nginx or Apache 166 | - Using self-signed certificates 167 | - Requiring custom SSL/TLS configurations 168 | 169 | ### Setting up with a Reverse Proxy 170 | 171 | 1. Configure your reverse proxy (example using Nginx): 172 | ```nginx 173 | server { 174 | listen 443 ssl; 175 | server_name qdrant.yourdomain.com; 176 | 177 | ssl_certificate /path/to/cert.pem; 178 | ssl_certificate_key /path/to/key.pem; 179 | 180 | location / { 181 | proxy_pass http://localhost:6333; 182 | proxy_set_header Host $host; 183 | proxy_set_header X-Real-IP $remote_addr; 184 | } 185 | } 186 | ``` 187 | 188 | 2. Update your environment variables: 189 | ```bash 190 | QDRANT_URL=https://qdrant.yourdomain.com 191 | ``` 192 | 193 | ### Security Considerations 194 | 195 | The server implements robust HTTPS handling with: 196 | - Custom SSL/TLS configuration 197 | - Proper certificate verification options 198 | - Connection pooling and keepalive 199 | - Automatic retry with exponential backoff 200 | - Configurable timeouts 201 | 202 | ### Troubleshooting HTTPS Connections 203 | 204 | If you experience connection issues: 205 | 206 | 1. Verify your certificates: 207 | ```bash 208 | openssl s_client -connect qdrant.yourdomain.com:443 209 | ``` 210 | 211 | 2. Test direct connectivity: 212 | ```bash 213 | curl -v https://qdrant.yourdomain.com/collections 214 | ``` 215 | 216 | 3. Check for any proxy settings: 217 | ```bash 218 | env | grep -i proxy 219 | ``` 220 | 221 | ## Contributing 222 | 223 | 1. Fork the repository 224 | 2. Create a feature branch 225 | 3. Make your changes 226 | 4. Submit a pull request 227 | 228 | ## License 229 | 230 | MIT -------------------------------------------------------------------------------- /persistence/qdrant.ts: -------------------------------------------------------------------------------- 1 | import { QdrantClient } from '@qdrant/js-client-rest'; 2 | import OpenAI from 'openai'; 3 | import https from 'https'; 4 | import type { OutgoingHttpHeaders, RequestOptions } from 'http'; 5 | import { Entity, Relation } from '../types.js'; 6 | import { QDRANT_URL, COLLECTION_NAME, OPENAI_API_KEY, QDRANT_API_KEY } from '../config.js'; 7 | 8 | // Custom fetch implementation using Node's HTTPS module 9 | async function customFetch(url: string, options: RequestInit = {}): Promise { 10 | return new Promise((resolve, reject) => { 11 | const urlObj = new URL(url); 12 | 13 | const headers: OutgoingHttpHeaders = { 14 | 'Accept': 'application/json', 15 | 'Content-Type': 'application/json' 16 | }; 17 | 18 | if (options.headers) { 19 | // Convert headers from RequestInit to OutgoingHttpHeaders 20 | Object.entries(options.headers).forEach(([key, value]) => { 21 | if (value) headers[key] = value.toString(); 22 | }); 23 | } 24 | 25 | const requestOptions: RequestOptions = { 26 | method: options.method || 'GET', 27 | hostname: urlObj.hostname, 28 | port: urlObj.port || urlObj.protocol === 'https:' ? 443 : 80, 29 | path: `${urlObj.pathname}${urlObj.search}`, 30 | headers, 31 | timeout: 60000, 32 | agent: new https.Agent({ 33 | rejectUnauthorized: false, 34 | keepAlive: true, 35 | timeout: 60000 36 | }) 37 | }; 38 | 39 | const req = https.request(requestOptions, (res) => { 40 | const chunks: Buffer[] = []; 41 | res.on('data', chunk => chunks.push(chunk)); 42 | res.on('end', () => { 43 | const body = Buffer.concat(chunks).toString(); 44 | const response = { 45 | ok: res.statusCode && res.statusCode >= 200 && res.statusCode < 300, 46 | status: res.statusCode || 500, 47 | statusText: res.statusMessage || '', 48 | headers: new Headers(Object.entries(res.headers).reduce((acc, [key, value]) => { 49 | if (key && value) acc[key] = Array.isArray(value) ? value.join(', ') : value; 50 | return acc; 51 | }, {} as Record)), 52 | json: async () => JSON.parse(body), 53 | text: async () => body 54 | } as Response; 55 | resolve(response); 56 | }); 57 | }); 58 | 59 | req.on('error', reject); 60 | req.on('timeout', () => { 61 | req.destroy(); 62 | reject(new Error('Request timed out')); 63 | }); 64 | 65 | if (options.body) { 66 | req.write(typeof options.body === 'string' ? options.body : JSON.stringify(options.body)); 67 | } 68 | req.end(); 69 | }); 70 | } 71 | 72 | // Override global fetch for the Qdrant client 73 | if (typeof globalThis !== 'undefined') { 74 | (globalThis as any).fetch = customFetch; 75 | } 76 | 77 | interface EntityPayload extends Entity { 78 | type: 'entity'; 79 | } 80 | 81 | interface RelationPayload extends Relation { 82 | type: 'relation'; 83 | } 84 | 85 | type QdrantPayload = EntityPayload | RelationPayload; 86 | 87 | function isEntity(payload: Record): payload is Entity { 88 | return ( 89 | typeof payload.name === 'string' && 90 | typeof payload.entityType === 'string' && 91 | Array.isArray(payload.observations) && 92 | payload.observations.every(obs => typeof obs === 'string') 93 | ); 94 | } 95 | 96 | function isRelation(payload: Record): payload is Relation { 97 | return ( 98 | typeof payload.from === 'string' && 99 | typeof payload.to === 'string' && 100 | typeof payload.relationType === 'string' 101 | ); 102 | } 103 | 104 | export class QdrantPersistence { 105 | private client: QdrantClient; 106 | private openai: OpenAI; 107 | private initialized: boolean = false; 108 | 109 | constructor() { 110 | // Validate QDRANT_URL format and protocol 111 | if (!QDRANT_URL.startsWith('http://') && !QDRANT_URL.startsWith('https://')) { 112 | throw new Error('QDRANT_URL must start with http:// or https://'); 113 | } 114 | 115 | 116 | this.client = new QdrantClient({ 117 | url: QDRANT_URL, 118 | timeout: 60000, 119 | apiKey: QDRANT_API_KEY, 120 | checkCompatibility: false 121 | }); 122 | 123 | this.openai = new OpenAI({ 124 | apiKey: OPENAI_API_KEY, 125 | }); 126 | } 127 | 128 | async connect(): Promise { 129 | if (this.initialized) return; 130 | 131 | 132 | // Add retry logic for initial connection with exponential backoff 133 | let retries = 3; 134 | let delay = 2000; // Start with 2 second delay 135 | 136 | while (retries > 0) { 137 | try { 138 | const collections = await this.client.getCollections(); 139 | this.initialized = true; 140 | break; 141 | } catch (error: unknown) { 142 | console.error('Connection attempt failed:', error instanceof Error ? error.message : error); 143 | console.error('Full error:', error); 144 | 145 | retries--; 146 | if (retries === 0) { 147 | const errorMessage = error instanceof Error ? error.message : 'Unknown error'; 148 | throw new Error(`Failed to connect to Qdrant after 3 attempts: ${errorMessage}`); 149 | } 150 | await new Promise(resolve => setTimeout(resolve, delay)); 151 | delay *= 2; // Exponential backoff 152 | } 153 | } 154 | } 155 | 156 | async initialize(): Promise { 157 | await this.connect(); 158 | 159 | try { 160 | await this.client.getCollection(COLLECTION_NAME); 161 | } catch { 162 | // Collection doesn't exist, create it 163 | try { 164 | await this.client.createCollection(COLLECTION_NAME, { 165 | vectors: { 166 | size: 1536, // OpenAI embedding dimension 167 | distance: 'Cosine' 168 | } 169 | }); 170 | } catch (error) { 171 | console.error('Error creating collection:', error); 172 | throw error; 173 | } 174 | } 175 | } 176 | 177 | private async generateEmbedding(text: string): Promise { 178 | const response = await this.openai.embeddings.create({ 179 | model: 'text-embedding-ada-002', 180 | input: text 181 | }); 182 | return response.data[0].embedding; 183 | } 184 | 185 | private async hashString(str: string): Promise { 186 | const encoder = new TextEncoder(); 187 | const data = encoder.encode(str); 188 | const hashBuffer = await crypto.subtle.digest('SHA-256', data); 189 | const hashArray = Array.from(new Uint8Array(hashBuffer)); 190 | return new DataView(new Uint8Array(hashArray.slice(0, 4)).buffer).getUint32(0); 191 | } 192 | 193 | async persistEntity(entity: Entity): Promise { 194 | await this.connect(); 195 | const text = `${entity.name} (${entity.entityType}): ${entity.observations.join('. ')}`; 196 | const vector = await this.generateEmbedding(text); 197 | const id = await this.hashString(entity.name); 198 | 199 | const payload: Record = { 200 | type: 'entity' as const, 201 | ...entity 202 | }; 203 | 204 | await this.client.upsert(COLLECTION_NAME, { 205 | points: [{ 206 | id, 207 | vector, 208 | payload 209 | }] 210 | }); 211 | } 212 | 213 | async persistRelation(relation: Relation): Promise { 214 | await this.connect(); 215 | const text = `${relation.from} ${relation.relationType} ${relation.to}`; 216 | const vector = await this.generateEmbedding(text); 217 | const id = await this.hashString(`${relation.from}-${relation.relationType}-${relation.to}`); 218 | 219 | const payload: Record = { 220 | type: 'relation' as const, 221 | ...relation 222 | }; 223 | 224 | await this.client.upsert(COLLECTION_NAME, { 225 | points: [{ 226 | id, 227 | vector, 228 | payload 229 | }] 230 | }); 231 | } 232 | 233 | async searchSimilar(query: string, limit: number = 10): Promise> { 234 | await this.connect(); 235 | const queryVector = await this.generateEmbedding(query); 236 | 237 | const results = await this.client.search(COLLECTION_NAME, { 238 | vector: queryVector, 239 | limit, 240 | with_payload: true 241 | }); 242 | 243 | const validResults: Array = []; 244 | 245 | for (const result of results) { 246 | const payload = result.payload as Record; 247 | 248 | if (payload.type === 'entity' && isEntity(payload)) { 249 | const { type, ...entity } = payload; 250 | validResults.push(entity as Entity); 251 | } else if (payload.type === 'relation' && isRelation(payload)) { 252 | const { type, ...relation } = payload; 253 | validResults.push(relation as Relation); 254 | } 255 | } 256 | 257 | return validResults; 258 | } 259 | 260 | async deleteEntity(entityName: string): Promise { 261 | await this.connect(); 262 | const id = await this.hashString(entityName); 263 | await this.client.delete(COLLECTION_NAME, { 264 | points: [id] 265 | }); 266 | } 267 | 268 | async deleteRelation(relation: Relation): Promise { 269 | await this.connect(); 270 | const id = await this.hashString(`${relation.from}-${relation.relationType}-${relation.to}`); 271 | await this.client.delete(COLLECTION_NAME, { 272 | points: [id] 273 | }); 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /src/persistence/qdrant.ts: -------------------------------------------------------------------------------- 1 | import { QdrantClient } from "@qdrant/js-client-rest"; 2 | import OpenAI from "openai"; 3 | import crypto from "crypto"; 4 | import { 5 | QDRANT_URL, 6 | COLLECTION_NAME, 7 | OPENAI_API_KEY, 8 | QDRANT_API_KEY 9 | } from "../config.js"; 10 | import { Entity, Relation } from "../types.js"; 11 | 12 | // Create custom Qdrant client that adds auth header 13 | class CustomQdrantClient extends QdrantClient { 14 | constructor(url: string) { 15 | const parsed = new URL(url); 16 | super({ 17 | url: `${parsed.protocol}//${parsed.hostname}`, 18 | port: parsed.port ? parseInt(parsed.port) : 6333, 19 | https: parsed.protocol === 'https:', 20 | apiKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3MiOiJtIn0.x6NrWBMMtPqcep5dNxOqjXT42sQhATAMdxEqVFDJKew', 21 | timeout: 60000, 22 | checkCompatibility: false 23 | }); 24 | } 25 | 26 | // Override request method to log requests 27 | async getCollections() { 28 | const result = await super.getCollections(); 29 | return result; 30 | } 31 | } 32 | 33 | interface EntityPayload extends Entity { 34 | type: "entity"; 35 | } 36 | 37 | interface QdrantCollectionConfig { 38 | params: { 39 | vectors: { 40 | size: number; 41 | distance: string; 42 | }; 43 | }; 44 | } 45 | 46 | interface QdrantCollectionInfo { 47 | config: QdrantCollectionConfig; 48 | } 49 | 50 | interface RelationPayload extends Relation { 51 | type: "relation"; 52 | } 53 | 54 | type Payload = EntityPayload | RelationPayload; 55 | 56 | function isEntity(payload: Payload): payload is EntityPayload { 57 | return ( 58 | payload.type === "entity" && 59 | typeof payload.name === "string" && 60 | typeof payload.entityType === "string" && 61 | Array.isArray(payload.observations) && 62 | payload.observations.every((obs: unknown) => typeof obs === "string") 63 | ); 64 | } 65 | 66 | function isRelation(payload: Payload): payload is RelationPayload { 67 | return ( 68 | payload.type === "relation" && 69 | typeof payload.from === "string" && 70 | typeof payload.to === "string" && 71 | typeof payload.relationType === "string" 72 | ); 73 | } 74 | 75 | export class QdrantPersistence { 76 | private client: CustomQdrantClient; 77 | private openai: OpenAI; 78 | private initialized: boolean = false; 79 | 80 | constructor() { 81 | if (!QDRANT_URL) { 82 | throw new Error("QDRANT_URL environment variable is required"); 83 | } 84 | 85 | // Validate QDRANT_URL format and protocol 86 | if ( 87 | !QDRANT_URL.startsWith("http://") && 88 | !QDRANT_URL.startsWith("https://") 89 | ) { 90 | throw new Error("QDRANT_URL must start with http:// or https://"); 91 | } 92 | 93 | this.client = new CustomQdrantClient(QDRANT_URL); 94 | 95 | this.openai = new OpenAI({ 96 | apiKey: OPENAI_API_KEY, 97 | }); 98 | } 99 | 100 | async connect() { 101 | if (this.initialized) return; 102 | 103 | // Add retry logic for initial connection with exponential backoff 104 | let retries = 3; 105 | let delay = 2000; // Start with 2 second delay 106 | 107 | while (retries > 0) { 108 | try { 109 | await this.client.getCollections(); 110 | this.initialized = true; 111 | break; 112 | } catch (error: unknown) { 113 | const message = 114 | error instanceof Error ? error.message : "Unknown Qdrant error"; 115 | console.error(`Connection attempt failed: ${message}`); 116 | console.error("Full error:", error); 117 | 118 | retries--; 119 | if (retries === 0) { 120 | throw new Error( 121 | `Failed to connect to Qdrant after multiple attempts: ${message}` 122 | ); 123 | } 124 | await new Promise((resolve) => setTimeout(resolve, delay)); 125 | delay *= 2; // Exponential backoff 126 | } 127 | } 128 | } 129 | 130 | async initialize() { 131 | await this.connect(); 132 | 133 | if (!COLLECTION_NAME) { 134 | throw new Error("COLLECTION_NAME environment variable is required"); 135 | } 136 | 137 | const requiredVectorSize = 1536; // OpenAI embedding dimension 138 | 139 | try { 140 | // Check if collection exists 141 | const collections = await this.client.getCollections(); 142 | const collection = collections.collections.find( 143 | (c) => c.name === COLLECTION_NAME 144 | ); 145 | 146 | if (!collection) { 147 | await this.client.createCollection(COLLECTION_NAME, { 148 | vectors: { 149 | size: requiredVectorSize, 150 | distance: "Cosine", 151 | }, 152 | }); 153 | return; 154 | } 155 | 156 | // Get collection info to check vector size 157 | const collectionInfo = (await this.client.getCollection( 158 | COLLECTION_NAME 159 | )) as QdrantCollectionInfo; 160 | const currentVectorSize = collectionInfo.config?.params?.vectors?.size; 161 | 162 | if (!currentVectorSize) { 163 | await this.recreateCollection(requiredVectorSize); 164 | return; 165 | } 166 | 167 | if (currentVectorSize !== requiredVectorSize) { 168 | await this.recreateCollection(requiredVectorSize); 169 | } 170 | } catch (error) { 171 | const message = error instanceof Error ? error.message : "Unknown Qdrant error"; 172 | console.error("Failed to initialize collection:", message); 173 | throw new Error( 174 | `Failed to initialize Qdrant collection. Please check server logs for details: ${message}` 175 | ); 176 | } 177 | } 178 | 179 | private async recreateCollection(vectorSize: number) { 180 | if (!COLLECTION_NAME) { 181 | throw new Error("COLLECTION_NAME environment variable is required in recreateCollection"); 182 | } 183 | 184 | try { 185 | await this.client.deleteCollection(COLLECTION_NAME); 186 | await this.client.createCollection(COLLECTION_NAME, { 187 | vectors: { 188 | size: vectorSize, 189 | distance: "Cosine", 190 | }, 191 | }); 192 | } catch (error) { 193 | const message = error instanceof Error ? error.message : "Unknown Qdrant error"; 194 | throw new Error(`Failed to recreate collection: ${message}`); 195 | } 196 | } 197 | 198 | private async generateEmbedding(text: string) { 199 | try { 200 | const response = await this.openai.embeddings.create({ 201 | model: "text-embedding-ada-002", 202 | input: text, 203 | }); 204 | return response.data[0].embedding; 205 | } catch (error) { 206 | const message = 207 | error instanceof Error ? error.message : "Unknown OpenAI error"; 208 | console.error("OpenAI embedding error:", message); 209 | throw new Error(`Failed to generate embeddings with OpenAI: ${message}`); 210 | } 211 | } 212 | 213 | private async hashString(str: string) { 214 | const hash = crypto.createHash("sha256"); 215 | hash.update(str); 216 | const buffer = hash.digest(); 217 | return buffer.readUInt32BE(0); 218 | } 219 | 220 | async persistEntity(entity: Entity) { 221 | await this.connect(); 222 | if (!COLLECTION_NAME) { 223 | throw new Error("COLLECTION_NAME environment variable is required"); 224 | } 225 | 226 | const text = `${entity.name} (${ 227 | entity.entityType 228 | }): ${entity.observations.join(". ")}`; 229 | const vector = await this.generateEmbedding(text); 230 | const id = await this.hashString(entity.name); 231 | 232 | const payload = { 233 | type: "entity", 234 | ...entity, 235 | }; 236 | 237 | await this.client.upsert(COLLECTION_NAME, { 238 | points: [ 239 | { 240 | id, 241 | vector, 242 | payload: payload as Record, 243 | }, 244 | ], 245 | }); 246 | } 247 | 248 | async persistRelation(relation: Relation) { 249 | await this.connect(); 250 | if (!COLLECTION_NAME) { 251 | throw new Error("COLLECTION_NAME environment variable is required"); 252 | } 253 | 254 | const text = `${relation.from} ${relation.relationType} ${relation.to}`; 255 | const vector = await this.generateEmbedding(text); 256 | const id = await this.hashString( 257 | `${relation.from}-${relation.relationType}-${relation.to}` 258 | ); 259 | 260 | const payload = { 261 | type: "relation", 262 | ...relation, 263 | }; 264 | 265 | await this.client.upsert(COLLECTION_NAME, { 266 | points: [ 267 | { 268 | id, 269 | vector, 270 | payload: payload as Record, 271 | }, 272 | ], 273 | }); 274 | } 275 | 276 | async searchSimilar(query: string, limit: number = 10) { 277 | await this.connect(); 278 | if (!COLLECTION_NAME) { 279 | throw new Error("COLLECTION_NAME environment variable is required"); 280 | } 281 | 282 | const queryVector = await this.generateEmbedding(query); 283 | 284 | const results = await this.client.search(COLLECTION_NAME, { 285 | vector: queryVector, 286 | limit, 287 | with_payload: true, 288 | }); 289 | 290 | const validResults: Array = []; 291 | 292 | for (const result of results) { 293 | if (!result.payload) continue; 294 | 295 | const payload = result.payload as unknown as Payload; 296 | 297 | if (isEntity(payload)) { 298 | const { type, ...entity } = payload; 299 | validResults.push(entity); 300 | } else if (isRelation(payload)) { 301 | const { type, ...relation } = payload; 302 | validResults.push(relation); 303 | } 304 | } 305 | 306 | return validResults; 307 | } 308 | 309 | async deleteEntity(entityName: string) { 310 | await this.connect(); 311 | if (!COLLECTION_NAME) { 312 | throw new Error("COLLECTION_NAME environment variable is required"); 313 | } 314 | 315 | const id = await this.hashString(entityName); 316 | await this.client.delete(COLLECTION_NAME, { 317 | points: [id], 318 | }); 319 | } 320 | 321 | async deleteRelation(relation: Relation) { 322 | await this.connect(); 323 | if (!COLLECTION_NAME) { 324 | throw new Error("COLLECTION_NAME environment variable is required"); 325 | } 326 | 327 | const id = await this.hashString( 328 | `${relation.from}-${relation.relationType}-${relation.to}` 329 | ); 330 | await this.client.delete(COLLECTION_NAME, { 331 | points: [id], 332 | }); 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import dotenv from 'dotenv'; 3 | dotenv.config(); 4 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 5 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 6 | import { 7 | CallToolRequestSchema, 8 | ListToolsRequestSchema, 9 | McpError, 10 | ErrorCode, 11 | } from "@modelcontextprotocol/sdk/types.js"; 12 | import { promises as fs } from 'fs'; 13 | import path from 'path'; 14 | import { fileURLToPath } from 'url'; 15 | import { QdrantPersistence } from './persistence/qdrant.js'; 16 | import { Entity, Relation, KnowledgeGraph } from './types.js'; 17 | import { 18 | validateCreateEntitiesRequest, 19 | validateCreateRelationsRequest, 20 | validateAddObservationsRequest, 21 | validateDeleteEntitiesRequest, 22 | validateDeleteObservationsRequest, 23 | validateDeleteRelationsRequest, 24 | validateSearchSimilarRequest, 25 | } from './validation.js'; 26 | 27 | // Define paths 28 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 29 | const MEMORY_FILE_PATH = path.join(__dirname, 'memory.json'); 30 | 31 | class KnowledgeGraphManager { 32 | private graph: KnowledgeGraph; 33 | private qdrant: QdrantPersistence; 34 | 35 | constructor() { 36 | this.graph = { entities: [], relations: [] }; 37 | this.qdrant = new QdrantPersistence(); 38 | } 39 | 40 | async initialize(): Promise { 41 | try { 42 | const data = await fs.readFile(MEMORY_FILE_PATH, 'utf-8'); 43 | const parsedData = JSON.parse(data); 44 | // Ensure entities have observations array 45 | this.graph = { 46 | entities: parsedData.entities.map((e: Entity) => ({ 47 | ...e, 48 | observations: e.observations || [] 49 | })), 50 | relations: parsedData.relations || [] 51 | }; 52 | } catch (error: unknown) { 53 | if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { 54 | // If file doesn't exist, use empty graph 55 | this.graph = { entities: [], relations: [] }; 56 | } else { 57 | // Re-throw unexpected errors 58 | throw new Error(`Failed to initialize graph: ${error instanceof Error ? error.message : String(error)}`); 59 | } 60 | } 61 | await this.qdrant.initialize(); 62 | } 63 | 64 | async save(): Promise { 65 | await fs.writeFile(MEMORY_FILE_PATH, JSON.stringify(this.graph, null, 2)); 66 | } 67 | 68 | async addEntities(entities: Entity[]): Promise { 69 | for (const entity of entities) { 70 | const existingIndex = this.graph.entities.findIndex((e: Entity) => e.name === entity.name); 71 | if (existingIndex !== -1) { 72 | this.graph.entities[existingIndex] = entity; 73 | } else { 74 | this.graph.entities.push(entity); 75 | } 76 | await this.qdrant.persistEntity(entity); 77 | } 78 | await this.save(); 79 | } 80 | 81 | async addRelations(relations: Relation[]): Promise { 82 | for (const relation of relations) { 83 | if (!this.graph.entities.some(e => e.name === relation.from)) { 84 | throw new Error(`Entity not found: ${relation.from}`); 85 | } 86 | if (!this.graph.entities.some(e => e.name === relation.to)) { 87 | throw new Error(`Entity not found: ${relation.to}`); 88 | } 89 | const existingIndex = this.graph.relations.findIndex( 90 | (r: Relation) => r.from === relation.from && r.to === relation.to && r.relationType === relation.relationType 91 | ); 92 | if (existingIndex !== -1) { 93 | this.graph.relations[existingIndex] = relation; 94 | } else { 95 | this.graph.relations.push(relation); 96 | } 97 | await this.qdrant.persistRelation(relation); 98 | } 99 | await this.save(); 100 | } 101 | 102 | async addObservations(entityName: string, observations: string[]): Promise { 103 | const entity = this.graph.entities.find((e: Entity) => e.name === entityName); 104 | if (!entity) { 105 | throw new Error(`Entity not found: ${entityName}`); 106 | } 107 | entity.observations.push(...observations); 108 | await this.qdrant.persistEntity(entity); 109 | await this.save(); 110 | } 111 | 112 | async deleteEntities(entityNames: string[]): Promise { 113 | for (const name of entityNames) { 114 | const index = this.graph.entities.findIndex((e: Entity) => e.name === name); 115 | if (index !== -1) { 116 | this.graph.entities.splice(index, 1); 117 | this.graph.relations = this.graph.relations.filter( 118 | (r: Relation) => r.from !== name && r.to !== name 119 | ); 120 | await this.qdrant.deleteEntity(name); 121 | } 122 | } 123 | await this.save(); 124 | } 125 | 126 | async deleteObservations(entityName: string, observations: string[]): Promise { 127 | const entity = this.graph.entities.find((e: Entity) => e.name === entityName); 128 | if (!entity) { 129 | throw new Error(`Entity not found: ${entityName}`); 130 | } 131 | entity.observations = entity.observations.filter((o: string) => !observations.includes(o)); 132 | await this.qdrant.persistEntity(entity); 133 | await this.save(); 134 | } 135 | 136 | async deleteRelations(relations: Relation[]): Promise { 137 | for (const relation of relations) { 138 | const index = this.graph.relations.findIndex( 139 | (r: Relation) => r.from === relation.from && r.to === relation.to && r.relationType === relation.relationType 140 | ); 141 | if (index !== -1) { 142 | this.graph.relations.splice(index, 1); 143 | await this.qdrant.deleteRelation(relation); 144 | } 145 | } 146 | await this.save(); 147 | } 148 | 149 | getGraph(): KnowledgeGraph { 150 | return this.graph; 151 | } 152 | 153 | async searchSimilar(query: string, limit: number = 10): Promise> { 154 | // Ensure limit is a positive number 155 | const validLimit = Math.max(1, Math.min(limit, 100)); // Cap at 100 results 156 | return await this.qdrant.searchSimilar(query, validLimit); 157 | } 158 | } 159 | 160 | interface CallToolRequest { 161 | params: { 162 | name: string; 163 | arguments?: Record; 164 | }; 165 | } 166 | 167 | class MemoryServer { 168 | private server: Server; 169 | private graphManager: KnowledgeGraphManager; 170 | 171 | constructor() { 172 | this.server = new Server( 173 | { 174 | name: "memory", 175 | version: "0.6.2", 176 | }, 177 | { 178 | capabilities: { 179 | tools: {}, 180 | }, 181 | } 182 | ); 183 | 184 | this.graphManager = new KnowledgeGraphManager(); 185 | this.setupToolHandlers(); 186 | } 187 | 188 | private setupToolHandlers() { 189 | this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ 190 | tools: [ 191 | { 192 | name: "create_entities", 193 | description: "Create multiple new entities in the knowledge graph", 194 | inputSchema: { 195 | type: "object", 196 | properties: { 197 | entities: { 198 | type: "array", 199 | items: { 200 | type: "object", 201 | properties: { 202 | name: { type: "string" }, 203 | entityType: { type: "string" }, 204 | observations: { 205 | type: "array", 206 | items: { type: "string" } 207 | } 208 | }, 209 | required: ["name", "entityType", "observations"] 210 | } 211 | } 212 | }, 213 | required: ["entities"] 214 | } 215 | }, 216 | { 217 | name: "create_relations", 218 | description: "Create multiple new relations between entities", 219 | inputSchema: { 220 | type: "object", 221 | properties: { 222 | relations: { 223 | type: "array", 224 | items: { 225 | type: "object", 226 | properties: { 227 | from: { type: "string" }, 228 | to: { type: "string" }, 229 | relationType: { type: "string" } 230 | }, 231 | required: ["from", "to", "relationType"] 232 | } 233 | } 234 | }, 235 | required: ["relations"] 236 | } 237 | }, 238 | { 239 | name: "add_observations", 240 | description: "Add new observations to existing entities", 241 | inputSchema: { 242 | type: "object", 243 | properties: { 244 | observations: { 245 | type: "array", 246 | items: { 247 | type: "object", 248 | properties: { 249 | entityName: { type: "string" }, 250 | contents: { 251 | type: "array", 252 | items: { type: "string" } 253 | } 254 | }, 255 | required: ["entityName", "contents"] 256 | } 257 | } 258 | }, 259 | required: ["observations"] 260 | } 261 | }, 262 | { 263 | name: "delete_entities", 264 | description: "Delete multiple entities and their relations", 265 | inputSchema: { 266 | type: "object", 267 | properties: { 268 | entityNames: { 269 | type: "array", 270 | items: { type: "string" } 271 | } 272 | }, 273 | required: ["entityNames"] 274 | } 275 | }, 276 | { 277 | name: "delete_observations", 278 | description: "Delete specific observations from entities", 279 | inputSchema: { 280 | type: "object", 281 | properties: { 282 | deletions: { 283 | type: "array", 284 | items: { 285 | type: "object", 286 | properties: { 287 | entityName: { type: "string" }, 288 | observations: { 289 | type: "array", 290 | items: { type: "string" } 291 | } 292 | }, 293 | required: ["entityName", "observations"] 294 | } 295 | } 296 | }, 297 | required: ["deletions"] 298 | } 299 | }, 300 | { 301 | name: "delete_relations", 302 | description: "Delete multiple relations", 303 | inputSchema: { 304 | type: "object", 305 | properties: { 306 | relations: { 307 | type: "array", 308 | items: { 309 | type: "object", 310 | properties: { 311 | from: { type: "string" }, 312 | to: { type: "string" }, 313 | relationType: { type: "string" } 314 | }, 315 | required: ["from", "to", "relationType"] 316 | } 317 | } 318 | }, 319 | required: ["relations"] 320 | } 321 | }, 322 | { 323 | name: "read_graph", 324 | description: "Read the entire knowledge graph", 325 | inputSchema: { 326 | type: "object", 327 | properties: {} 328 | } 329 | }, 330 | { 331 | name: "search_similar", 332 | description: "Search for similar entities and relations using semantic search", 333 | inputSchema: { 334 | type: "object", 335 | properties: { 336 | query: { type: "string" }, 337 | limit: { 338 | type: "number", 339 | default: 10 340 | } 341 | }, 342 | required: ["query"] 343 | } 344 | } 345 | ], 346 | })); 347 | 348 | this.server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) => { 349 | if (!request.params.arguments) { 350 | throw new McpError( 351 | ErrorCode.InvalidParams, 352 | "Missing arguments" 353 | ); 354 | } 355 | 356 | try { 357 | switch (request.params.name) { 358 | case "create_entities": { 359 | const args = validateCreateEntitiesRequest(request.params.arguments); 360 | await this.graphManager.addEntities(args.entities); 361 | return { 362 | content: [{ type: "text", text: "Entities created successfully" }], 363 | }; 364 | } 365 | 366 | case "create_relations": { 367 | const args = validateCreateRelationsRequest(request.params.arguments); 368 | await this.graphManager.addRelations(args.relations); 369 | return { 370 | content: [{ type: "text", text: "Relations created successfully" }], 371 | }; 372 | } 373 | 374 | case "add_observations": { 375 | const args = validateAddObservationsRequest(request.params.arguments); 376 | for (const obs of args.observations) { 377 | await this.graphManager.addObservations(obs.entityName, obs.contents); 378 | } 379 | return { 380 | content: [{ type: "text", text: "Observations added successfully" }], 381 | }; 382 | } 383 | 384 | case "delete_entities": { 385 | const args = validateDeleteEntitiesRequest(request.params.arguments); 386 | await this.graphManager.deleteEntities(args.entityNames); 387 | return { 388 | content: [{ type: "text", text: "Entities deleted successfully" }], 389 | }; 390 | } 391 | 392 | case "delete_observations": { 393 | const args = validateDeleteObservationsRequest(request.params.arguments); 394 | for (const del of args.deletions) { 395 | await this.graphManager.deleteObservations(del.entityName, del.observations); 396 | } 397 | return { 398 | content: [{ type: "text", text: "Observations deleted successfully" }], 399 | }; 400 | } 401 | 402 | case "delete_relations": { 403 | const args = validateDeleteRelationsRequest(request.params.arguments); 404 | await this.graphManager.deleteRelations(args.relations); 405 | return { 406 | content: [{ type: "text", text: "Relations deleted successfully" }], 407 | }; 408 | } 409 | 410 | case "read_graph": 411 | return { 412 | content: [ 413 | { 414 | type: "text", 415 | text: JSON.stringify(this.graphManager.getGraph(), null, 2), 416 | }, 417 | ], 418 | }; 419 | 420 | case "search_similar": { 421 | const args = validateSearchSimilarRequest(request.params.arguments); 422 | const results = await this.graphManager.searchSimilar( 423 | args.query, 424 | args.limit 425 | ); 426 | return { 427 | content: [ 428 | { 429 | type: "text", 430 | text: JSON.stringify(results, null, 2), 431 | }, 432 | ], 433 | }; 434 | } 435 | 436 | default: 437 | throw new McpError( 438 | ErrorCode.MethodNotFound, 439 | `Unknown tool: ${request.params.name}` 440 | ); 441 | } 442 | } catch (error) { 443 | throw new McpError( 444 | ErrorCode.InternalError, 445 | error instanceof Error ? error.message : String(error) 446 | ); 447 | } 448 | }); 449 | } 450 | 451 | async run() { 452 | try { 453 | await this.graphManager.initialize(); 454 | const transport = new StdioServerTransport(); 455 | await this.server.connect(transport); 456 | console.error("Memory MCP server running on stdio"); 457 | } catch (error) { 458 | console.error("Fatal error running server:", error); 459 | process.exit(1); 460 | } 461 | } 462 | } 463 | 464 | // Server startup 465 | const server = new MemoryServer(); 466 | server.run().catch((error) => { 467 | console.error("Fatal error running server:", error); 468 | process.exit(1); 469 | }); --------------------------------------------------------------------------------