Added memory module
This commit is contained in:
24
mcpServer/modules/memory/Dockerfile
Normal file
24
mcpServer/modules/memory/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
FROM node:22.12-alpine AS builder
|
||||
|
||||
COPY . /app
|
||||
COPY tsconfig.json /tsconfig.json
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm install
|
||||
|
||||
RUN npm ci --ignore-scripts --omit-dev
|
||||
|
||||
FROM node:22-alpine AS release
|
||||
|
||||
COPY --from=builder /app/dist /app/dist
|
||||
COPY --from=builder /app/package.json /app/package.json
|
||||
COPY --from=builder /app/package-lock.json /app/package-lock.json
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm ci --ignore-scripts --omit-dev
|
||||
|
||||
ENTRYPOINT ["node", "dist/index.js"]
|
||||
283
mcpServer/modules/memory/README.md
Normal file
283
mcpServer/modules/memory/README.md
Normal file
@@ -0,0 +1,283 @@
|
||||
# Knowledge Graph Memory Server
|
||||
|
||||
A basic implementation of persistent memory using a local knowledge graph. This lets Claude remember information about the user across chats.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Entities
|
||||
Entities are the primary nodes in the knowledge graph. Each entity has:
|
||||
- A unique name (identifier)
|
||||
- An entity type (e.g., "person", "organization", "event")
|
||||
- A list of observations
|
||||
|
||||
Example:
|
||||
```json
|
||||
{
|
||||
"name": "John_Smith",
|
||||
"entityType": "person",
|
||||
"observations": ["Speaks fluent Spanish"]
|
||||
}
|
||||
```
|
||||
|
||||
### Relations
|
||||
Relations define directed connections between entities. They are always stored in active voice and describe how entities interact or relate to each other.
|
||||
|
||||
Example:
|
||||
```json
|
||||
{
|
||||
"from": "John_Smith",
|
||||
"to": "Anthropic",
|
||||
"relationType": "works_at"
|
||||
}
|
||||
```
|
||||
### Observations
|
||||
Observations are discrete pieces of information about an entity. They are:
|
||||
|
||||
- Stored as strings
|
||||
- Attached to specific entities
|
||||
- Can be added or removed independently
|
||||
- Should be atomic (one fact per observation)
|
||||
|
||||
Example:
|
||||
```json
|
||||
{
|
||||
"entityName": "John_Smith",
|
||||
"observations": [
|
||||
"Speaks fluent Spanish",
|
||||
"Graduated in 2019",
|
||||
"Prefers morning meetings"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### Tools
|
||||
- **create_entities**
|
||||
- Create multiple new entities in the knowledge graph
|
||||
- Input: `entities` (array of objects)
|
||||
- Each object contains:
|
||||
- `name` (string): Entity identifier
|
||||
- `entityType` (string): Type classification
|
||||
- `observations` (string[]): Associated observations
|
||||
- Ignores entities with existing names
|
||||
|
||||
- **create_relations**
|
||||
- Create multiple new relations between entities
|
||||
- Input: `relations` (array of objects)
|
||||
- Each object contains:
|
||||
- `from` (string): Source entity name
|
||||
- `to` (string): Target entity name
|
||||
- `relationType` (string): Relationship type in active voice
|
||||
- Skips duplicate relations
|
||||
|
||||
- **add_observations**
|
||||
- Add new observations to existing entities
|
||||
- Input: `observations` (array of objects)
|
||||
- Each object contains:
|
||||
- `entityName` (string): Target entity
|
||||
- `contents` (string[]): New observations to add
|
||||
- Returns added observations per entity
|
||||
- Fails if entity doesn't exist
|
||||
|
||||
- **delete_entities**
|
||||
- Remove entities and their relations
|
||||
- Input: `entityNames` (string[])
|
||||
- Cascading deletion of associated relations
|
||||
- Silent operation if entity doesn't exist
|
||||
|
||||
- **delete_observations**
|
||||
- Remove specific observations from entities
|
||||
- Input: `deletions` (array of objects)
|
||||
- Each object contains:
|
||||
- `entityName` (string): Target entity
|
||||
- `observations` (string[]): Observations to remove
|
||||
- Silent operation if observation doesn't exist
|
||||
|
||||
- **delete_relations**
|
||||
- Remove specific relations from the graph
|
||||
- Input: `relations` (array of objects)
|
||||
- Each object contains:
|
||||
- `from` (string): Source entity name
|
||||
- `to` (string): Target entity name
|
||||
- `relationType` (string): Relationship type
|
||||
- Silent operation if relation doesn't exist
|
||||
|
||||
- **read_graph**
|
||||
- Read the entire knowledge graph
|
||||
- No input required
|
||||
- Returns complete graph structure with all entities and relations
|
||||
|
||||
- **search_nodes**
|
||||
- Search for nodes based on query
|
||||
- Input: `query` (string)
|
||||
- Searches across:
|
||||
- Entity names
|
||||
- Entity types
|
||||
- Observation content
|
||||
- Returns matching entities and their relations
|
||||
|
||||
- **open_nodes**
|
||||
- Retrieve specific nodes by name
|
||||
- Input: `names` (string[])
|
||||
- Returns:
|
||||
- Requested entities
|
||||
- Relations between requested entities
|
||||
- Silently skips non-existent nodes
|
||||
|
||||
# Usage with Claude Desktop
|
||||
|
||||
### Setup
|
||||
|
||||
Add this to your claude_desktop_config.json:
|
||||
|
||||
#### Docker
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"memory": {
|
||||
"command": "docker",
|
||||
"args": ["run", "-i", "-v", "claude-memory:/app/dist", "--rm", "mcp/memory"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### NPX
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"memory": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-memory"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### NPX with custom setting
|
||||
|
||||
The server can be configured using the following environment variables:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"memory": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-memory"
|
||||
],
|
||||
"env": {
|
||||
"MEMORY_FILE_PATH": "/path/to/custom/memory.jsonl"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `MEMORY_FILE_PATH`: Path to the memory storage JSONL file (default: `memory.jsonl` in the server directory)
|
||||
|
||||
# VS Code Installation Instructions
|
||||
|
||||
For quick installation, use one of the one-click installation buttons below:
|
||||
|
||||
[](https://insiders.vscode.dev/redirect/mcp/install?name=memory&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40modelcontextprotocol%2Fserver-memory%22%5D%7D) [](https://insiders.vscode.dev/redirect/mcp/install?name=memory&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40modelcontextprotocol%2Fserver-memory%22%5D%7D&quality=insiders)
|
||||
|
||||
[](https://insiders.vscode.dev/redirect/mcp/install?name=memory&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22-v%22%2C%22claude-memory%3A%2Fapp%2Fdist%22%2C%22--rm%22%2C%22mcp%2Fmemory%22%5D%7D) [](https://insiders.vscode.dev/redirect/mcp/install?name=memory&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22-v%22%2C%22claude-memory%3A%2Fapp%2Fdist%22%2C%22--rm%22%2C%22mcp%2Fmemory%22%5D%7D&quality=insiders)
|
||||
|
||||
For manual installation, you can configure the MCP server using one of these methods:
|
||||
|
||||
**Method 1: User Configuration (Recommended)**
|
||||
Add the configuration to your user-level MCP configuration file. Open the Command Palette (`Ctrl + Shift + P`) and run `MCP: Open User Configuration`. This will open your user `mcp.json` file where you can add the server configuration.
|
||||
|
||||
**Method 2: Workspace Configuration**
|
||||
Alternatively, you can add the configuration to a file called `.vscode/mcp.json` in your workspace. This will allow you to share the configuration with others.
|
||||
|
||||
> For more details about MCP configuration in VS Code, see the [official VS Code MCP documentation](https://code.visualstudio.com/docs/copilot/customization/mcp-servers).
|
||||
|
||||
#### NPX
|
||||
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"memory": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-memory"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Docker
|
||||
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"memory": {
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"run",
|
||||
"-i",
|
||||
"-v",
|
||||
"claude-memory:/app/dist",
|
||||
"--rm",
|
||||
"mcp/memory"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### System Prompt
|
||||
|
||||
The prompt for utilizing memory depends on the use case. Changing the prompt will help the model determine the frequency and types of memories created.
|
||||
|
||||
Here is an example prompt for chat personalization. You could use this prompt in the "Custom Instructions" field of a [Claude.ai Project](https://www.anthropic.com/news/projects).
|
||||
|
||||
```
|
||||
Follow these steps for each interaction:
|
||||
|
||||
1. User Identification:
|
||||
- You should assume that you are interacting with default_user
|
||||
- If you have not identified default_user, proactively try to do so.
|
||||
|
||||
2. Memory Retrieval:
|
||||
- Always begin your chat by saying only "Remembering..." and retrieve all relevant information from your knowledge graph
|
||||
- Always refer to your knowledge graph as your "memory"
|
||||
|
||||
3. Memory
|
||||
- While conversing with the user, be attentive to any new information that falls into these categories:
|
||||
a) Basic Identity (age, gender, location, job title, education level, etc.)
|
||||
b) Behaviors (interests, habits, etc.)
|
||||
c) Preferences (communication style, preferred language, etc.)
|
||||
d) Goals (goals, targets, aspirations, etc.)
|
||||
e) Relationships (personal and professional relationships up to 3 degrees of separation)
|
||||
|
||||
4. Memory Update:
|
||||
- If any new information was gathered during the interaction, update your memory as follows:
|
||||
a) Create entities for recurring organizations, people, and significant events
|
||||
b) Connect them to the current entities using relations
|
||||
c) Store facts about them as observations
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
Docker:
|
||||
|
||||
```sh
|
||||
docker build -t mcp/memory -f src/memory/Dockerfile .
|
||||
```
|
||||
|
||||
For Awareness: a prior mcp/memory volume contains an index.js file that could be overwritten by the new container. If you are using a docker volume for storage, delete the old docker volume's `index.js` file before starting the new container.
|
||||
|
||||
## License
|
||||
|
||||
This MCP server is licensed under the MIT License. This means you are free to use, modify, and distribute the software, subject to the terms and conditions of the MIT License. For more details, please see the LICENSE file in the project repository.
|
||||
156
mcpServer/modules/memory/__tests__/file-path.test.ts
Normal file
156
mcpServer/modules/memory/__tests__/file-path.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { ensureMemoryFilePath, defaultMemoryPath } from '../index.js';
|
||||
|
||||
describe('ensureMemoryFilePath', () => {
|
||||
const testDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const oldMemoryPath = path.join(testDir, '..', 'memory.json');
|
||||
const newMemoryPath = path.join(testDir, '..', 'memory.jsonl');
|
||||
|
||||
let originalEnv: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
// Save original environment variable
|
||||
originalEnv = process.env.MEMORY_FILE_PATH;
|
||||
// Delete environment variable
|
||||
delete process.env.MEMORY_FILE_PATH;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Restore original environment variable
|
||||
if (originalEnv !== undefined) {
|
||||
process.env.MEMORY_FILE_PATH = originalEnv;
|
||||
} else {
|
||||
delete process.env.MEMORY_FILE_PATH;
|
||||
}
|
||||
|
||||
// Clean up test files
|
||||
try {
|
||||
await fs.unlink(oldMemoryPath);
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
try {
|
||||
await fs.unlink(newMemoryPath);
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
});
|
||||
|
||||
describe('with MEMORY_FILE_PATH environment variable', () => {
|
||||
it('should return absolute path when MEMORY_FILE_PATH is absolute', async () => {
|
||||
const absolutePath = '/tmp/custom-memory.jsonl';
|
||||
process.env.MEMORY_FILE_PATH = absolutePath;
|
||||
|
||||
const result = await ensureMemoryFilePath();
|
||||
|
||||
expect(result).toBe(absolutePath);
|
||||
});
|
||||
|
||||
it('should convert relative path to absolute when MEMORY_FILE_PATH is relative', async () => {
|
||||
const relativePath = 'custom-memory.jsonl';
|
||||
process.env.MEMORY_FILE_PATH = relativePath;
|
||||
|
||||
const result = await ensureMemoryFilePath();
|
||||
|
||||
expect(path.isAbsolute(result)).toBe(true);
|
||||
expect(result).toContain('custom-memory.jsonl');
|
||||
});
|
||||
|
||||
it('should handle Windows absolute paths', async () => {
|
||||
const windowsPath = 'C:\\temp\\memory.jsonl';
|
||||
process.env.MEMORY_FILE_PATH = windowsPath;
|
||||
|
||||
const result = await ensureMemoryFilePath();
|
||||
|
||||
// On Windows, should return as-is; on Unix, will be treated as relative
|
||||
if (process.platform === 'win32') {
|
||||
expect(result).toBe(windowsPath);
|
||||
} else {
|
||||
expect(path.isAbsolute(result)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('without MEMORY_FILE_PATH environment variable', () => {
|
||||
it('should return default path when no files exist', async () => {
|
||||
const result = await ensureMemoryFilePath();
|
||||
|
||||
expect(result).toBe(defaultMemoryPath);
|
||||
});
|
||||
|
||||
it('should migrate from memory.json to memory.jsonl when only old file exists', async () => {
|
||||
// Create old memory.json file
|
||||
await fs.writeFile(oldMemoryPath, '{"test":"data"}');
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const result = await ensureMemoryFilePath();
|
||||
|
||||
expect(result).toBe(defaultMemoryPath);
|
||||
|
||||
// Verify migration happened
|
||||
const newFileExists = await fs.access(newMemoryPath).then(() => true).catch(() => false);
|
||||
const oldFileExists = await fs.access(oldMemoryPath).then(() => true).catch(() => false);
|
||||
|
||||
expect(newFileExists).toBe(true);
|
||||
expect(oldFileExists).toBe(false);
|
||||
|
||||
// Verify console messages
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('DETECTED: Found legacy memory.json file')
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('COMPLETED: Successfully migrated')
|
||||
);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should use new file when both old and new files exist', async () => {
|
||||
// Create both files
|
||||
await fs.writeFile(oldMemoryPath, '{"old":"data"}');
|
||||
await fs.writeFile(newMemoryPath, '{"new":"data"}');
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const result = await ensureMemoryFilePath();
|
||||
|
||||
expect(result).toBe(defaultMemoryPath);
|
||||
|
||||
// Verify no migration happened (both files should still exist)
|
||||
const newFileExists = await fs.access(newMemoryPath).then(() => true).catch(() => false);
|
||||
const oldFileExists = await fs.access(oldMemoryPath).then(() => true).catch(() => false);
|
||||
|
||||
expect(newFileExists).toBe(true);
|
||||
expect(oldFileExists).toBe(true);
|
||||
|
||||
// Verify no console messages about migration
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should preserve file content during migration', async () => {
|
||||
const testContent = '{"entities": [{"name": "test", "type": "person"}]}';
|
||||
await fs.writeFile(oldMemoryPath, testContent);
|
||||
|
||||
await ensureMemoryFilePath();
|
||||
|
||||
const migratedContent = await fs.readFile(newMemoryPath, 'utf-8');
|
||||
expect(migratedContent).toBe(testContent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultMemoryPath', () => {
|
||||
it('should end with memory.jsonl', () => {
|
||||
expect(defaultMemoryPath).toMatch(/memory\.jsonl$/);
|
||||
});
|
||||
|
||||
it('should be an absolute path', () => {
|
||||
expect(path.isAbsolute(defaultMemoryPath)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
483
mcpServer/modules/memory/__tests__/knowledge-graph.test.ts
Normal file
483
mcpServer/modules/memory/__tests__/knowledge-graph.test.ts
Normal file
@@ -0,0 +1,483 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { KnowledgeGraphManager, Entity, Relation, KnowledgeGraph } from '../index.js';
|
||||
|
||||
describe('KnowledgeGraphManager', () => {
|
||||
let manager: KnowledgeGraphManager;
|
||||
let testFilePath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a temporary test file path
|
||||
testFilePath = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
`test-memory-${Date.now()}.jsonl`
|
||||
);
|
||||
manager = new KnowledgeGraphManager(testFilePath);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up test file
|
||||
try {
|
||||
await fs.unlink(testFilePath);
|
||||
} catch (error) {
|
||||
// Ignore errors if file doesn't exist
|
||||
}
|
||||
});
|
||||
|
||||
describe('createEntities', () => {
|
||||
it('should create new entities', async () => {
|
||||
const entities: Entity[] = [
|
||||
{ name: 'Alice', entityType: 'person', observations: ['works at Acme Corp'] },
|
||||
{ name: 'Bob', entityType: 'person', observations: ['likes programming'] },
|
||||
];
|
||||
|
||||
const newEntities = await manager.createEntities(entities);
|
||||
expect(newEntities).toHaveLength(2);
|
||||
expect(newEntities).toEqual(entities);
|
||||
|
||||
const graph = await manager.readGraph();
|
||||
expect(graph.entities).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should not create duplicate entities', async () => {
|
||||
const entities: Entity[] = [
|
||||
{ name: 'Alice', entityType: 'person', observations: ['works at Acme Corp'] },
|
||||
];
|
||||
|
||||
await manager.createEntities(entities);
|
||||
const newEntities = await manager.createEntities(entities);
|
||||
|
||||
expect(newEntities).toHaveLength(0);
|
||||
|
||||
const graph = await manager.readGraph();
|
||||
expect(graph.entities).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle empty entity arrays', async () => {
|
||||
const newEntities = await manager.createEntities([]);
|
||||
expect(newEntities).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRelations', () => {
|
||||
it('should create new relations', async () => {
|
||||
await manager.createEntities([
|
||||
{ name: 'Alice', entityType: 'person', observations: [] },
|
||||
{ name: 'Bob', entityType: 'person', observations: [] },
|
||||
]);
|
||||
|
||||
const relations: Relation[] = [
|
||||
{ from: 'Alice', to: 'Bob', relationType: 'knows' },
|
||||
];
|
||||
|
||||
const newRelations = await manager.createRelations(relations);
|
||||
expect(newRelations).toHaveLength(1);
|
||||
expect(newRelations).toEqual(relations);
|
||||
|
||||
const graph = await manager.readGraph();
|
||||
expect(graph.relations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should not create duplicate relations', async () => {
|
||||
await manager.createEntities([
|
||||
{ name: 'Alice', entityType: 'person', observations: [] },
|
||||
{ name: 'Bob', entityType: 'person', observations: [] },
|
||||
]);
|
||||
|
||||
const relations: Relation[] = [
|
||||
{ from: 'Alice', to: 'Bob', relationType: 'knows' },
|
||||
];
|
||||
|
||||
await manager.createRelations(relations);
|
||||
const newRelations = await manager.createRelations(relations);
|
||||
|
||||
expect(newRelations).toHaveLength(0);
|
||||
|
||||
const graph = await manager.readGraph();
|
||||
expect(graph.relations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle empty relation arrays', async () => {
|
||||
const newRelations = await manager.createRelations([]);
|
||||
expect(newRelations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addObservations', () => {
|
||||
it('should add observations to existing entities', async () => {
|
||||
await manager.createEntities([
|
||||
{ name: 'Alice', entityType: 'person', observations: ['works at Acme Corp'] },
|
||||
]);
|
||||
|
||||
const results = await manager.addObservations([
|
||||
{ entityName: 'Alice', contents: ['likes coffee', 'has a dog'] },
|
||||
]);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].entityName).toBe('Alice');
|
||||
expect(results[0].addedObservations).toHaveLength(2);
|
||||
|
||||
const graph = await manager.readGraph();
|
||||
const alice = graph.entities.find(e => e.name === 'Alice');
|
||||
expect(alice?.observations).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should not add duplicate observations', async () => {
|
||||
await manager.createEntities([
|
||||
{ name: 'Alice', entityType: 'person', observations: ['works at Acme Corp'] },
|
||||
]);
|
||||
|
||||
await manager.addObservations([
|
||||
{ entityName: 'Alice', contents: ['likes coffee'] },
|
||||
]);
|
||||
|
||||
const results = await manager.addObservations([
|
||||
{ entityName: 'Alice', contents: ['likes coffee', 'has a dog'] },
|
||||
]);
|
||||
|
||||
expect(results[0].addedObservations).toHaveLength(1);
|
||||
expect(results[0].addedObservations).toContain('has a dog');
|
||||
|
||||
const graph = await manager.readGraph();
|
||||
const alice = graph.entities.find(e => e.name === 'Alice');
|
||||
expect(alice?.observations).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should throw error for non-existent entity', async () => {
|
||||
await expect(
|
||||
manager.addObservations([
|
||||
{ entityName: 'NonExistent', contents: ['some observation'] },
|
||||
])
|
||||
).rejects.toThrow('Entity with name NonExistent not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteEntities', () => {
|
||||
it('should delete entities', async () => {
|
||||
await manager.createEntities([
|
||||
{ name: 'Alice', entityType: 'person', observations: [] },
|
||||
{ name: 'Bob', entityType: 'person', observations: [] },
|
||||
]);
|
||||
|
||||
await manager.deleteEntities(['Alice']);
|
||||
|
||||
const graph = await manager.readGraph();
|
||||
expect(graph.entities).toHaveLength(1);
|
||||
expect(graph.entities[0].name).toBe('Bob');
|
||||
});
|
||||
|
||||
it('should cascade delete relations when deleting entities', async () => {
|
||||
await manager.createEntities([
|
||||
{ name: 'Alice', entityType: 'person', observations: [] },
|
||||
{ name: 'Bob', entityType: 'person', observations: [] },
|
||||
{ name: 'Charlie', entityType: 'person', observations: [] },
|
||||
]);
|
||||
|
||||
await manager.createRelations([
|
||||
{ from: 'Alice', to: 'Bob', relationType: 'knows' },
|
||||
{ from: 'Bob', to: 'Charlie', relationType: 'knows' },
|
||||
]);
|
||||
|
||||
await manager.deleteEntities(['Bob']);
|
||||
|
||||
const graph = await manager.readGraph();
|
||||
expect(graph.entities).toHaveLength(2);
|
||||
expect(graph.relations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle deleting non-existent entities', async () => {
|
||||
await manager.deleteEntities(['NonExistent']);
|
||||
const graph = await manager.readGraph();
|
||||
expect(graph.entities).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteObservations', () => {
|
||||
it('should delete observations from entities', async () => {
|
||||
await manager.createEntities([
|
||||
{ name: 'Alice', entityType: 'person', observations: ['works at Acme Corp', 'likes coffee'] },
|
||||
]);
|
||||
|
||||
await manager.deleteObservations([
|
||||
{ entityName: 'Alice', observations: ['likes coffee'] },
|
||||
]);
|
||||
|
||||
const graph = await manager.readGraph();
|
||||
const alice = graph.entities.find(e => e.name === 'Alice');
|
||||
expect(alice?.observations).toHaveLength(1);
|
||||
expect(alice?.observations).toContain('works at Acme Corp');
|
||||
});
|
||||
|
||||
it('should handle deleting from non-existent entities', async () => {
|
||||
await manager.deleteObservations([
|
||||
{ entityName: 'NonExistent', observations: ['some observation'] },
|
||||
]);
|
||||
// Should not throw error
|
||||
const graph = await manager.readGraph();
|
||||
expect(graph.entities).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteRelations', () => {
|
||||
it('should delete specific relations', async () => {
|
||||
await manager.createEntities([
|
||||
{ name: 'Alice', entityType: 'person', observations: [] },
|
||||
{ name: 'Bob', entityType: 'person', observations: [] },
|
||||
]);
|
||||
|
||||
await manager.createRelations([
|
||||
{ from: 'Alice', to: 'Bob', relationType: 'knows' },
|
||||
{ from: 'Alice', to: 'Bob', relationType: 'works_with' },
|
||||
]);
|
||||
|
||||
await manager.deleteRelations([
|
||||
{ from: 'Alice', to: 'Bob', relationType: 'knows' },
|
||||
]);
|
||||
|
||||
const graph = await manager.readGraph();
|
||||
expect(graph.relations).toHaveLength(1);
|
||||
expect(graph.relations[0].relationType).toBe('works_with');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readGraph', () => {
|
||||
it('should return empty graph when file does not exist', async () => {
|
||||
const graph = await manager.readGraph();
|
||||
expect(graph.entities).toHaveLength(0);
|
||||
expect(graph.relations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return complete graph with entities and relations', async () => {
|
||||
await manager.createEntities([
|
||||
{ name: 'Alice', entityType: 'person', observations: ['works at Acme Corp'] },
|
||||
]);
|
||||
|
||||
await manager.createRelations([
|
||||
{ from: 'Alice', to: 'Alice', relationType: 'self' },
|
||||
]);
|
||||
|
||||
const graph = await manager.readGraph();
|
||||
expect(graph.entities).toHaveLength(1);
|
||||
expect(graph.relations).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchNodes', () => {
|
||||
beforeEach(async () => {
|
||||
await manager.createEntities([
|
||||
{ name: 'Alice', entityType: 'person', observations: ['works at Acme Corp', 'likes programming'] },
|
||||
{ name: 'Bob', entityType: 'person', observations: ['works at TechCo'] },
|
||||
{ name: 'Acme Corp', entityType: 'company', observations: ['tech company'] },
|
||||
]);
|
||||
|
||||
await manager.createRelations([
|
||||
{ from: 'Alice', to: 'Acme Corp', relationType: 'works_at' },
|
||||
{ from: 'Bob', to: 'Acme Corp', relationType: 'competitor' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should search by entity name', async () => {
|
||||
const result = await manager.searchNodes('Alice');
|
||||
expect(result.entities).toHaveLength(1);
|
||||
expect(result.entities[0].name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('should search by entity type', async () => {
|
||||
const result = await manager.searchNodes('company');
|
||||
expect(result.entities).toHaveLength(1);
|
||||
expect(result.entities[0].name).toBe('Acme Corp');
|
||||
});
|
||||
|
||||
it('should search by observation content', async () => {
|
||||
const result = await manager.searchNodes('programming');
|
||||
expect(result.entities).toHaveLength(1);
|
||||
expect(result.entities[0].name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('should be case insensitive', async () => {
|
||||
const result = await manager.searchNodes('ALICE');
|
||||
expect(result.entities).toHaveLength(1);
|
||||
expect(result.entities[0].name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('should include relations between matched entities', async () => {
|
||||
const result = await manager.searchNodes('Acme');
|
||||
expect(result.entities).toHaveLength(2); // Alice and Acme Corp
|
||||
expect(result.relations).toHaveLength(1); // Only Alice -> Acme Corp relation
|
||||
});
|
||||
|
||||
it('should return empty graph for no matches', async () => {
|
||||
const result = await manager.searchNodes('NonExistent');
|
||||
expect(result.entities).toHaveLength(0);
|
||||
expect(result.relations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openNodes', () => {
|
||||
beforeEach(async () => {
|
||||
await manager.createEntities([
|
||||
{ name: 'Alice', entityType: 'person', observations: [] },
|
||||
{ name: 'Bob', entityType: 'person', observations: [] },
|
||||
{ name: 'Charlie', entityType: 'person', observations: [] },
|
||||
]);
|
||||
|
||||
await manager.createRelations([
|
||||
{ from: 'Alice', to: 'Bob', relationType: 'knows' },
|
||||
{ from: 'Bob', to: 'Charlie', relationType: 'knows' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should open specific nodes by name', async () => {
|
||||
const result = await manager.openNodes(['Alice', 'Bob']);
|
||||
expect(result.entities).toHaveLength(2);
|
||||
expect(result.entities.map(e => e.name)).toContain('Alice');
|
||||
expect(result.entities.map(e => e.name)).toContain('Bob');
|
||||
});
|
||||
|
||||
it('should include relations between opened nodes', async () => {
|
||||
const result = await manager.openNodes(['Alice', 'Bob']);
|
||||
expect(result.relations).toHaveLength(1);
|
||||
expect(result.relations[0].from).toBe('Alice');
|
||||
expect(result.relations[0].to).toBe('Bob');
|
||||
});
|
||||
|
||||
it('should exclude relations to unopened nodes', async () => {
|
||||
const result = await manager.openNodes(['Bob']);
|
||||
expect(result.relations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle opening non-existent nodes', async () => {
|
||||
const result = await manager.openNodes(['NonExistent']);
|
||||
expect(result.entities).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle empty node list', async () => {
|
||||
const result = await manager.openNodes([]);
|
||||
expect(result.entities).toHaveLength(0);
|
||||
expect(result.relations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('file persistence', () => {
|
||||
it('should persist data across manager instances', async () => {
|
||||
await manager.createEntities([
|
||||
{ name: 'Alice', entityType: 'person', observations: ['persistent data'] },
|
||||
]);
|
||||
|
||||
// Create new manager instance with same file path
|
||||
const manager2 = new KnowledgeGraphManager(testFilePath);
|
||||
const graph = await manager2.readGraph();
|
||||
|
||||
expect(graph.entities).toHaveLength(1);
|
||||
expect(graph.entities[0].name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('should handle JSONL format correctly', async () => {
|
||||
await manager.createEntities([
|
||||
{ name: 'Alice', entityType: 'person', observations: [] },
|
||||
]);
|
||||
await manager.createRelations([
|
||||
{ from: 'Alice', to: 'Alice', relationType: 'self' },
|
||||
]);
|
||||
|
||||
// Read file directly
|
||||
const fileContent = await fs.readFile(testFilePath, 'utf-8');
|
||||
const lines = fileContent.split('\n').filter(line => line.trim());
|
||||
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(JSON.parse(lines[0])).toHaveProperty('type', 'entity');
|
||||
expect(JSON.parse(lines[1])).toHaveProperty('type', 'relation');
|
||||
});
|
||||
|
||||
it('should strip type field from entities when loading from file', async () => {
|
||||
// Create entities and relations (these get saved with type field)
|
||||
await manager.createEntities([
|
||||
{ name: 'Alice', entityType: 'person', observations: ['test observation'] },
|
||||
{ name: 'Bob', entityType: 'person', observations: [] },
|
||||
]);
|
||||
await manager.createRelations([
|
||||
{ from: 'Alice', to: 'Bob', relationType: 'knows' },
|
||||
]);
|
||||
|
||||
// Verify file contains type field (order may vary)
|
||||
const fileContent = await fs.readFile(testFilePath, 'utf-8');
|
||||
const fileLines = fileContent.split('\n').filter(line => line.trim());
|
||||
const fileItems = fileLines.map(line => JSON.parse(line));
|
||||
const fileEntity = fileItems.find(item => item.type === 'entity');
|
||||
const fileRelation = fileItems.find(item => item.type === 'relation');
|
||||
expect(fileEntity).toBeDefined();
|
||||
expect(fileEntity).toHaveProperty('type', 'entity');
|
||||
expect(fileRelation).toBeDefined();
|
||||
expect(fileRelation).toHaveProperty('type', 'relation');
|
||||
|
||||
// Create new manager instance to force reload from file
|
||||
const manager2 = new KnowledgeGraphManager(testFilePath);
|
||||
const graph = await manager2.readGraph();
|
||||
|
||||
// Verify loaded entities don't have type field
|
||||
expect(graph.entities).toHaveLength(2);
|
||||
graph.entities.forEach(entity => {
|
||||
expect(entity).not.toHaveProperty('type');
|
||||
expect(entity).toHaveProperty('name');
|
||||
expect(entity).toHaveProperty('entityType');
|
||||
expect(entity).toHaveProperty('observations');
|
||||
});
|
||||
|
||||
// Verify loaded relations don't have type field
|
||||
expect(graph.relations).toHaveLength(1);
|
||||
graph.relations.forEach(relation => {
|
||||
expect(relation).not.toHaveProperty('type');
|
||||
expect(relation).toHaveProperty('from');
|
||||
expect(relation).toHaveProperty('to');
|
||||
expect(relation).toHaveProperty('relationType');
|
||||
});
|
||||
});
|
||||
|
||||
it('should strip type field from searchNodes results', async () => {
|
||||
await manager.createEntities([
|
||||
{ name: 'Alice', entityType: 'person', observations: ['works at Acme'] },
|
||||
]);
|
||||
await manager.createRelations([
|
||||
{ from: 'Alice', to: 'Alice', relationType: 'self' },
|
||||
]);
|
||||
|
||||
// Create new manager instance to force reload from file
|
||||
const manager2 = new KnowledgeGraphManager(testFilePath);
|
||||
const result = await manager2.searchNodes('Alice');
|
||||
|
||||
// Verify search results don't have type field
|
||||
expect(result.entities).toHaveLength(1);
|
||||
expect(result.entities[0]).not.toHaveProperty('type');
|
||||
expect(result.entities[0].name).toBe('Alice');
|
||||
|
||||
expect(result.relations).toHaveLength(1);
|
||||
expect(result.relations[0]).not.toHaveProperty('type');
|
||||
expect(result.relations[0].from).toBe('Alice');
|
||||
});
|
||||
|
||||
it('should strip type field from openNodes results', async () => {
|
||||
await manager.createEntities([
|
||||
{ name: 'Alice', entityType: 'person', observations: [] },
|
||||
{ name: 'Bob', entityType: 'person', observations: [] },
|
||||
]);
|
||||
await manager.createRelations([
|
||||
{ from: 'Alice', to: 'Bob', relationType: 'knows' },
|
||||
]);
|
||||
|
||||
// Create new manager instance to force reload from file
|
||||
const manager2 = new KnowledgeGraphManager(testFilePath);
|
||||
const result = await manager2.openNodes(['Alice', 'Bob']);
|
||||
|
||||
// Verify open results don't have type field
|
||||
expect(result.entities).toHaveLength(2);
|
||||
result.entities.forEach(entity => {
|
||||
expect(entity).not.toHaveProperty('type');
|
||||
});
|
||||
|
||||
expect(result.relations).toHaveLength(1);
|
||||
expect(result.relations[0]).not.toHaveProperty('type');
|
||||
});
|
||||
});
|
||||
});
|
||||
535
mcpServer/modules/memory/index.ts
Normal file
535
mcpServer/modules/memory/index.ts
Normal file
@@ -0,0 +1,535 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||
import { z } from "zod";
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import * as http from "http";
|
||||
|
||||
// Define memory file path using environment variable with fallback
|
||||
export const defaultMemoryPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'memory.jsonl');
|
||||
|
||||
// Handle backward compatibility: migrate memory.json to memory.jsonl if needed
|
||||
export async function ensureMemoryFilePath(): Promise<string> {
|
||||
if (process.env.MEMORY_FILE_PATH) {
|
||||
// Custom path provided, use it as-is (with absolute path resolution)
|
||||
return path.isAbsolute(process.env.MEMORY_FILE_PATH)
|
||||
? process.env.MEMORY_FILE_PATH
|
||||
: path.join(path.dirname(fileURLToPath(import.meta.url)), process.env.MEMORY_FILE_PATH);
|
||||
}
|
||||
|
||||
// No custom path set, check for backward compatibility migration
|
||||
const oldMemoryPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'memory.json');
|
||||
const newMemoryPath = defaultMemoryPath;
|
||||
|
||||
try {
|
||||
// Check if old file exists and new file doesn't
|
||||
await fs.access(oldMemoryPath);
|
||||
try {
|
||||
await fs.access(newMemoryPath);
|
||||
// Both files exist, use new one (no migration needed)
|
||||
return newMemoryPath;
|
||||
} catch {
|
||||
// Old file exists, new file doesn't - migrate
|
||||
console.error('DETECTED: Found legacy memory.json file, migrating to memory.jsonl for JSONL format compatibility');
|
||||
await fs.rename(oldMemoryPath, newMemoryPath);
|
||||
console.error('COMPLETED: Successfully migrated memory.json to memory.jsonl');
|
||||
return newMemoryPath;
|
||||
}
|
||||
} catch {
|
||||
// Old file doesn't exist, use new path
|
||||
return newMemoryPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize memory file path (will be set during startup)
|
||||
let MEMORY_FILE_PATH: string;
|
||||
|
||||
// We are storing our memory using entities, relations, and observations in a graph structure
|
||||
export interface Entity {
|
||||
name: string;
|
||||
entityType: string;
|
||||
observations: string[];
|
||||
}
|
||||
|
||||
export interface Relation {
|
||||
from: string;
|
||||
to: string;
|
||||
relationType: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeGraph {
|
||||
entities: Entity[];
|
||||
relations: Relation[];
|
||||
}
|
||||
|
||||
// The KnowledgeGraphManager class contains all operations to interact with the knowledge graph
|
||||
export class KnowledgeGraphManager {
|
||||
constructor(private memoryFilePath: string) {}
|
||||
|
||||
private async loadGraph(): Promise<KnowledgeGraph> {
|
||||
try {
|
||||
const data = await fs.readFile(this.memoryFilePath, "utf-8");
|
||||
const lines = data.split("\n").filter(line => line.trim() !== "");
|
||||
return lines.reduce((graph: KnowledgeGraph, line) => {
|
||||
const item = JSON.parse(line);
|
||||
if (item.type === "entity") {
|
||||
graph.entities.push({
|
||||
name: item.name,
|
||||
entityType: item.entityType,
|
||||
observations: item.observations
|
||||
});
|
||||
}
|
||||
if (item.type === "relation") {
|
||||
graph.relations.push({
|
||||
from: item.from,
|
||||
to: item.to,
|
||||
relationType: item.relationType
|
||||
});
|
||||
}
|
||||
return graph;
|
||||
}, { entities: [], relations: [] });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && (error as any).code === "ENOENT") {
|
||||
return { entities: [], relations: [] };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async saveGraph(graph: KnowledgeGraph): Promise<void> {
|
||||
const lines = [
|
||||
...graph.entities.map(e => JSON.stringify({
|
||||
type: "entity",
|
||||
name: e.name,
|
||||
entityType: e.entityType,
|
||||
observations: e.observations
|
||||
})),
|
||||
...graph.relations.map(r => JSON.stringify({
|
||||
type: "relation",
|
||||
from: r.from,
|
||||
to: r.to,
|
||||
relationType: r.relationType
|
||||
})),
|
||||
];
|
||||
await fs.writeFile(this.memoryFilePath, lines.join("\n"));
|
||||
}
|
||||
|
||||
async createEntities(entities: Entity[]): Promise<Entity[]> {
|
||||
const graph = await this.loadGraph();
|
||||
const newEntities = entities.filter(e => !graph.entities.some(existingEntity => existingEntity.name === e.name));
|
||||
graph.entities.push(...newEntities);
|
||||
await this.saveGraph(graph);
|
||||
return newEntities;
|
||||
}
|
||||
|
||||
async createRelations(relations: Relation[]): Promise<Relation[]> {
|
||||
const graph = await this.loadGraph();
|
||||
const newRelations = relations.filter(r => !graph.relations.some(existingRelation =>
|
||||
existingRelation.from === r.from &&
|
||||
existingRelation.to === r.to &&
|
||||
existingRelation.relationType === r.relationType
|
||||
));
|
||||
graph.relations.push(...newRelations);
|
||||
await this.saveGraph(graph);
|
||||
return newRelations;
|
||||
}
|
||||
|
||||
async addObservations(observations: { entityName: string; contents: string[] }[]): Promise<{ entityName: string; addedObservations: string[] }[]> {
|
||||
const graph = await this.loadGraph();
|
||||
const results = observations.map(o => {
|
||||
const entity = graph.entities.find(e => e.name === o.entityName);
|
||||
if (!entity) {
|
||||
throw new Error(`Entity with name ${o.entityName} not found`);
|
||||
}
|
||||
const newObservations = o.contents.filter(content => !entity.observations.includes(content));
|
||||
entity.observations.push(...newObservations);
|
||||
return { entityName: o.entityName, addedObservations: newObservations };
|
||||
});
|
||||
await this.saveGraph(graph);
|
||||
return results;
|
||||
}
|
||||
|
||||
async deleteEntities(entityNames: string[]): Promise<void> {
|
||||
const graph = await this.loadGraph();
|
||||
graph.entities = graph.entities.filter(e => !entityNames.includes(e.name));
|
||||
graph.relations = graph.relations.filter(r => !entityNames.includes(r.from) && !entityNames.includes(r.to));
|
||||
await this.saveGraph(graph);
|
||||
}
|
||||
|
||||
async deleteObservations(deletions: { entityName: string; observations: string[] }[]): Promise<void> {
|
||||
const graph = await this.loadGraph();
|
||||
deletions.forEach(d => {
|
||||
const entity = graph.entities.find(e => e.name === d.entityName);
|
||||
if (entity) {
|
||||
entity.observations = entity.observations.filter(o => !d.observations.includes(o));
|
||||
}
|
||||
});
|
||||
await this.saveGraph(graph);
|
||||
}
|
||||
|
||||
async deleteRelations(relations: Relation[]): Promise<void> {
|
||||
const graph = await this.loadGraph();
|
||||
graph.relations = graph.relations.filter(r => !relations.some(delRelation =>
|
||||
r.from === delRelation.from &&
|
||||
r.to === delRelation.to &&
|
||||
r.relationType === delRelation.relationType
|
||||
));
|
||||
await this.saveGraph(graph);
|
||||
}
|
||||
|
||||
async readGraph(): Promise<KnowledgeGraph> {
|
||||
return this.loadGraph();
|
||||
}
|
||||
|
||||
// Very basic search function
|
||||
async searchNodes(query: string): Promise<KnowledgeGraph> {
|
||||
const graph = await this.loadGraph();
|
||||
|
||||
// Filter entities
|
||||
const filteredEntities = graph.entities.filter(e =>
|
||||
e.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
e.entityType.toLowerCase().includes(query.toLowerCase()) ||
|
||||
e.observations.some(o => o.toLowerCase().includes(query.toLowerCase()))
|
||||
);
|
||||
|
||||
// Create a Set of filtered entity names for quick lookup
|
||||
const filteredEntityNames = new Set(filteredEntities.map(e => e.name));
|
||||
|
||||
// Filter relations to only include those between filtered entities
|
||||
const filteredRelations = graph.relations.filter(r =>
|
||||
filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to)
|
||||
);
|
||||
|
||||
const filteredGraph: KnowledgeGraph = {
|
||||
entities: filteredEntities,
|
||||
relations: filteredRelations,
|
||||
};
|
||||
|
||||
return filteredGraph;
|
||||
}
|
||||
|
||||
async openNodes(names: string[]): Promise<KnowledgeGraph> {
|
||||
const graph = await this.loadGraph();
|
||||
|
||||
// Filter entities
|
||||
const filteredEntities = graph.entities.filter(e => names.includes(e.name));
|
||||
|
||||
// Create a Set of filtered entity names for quick lookup
|
||||
const filteredEntityNames = new Set(filteredEntities.map(e => e.name));
|
||||
|
||||
// Filter relations to only include those between filtered entities
|
||||
const filteredRelations = graph.relations.filter(r =>
|
||||
filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to)
|
||||
);
|
||||
|
||||
const filteredGraph: KnowledgeGraph = {
|
||||
entities: filteredEntities,
|
||||
relations: filteredRelations,
|
||||
};
|
||||
|
||||
return filteredGraph;
|
||||
}
|
||||
}
|
||||
|
||||
let knowledgeGraphManager: KnowledgeGraphManager;
|
||||
|
||||
// Zod schemas for entities and relations
|
||||
const EntitySchema = z.object({
|
||||
name: z.string().describe("The name of the entity"),
|
||||
entityType: z.string().describe("The type of the entity"),
|
||||
observations: z.array(z.string()).describe("An array of observation contents associated with the entity")
|
||||
});
|
||||
|
||||
const RelationSchema = z.object({
|
||||
from: z.string().describe("The name of the entity where the relation starts"),
|
||||
to: z.string().describe("The name of the entity where the relation ends"),
|
||||
relationType: z.string().describe("The type of the relation")
|
||||
});
|
||||
|
||||
// The server instance and tools exposed to Claude
|
||||
const server = new McpServer({
|
||||
name: "memory-server",
|
||||
version: "0.6.3",
|
||||
});
|
||||
|
||||
// Register create_entities tool
|
||||
server.registerTool(
|
||||
"create_entities",
|
||||
{
|
||||
title: "Create Entities",
|
||||
description: "Create multiple new entities in the knowledge graph",
|
||||
inputSchema: {
|
||||
entities: z.array(EntitySchema)
|
||||
},
|
||||
outputSchema: {
|
||||
entities: z.array(EntitySchema)
|
||||
}
|
||||
},
|
||||
async ({ entities }) => {
|
||||
const result = await knowledgeGraphManager.createEntities(entities);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
|
||||
structuredContent: { entities: result }
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Register create_relations tool
|
||||
server.registerTool(
|
||||
"create_relations",
|
||||
{
|
||||
title: "Create Relations",
|
||||
description: "Create multiple new relations between entities in the knowledge graph. Relations should be in active voice",
|
||||
inputSchema: {
|
||||
relations: z.array(RelationSchema)
|
||||
},
|
||||
outputSchema: {
|
||||
relations: z.array(RelationSchema)
|
||||
}
|
||||
},
|
||||
async ({ relations }) => {
|
||||
const result = await knowledgeGraphManager.createRelations(relations);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
|
||||
structuredContent: { relations: result }
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Register add_observations tool
|
||||
server.registerTool(
|
||||
"add_observations",
|
||||
{
|
||||
title: "Add Observations",
|
||||
description: "Add new observations to existing entities in the knowledge graph",
|
||||
inputSchema: {
|
||||
observations: z.array(z.object({
|
||||
entityName: z.string().describe("The name of the entity to add the observations to"),
|
||||
contents: z.array(z.string()).describe("An array of observation contents to add")
|
||||
}))
|
||||
},
|
||||
outputSchema: {
|
||||
results: z.array(z.object({
|
||||
entityName: z.string(),
|
||||
addedObservations: z.array(z.string())
|
||||
}))
|
||||
}
|
||||
},
|
||||
async ({ observations }) => {
|
||||
const result = await knowledgeGraphManager.addObservations(observations);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
|
||||
structuredContent: { results: result }
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Register delete_entities tool
|
||||
server.registerTool(
|
||||
"delete_entities",
|
||||
{
|
||||
title: "Delete Entities",
|
||||
description: "Delete multiple entities and their associated relations from the knowledge graph",
|
||||
inputSchema: {
|
||||
entityNames: z.array(z.string()).describe("An array of entity names to delete")
|
||||
},
|
||||
outputSchema: {
|
||||
success: z.boolean(),
|
||||
message: z.string()
|
||||
}
|
||||
},
|
||||
async ({ entityNames }) => {
|
||||
await knowledgeGraphManager.deleteEntities(entityNames);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "Entities deleted successfully" }],
|
||||
structuredContent: { success: true, message: "Entities deleted successfully" }
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Register delete_observations tool
|
||||
server.registerTool(
|
||||
"delete_observations",
|
||||
{
|
||||
title: "Delete Observations",
|
||||
description: "Delete specific observations from entities in the knowledge graph",
|
||||
inputSchema: {
|
||||
deletions: z.array(z.object({
|
||||
entityName: z.string().describe("The name of the entity containing the observations"),
|
||||
observations: z.array(z.string()).describe("An array of observations to delete")
|
||||
}))
|
||||
},
|
||||
outputSchema: {
|
||||
success: z.boolean(),
|
||||
message: z.string()
|
||||
}
|
||||
},
|
||||
async ({ deletions }) => {
|
||||
await knowledgeGraphManager.deleteObservations(deletions);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "Observations deleted successfully" }],
|
||||
structuredContent: { success: true, message: "Observations deleted successfully" }
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Register delete_relations tool
|
||||
server.registerTool(
|
||||
"delete_relations",
|
||||
{
|
||||
title: "Delete Relations",
|
||||
description: "Delete multiple relations from the knowledge graph",
|
||||
inputSchema: {
|
||||
relations: z.array(RelationSchema).describe("An array of relations to delete")
|
||||
},
|
||||
outputSchema: {
|
||||
success: z.boolean(),
|
||||
message: z.string()
|
||||
}
|
||||
},
|
||||
async ({ relations }) => {
|
||||
await knowledgeGraphManager.deleteRelations(relations);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "Relations deleted successfully" }],
|
||||
structuredContent: { success: true, message: "Relations deleted successfully" }
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Register read_graph tool
|
||||
server.registerTool(
|
||||
"read_graph",
|
||||
{
|
||||
title: "Read Graph",
|
||||
description: "Read the entire knowledge graph",
|
||||
inputSchema: {},
|
||||
outputSchema: {
|
||||
entities: z.array(EntitySchema),
|
||||
relations: z.array(RelationSchema)
|
||||
}
|
||||
},
|
||||
async () => {
|
||||
const graph = await knowledgeGraphManager.readGraph();
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(graph, null, 2) }],
|
||||
structuredContent: { ...graph }
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Register search_nodes tool
|
||||
server.registerTool(
|
||||
"search_nodes",
|
||||
{
|
||||
title: "Search Nodes",
|
||||
description: "Search for nodes in the knowledge graph based on a query",
|
||||
inputSchema: {
|
||||
query: z.string().describe("The search query to match against entity names, types, and observation content")
|
||||
},
|
||||
outputSchema: {
|
||||
entities: z.array(EntitySchema),
|
||||
relations: z.array(RelationSchema)
|
||||
}
|
||||
},
|
||||
async ({ query }) => {
|
||||
const graph = await knowledgeGraphManager.searchNodes(query);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(graph, null, 2) }],
|
||||
structuredContent: { ...graph }
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Register open_nodes tool
|
||||
server.registerTool(
|
||||
"open_nodes",
|
||||
{
|
||||
title: "Open Nodes",
|
||||
description: "Open specific nodes in the knowledge graph by their names",
|
||||
inputSchema: {
|
||||
names: z.array(z.string()).describe("An array of entity names to retrieve")
|
||||
},
|
||||
outputSchema: {
|
||||
entities: z.array(EntitySchema),
|
||||
relations: z.array(RelationSchema)
|
||||
}
|
||||
},
|
||||
async ({ names }) => {
|
||||
const graph = await knowledgeGraphManager.openNodes(names);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(graph, null, 2) }],
|
||||
structuredContent: { ...graph }
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// SSE transport session routing (sessionId -> transport)
|
||||
const sseTransportsBySessionId = new Map<string, SSEServerTransport>();
|
||||
|
||||
function runServer() {
|
||||
const port = Number(process.env.MCP_PORT ?? process.env.SSE_PORT ?? 3000);
|
||||
|
||||
const httpServer = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
||||
const pathname = url.pathname;
|
||||
|
||||
if (req.method === "GET" && (pathname === "/sse" || pathname === "/")) {
|
||||
try {
|
||||
const transport = new SSEServerTransport("/messages", res);
|
||||
sseTransportsBySessionId.set(transport.sessionId, transport);
|
||||
transport.onclose = () => {
|
||||
sseTransportsBySessionId.delete(transport.sessionId);
|
||||
};
|
||||
await server.connect(transport);
|
||||
console.error("Knowledge Graph MCP Server: new SSE client connected");
|
||||
} catch (error) {
|
||||
console.error("SSE connection error:", error);
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(500).end("Internal server error");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && pathname === "/messages") {
|
||||
const sessionId = url.searchParams.get("sessionId");
|
||||
if (!sessionId) {
|
||||
res.writeHead(400).end("Missing sessionId query parameter");
|
||||
return;
|
||||
}
|
||||
const transport = sseTransportsBySessionId.get(sessionId);
|
||||
if (!transport) {
|
||||
res.writeHead(404).end("Unknown session");
|
||||
return;
|
||||
}
|
||||
await transport.handlePostMessage(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404).end("Not found");
|
||||
});
|
||||
|
||||
httpServer.listen(port, () => {
|
||||
console.error(`Knowledge Graph MCP Server running on SSE at http://localhost:${port}`);
|
||||
console.error(" GET /sse – open SSE stream (then POST to /messages?sessionId=...)");
|
||||
console.error(" POST /messages?sessionId=<id> – send MCP messages");
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Initialize memory file path with backward compatibility
|
||||
MEMORY_FILE_PATH = await ensureMemoryFilePath();
|
||||
|
||||
// Initialize knowledge graph manager with the memory file path
|
||||
knowledgeGraphManager = new KnowledgeGraphManager(MEMORY_FILE_PATH);
|
||||
|
||||
runServer();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("Fatal error in main():", error);
|
||||
process.exit(1);
|
||||
});
|
||||
37
mcpServer/modules/memory/package.json
Normal file
37
mcpServer/modules/memory/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@modelcontextprotocol/server-memory",
|
||||
"version": "0.6.3",
|
||||
"description": "MCP server for enabling memory for Claude through a knowledge graph",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"mcpName": "io.github.modelcontextprotocol/server-memory",
|
||||
"author": "Model Context Protocol a Series of LF Projects, LLC.",
|
||||
"homepage": "https://modelcontextprotocol.io",
|
||||
"bugs": "https://github.com/modelcontextprotocol/servers/issues",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/modelcontextprotocol/servers.git"
|
||||
},
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"mcp-server-memory": "dist/index.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc && shx chmod +x dist/*.js",
|
||||
"prepare": "npm run build",
|
||||
"watch": "tsc --watch",
|
||||
"test": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.26.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22",
|
||||
"@vitest/coverage-v8": "^2.1.8",
|
||||
"shx": "^0.3.4",
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
11
mcpServer/modules/memory/tsconfig.json
Normal file
11
mcpServer/modules/memory/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": ".",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["./**/*.ts"],
|
||||
"exclude": ["**/*.test.ts", "vitest.config.ts"]
|
||||
}
|
||||
14
mcpServer/modules/memory/vitest.config.ts
Normal file
14
mcpServer/modules/memory/vitest.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['**/__tests__/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['**/*.ts'],
|
||||
exclude: ['**/__tests__/**', '**/dist/**'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user