Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d73f99efbf |
||
|
|
1ec570c27d |
||
|
|
f41b666edf |
||
|
|
c3d8e43d1f |
||
|
|
abbbddb2e3 |
||
|
|
649af5856e |
||
|
|
242751e8ee |
||
|
|
ba167cef5b |
||
|
|
48723c95dc |
||
|
|
96c49c7364 |
||
|
|
cce08de417 |
||
|
|
2e3e4c7ab7 |
||
|
|
3aa19f5574 |
16 changed files with 804 additions and 143 deletions
7
.github/workflows/readme-pr-check.yml
vendored
7
.github/workflows/readme-pr-check.yml
vendored
|
|
@ -1,7 +1,10 @@
|
|||
name: README PR Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
# pull_request_target, not pull_request: fork PRs get a read-only token under
|
||||
# pull_request, so the label and comment calls fail with 403. This job only
|
||||
# calls the API and never checks out PR code, so the write token is safe.
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
paths:
|
||||
- 'README.md'
|
||||
|
|
@ -10,7 +13,7 @@ on:
|
|||
|
||||
jobs:
|
||||
check-readme-only:
|
||||
if: github.event_name == 'pull_request'
|
||||
if: github.event_name == 'pull_request_target'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
|
|||
9
package-lock.json
generated
9
package-lock.json
generated
|
|
@ -3866,7 +3866,8 @@
|
|||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"diff": "^8.0.3",
|
||||
"glob": "^13.0.6",
|
||||
"minimatch": "^10.0.1"
|
||||
"minimatch": "^10.0.1",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"mcp-server-filesystem": "dist/index.js"
|
||||
|
|
@ -3886,7 +3887,8 @@
|
|||
"version": "0.6.3",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.30.0"
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"mcp-server-memory": "dist/index.js"
|
||||
|
|
@ -3906,7 +3908,8 @@
|
|||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"chalk": "^5.3.0",
|
||||
"yargs": "^17.7.2"
|
||||
"yargs": "^17.7.2",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"mcp-server-sequential-thinking": "dist/index.js"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/server-everything",
|
||||
"version": "2026.8.31",
|
||||
"version": "2.0.0",
|
||||
"description": "MCP server that exercises all the features of the MCP protocol",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"mcpName": "io.github.modelcontextprotocol/server-everything",
|
||||
|
|
@ -45,4 +45,4 @@
|
|||
"typescript": "^5.6.2",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/server-filesystem",
|
||||
"version": "2026.8.31",
|
||||
"version": "0.6.3",
|
||||
"description": "MCP server for filesystem access",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"mcpName": "io.github.modelcontextprotocol/server-filesystem",
|
||||
|
|
@ -28,7 +28,8 @@
|
|||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"diff": "^8.0.3",
|
||||
"glob": "^13.0.6",
|
||||
"minimatch": "^10.0.1"
|
||||
"minimatch": "^10.0.1",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/diff": "^5.0.9",
|
||||
|
|
@ -39,4 +40,4 @@
|
|||
"typescript": "^5.8.2",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Sequence, Optional
|
||||
from typing import Any, Optional, Sequence
|
||||
from mcp.server import Server
|
||||
from mcp.server.session import ServerSession
|
||||
from mcp.server.stdio import stdio_server
|
||||
|
|
@ -157,45 +157,28 @@ def git_reset(repo: git.Repo) -> str:
|
|||
return "All staged changes reset"
|
||||
|
||||
def git_log(repo: git.Repo, max_count: int = 10, start_timestamp: Optional[str] = None, end_timestamp: Optional[str] = None) -> list[str]:
|
||||
if start_timestamp or end_timestamp:
|
||||
# Defense in depth: reject timestamps starting with '-' to prevent flag injection
|
||||
if start_timestamp and start_timestamp.startswith("-"):
|
||||
raise ValueError(f"Invalid start_timestamp: '{start_timestamp}' - cannot start with '-'")
|
||||
if end_timestamp and end_timestamp.startswith("-"):
|
||||
raise ValueError(f"Invalid end_timestamp: '{end_timestamp}' - cannot start with '-'")
|
||||
# Use git log command with date filtering
|
||||
args = []
|
||||
if start_timestamp:
|
||||
args.extend(['--since', start_timestamp])
|
||||
if end_timestamp:
|
||||
args.extend(['--until', end_timestamp])
|
||||
args.extend(['--format=%H%n%an%n%ad%n%s%n'])
|
||||
# Defense in depth: reject timestamps starting with '-' to prevent flag injection
|
||||
if start_timestamp and start_timestamp.startswith("-"):
|
||||
raise ValueError(f"Invalid start_timestamp: '{start_timestamp}' - cannot start with '-'")
|
||||
if end_timestamp and end_timestamp.startswith("-"):
|
||||
raise ValueError(f"Invalid end_timestamp: '{end_timestamp}' - cannot start with '-'")
|
||||
|
||||
log_output = repo.git.log(*args).split('\n')
|
||||
kwargs: dict[str, Any] = {"max_count": max_count}
|
||||
if start_timestamp:
|
||||
kwargs["since"] = start_timestamp
|
||||
if end_timestamp:
|
||||
kwargs["until"] = end_timestamp
|
||||
|
||||
log = []
|
||||
# Process commits in groups of 4 (hash, author, date, message)
|
||||
for i in range(0, len(log_output), 4):
|
||||
if i + 3 < len(log_output) and len(log) < max_count:
|
||||
log.append(
|
||||
f"Commit: {log_output[i]}\n"
|
||||
f"Author: {log_output[i+1]}\n"
|
||||
f"Date: {log_output[i+2]}\n"
|
||||
f"Message: {log_output[i+3]}\n"
|
||||
)
|
||||
return log
|
||||
else:
|
||||
# Use existing logic for simple log without date filtering
|
||||
commits = list(repo.iter_commits(max_count=max_count))
|
||||
log = []
|
||||
for commit in commits:
|
||||
log.append(
|
||||
f"Commit: {commit.hexsha!r}\n"
|
||||
f"Author: {commit.author!r}\n"
|
||||
f"Date: {commit.authored_datetime}\n"
|
||||
f"Message: {commit.message!r}\n"
|
||||
)
|
||||
return log
|
||||
commits = list(repo.iter_commits(**kwargs))
|
||||
log = []
|
||||
for commit in commits:
|
||||
log.append(
|
||||
f"Commit: {commit.hexsha}\n"
|
||||
f"Author: {commit.author}\n"
|
||||
f"Date: {commit.authored_datetime}\n"
|
||||
f"Message: {commit.message}\n"
|
||||
)
|
||||
return log
|
||||
|
||||
def git_create_branch(repo: git.Repo, branch_name: str, base_branch: str | None = None) -> str:
|
||||
# Defense in depth: reject names starting with '-' to prevent flag injection
|
||||
|
|
@ -599,4 +582,4 @@ async def serve(repository: Path | None) -> None:
|
|||
|
||||
options = server.create_initialization_options()
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
await server.run(read_stream, write_stream, options, raise_exceptions=True)
|
||||
await server.run(read_stream, write_stream, options)
|
||||
|
|
|
|||
|
|
@ -16,8 +16,10 @@ from mcp_server_git.server import (
|
|||
git_create_branch,
|
||||
git_show,
|
||||
validate_repo_path,
|
||||
serve,
|
||||
)
|
||||
import shutil
|
||||
import unittest.mock as mock
|
||||
|
||||
@pytest.fixture
|
||||
def test_repository(tmp_path: Path):
|
||||
|
|
@ -508,3 +510,81 @@ def test_git_branch_rejects_contains_flag_injection(test_repository):
|
|||
|
||||
with pytest.raises(BadName):
|
||||
git_branch(test_repository, "local", not_contains="--exec=evil")
|
||||
|
||||
|
||||
def test_git_log_formatting_no_repr(test_repository):
|
||||
"""Test that git_log does not use !r formatting (no Python object repr or quotes)."""
|
||||
file_path = Path(test_repository.working_dir) / "multiline.txt"
|
||||
file_path.write_text("multiline test")
|
||||
test_repository.index.add(["multiline.txt"])
|
||||
test_repository.index.commit("Subject line\n\nDetailed body line 1\nDetailed body line 2")
|
||||
|
||||
result = git_log(test_repository, max_count=1)
|
||||
entry = result[0]
|
||||
|
||||
# Verify no python repr quotes around commit hash or message
|
||||
assert "Commit: '" not in entry
|
||||
assert 'Commit: "' not in entry
|
||||
assert "<git.Actor" not in entry
|
||||
assert "Message: '" not in entry
|
||||
assert 'Message: "' not in entry
|
||||
|
||||
# Verify full message with body is included
|
||||
assert "Subject line\n\nDetailed body line 1\nDetailed body line 2" in entry
|
||||
|
||||
|
||||
def test_git_log_filtered_unfiltered_parity(test_repository):
|
||||
"""Test that filtered and unfiltered git_log produce identical schema and preserve commit body."""
|
||||
file_path = Path(test_repository.working_dir) / "parity_test.txt"
|
||||
file_path.write_text("parity test")
|
||||
test_repository.index.add(["parity_test.txt"])
|
||||
test_repository.index.commit("Parity subject\n\nParity body line 1\nParity body line 2")
|
||||
|
||||
unfiltered = git_log(test_repository, max_count=1)
|
||||
filtered_since = git_log(test_repository, max_count=1, start_timestamp="yesterday")
|
||||
filtered_until = git_log(test_repository, max_count=1, end_timestamp="2099-01-01")
|
||||
|
||||
# Output schemas and contents must be identical
|
||||
assert unfiltered == filtered_since
|
||||
assert unfiltered == filtered_until
|
||||
|
||||
# Multi-line commit message preserved in filtered results
|
||||
assert "Parity subject\n\nParity body line 1\nParity body line 2" in filtered_since[0]
|
||||
|
||||
|
||||
def test_git_log_date_filtering(test_repository):
|
||||
"""Test date filtering logic in git_log."""
|
||||
# Future start_timestamp should return no commits
|
||||
future_result = git_log(test_repository, start_timestamp="2099-01-01")
|
||||
assert future_result == []
|
||||
|
||||
# Past end_timestamp should return no commits
|
||||
past_result = git_log(test_repository, end_timestamp="2000-01-01")
|
||||
assert past_result == []
|
||||
|
||||
# Valid range with max_count
|
||||
valid_result = git_log(test_repository, max_count=1, start_timestamp="2000-01-01")
|
||||
assert len(valid_result) == 1
|
||||
|
||||
|
||||
def test_serve_run_does_not_raise_exceptions(tmp_path: Path):
|
||||
"""Verify that serve() runs server.run without raise_exceptions=True."""
|
||||
import anyio
|
||||
|
||||
repo_path = tmp_path / "serve_test_repo"
|
||||
git.Repo.init(repo_path)
|
||||
|
||||
async def _run():
|
||||
with mock.patch("mcp_server_git.server.stdio_server") as mock_stdio:
|
||||
mock_read = mock.AsyncMock()
|
||||
mock_write = mock.AsyncMock()
|
||||
mock_stdio.return_value.__aenter__.return_value = (mock_read, mock_write)
|
||||
mock_stdio.return_value.__aexit__.return_value = None
|
||||
|
||||
with mock.patch("mcp_server_git.server.Server.run", new_callable=mock.AsyncMock) as mock_run:
|
||||
await serve(repo_path)
|
||||
mock_run.assert_awaited_once()
|
||||
_, kwargs = mock_run.call_args
|
||||
assert kwargs.get("raise_exceptions") is not True
|
||||
|
||||
anyio.run(_run)
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ Example:
|
|||
- `to` (string): Target entity name
|
||||
- `relationType` (string): Relationship type in active voice
|
||||
- Skips duplicate relations
|
||||
- Fails if either the source or target entity doesn't exist
|
||||
|
||||
- **add_observations**
|
||||
- Add new observations to existing entities
|
||||
|
|
@ -86,7 +87,7 @@ Example:
|
|||
- Remove entities and their relations
|
||||
- Input: `entityNames` (string[])
|
||||
- Cascading deletion of associated relations
|
||||
- Silent operation if entity doesn't exist
|
||||
- No error if an entity doesn't exist; the response reports which names were not found
|
||||
|
||||
- **delete_observations**
|
||||
- Remove specific observations from entities
|
||||
|
|
@ -94,7 +95,7 @@ Example:
|
|||
- Each object contains:
|
||||
- `entityName` (string): Target entity
|
||||
- `observations` (string[]): Observations to remove
|
||||
- Silent operation if observation doesn't exist
|
||||
- No error if an observation doesn't exist; the response reports how many were deleted
|
||||
|
||||
- **delete_relations**
|
||||
- Remove specific relations from the graph
|
||||
|
|
@ -103,7 +104,7 @@ Example:
|
|||
- `from` (string): Source entity name
|
||||
- `to` (string): Target entity name
|
||||
- `relationType` (string): Relationship type
|
||||
- Silent operation if relation doesn't exist
|
||||
- No error if a relation doesn't exist; the response reports how many were deleted
|
||||
|
||||
- **read_graph**
|
||||
- Read the entire knowledge graph
|
||||
|
|
|
|||
99
src/memory/__tests__/delete-reporting.test.ts
Normal file
99
src/memory/__tests__/delete-reporting.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
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 } from '../index.js';
|
||||
|
||||
/**
|
||||
* The delete tools stay silent when a target is absent, which the README
|
||||
* documents. What they must not do is report a deletion that did not happen:
|
||||
* an agent that mistypes a name is told its memory is clean while the data is
|
||||
* still on disk, and nothing in the response contradicts that.
|
||||
*/
|
||||
describe('delete reporting', () => {
|
||||
let manager: KnowledgeGraphManager;
|
||||
let testFilePath: string;
|
||||
|
||||
const entities: Entity[] = [
|
||||
{ name: 'Alice', entityType: 'person', observations: ['works at Acme Corp', 'likes tea'] },
|
||||
{ name: 'Bob', entityType: 'person', observations: ['likes programming'] },
|
||||
];
|
||||
const relations: Relation[] = [{ from: 'Alice', to: 'Bob', relationType: 'works_with' }];
|
||||
|
||||
beforeEach(async () => {
|
||||
testFilePath = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
`test-delete-reporting-${Date.now()}-${Math.random().toString(16).slice(2)}.jsonl`
|
||||
);
|
||||
manager = new KnowledgeGraphManager(testFilePath);
|
||||
await manager.createEntities(entities);
|
||||
await manager.createRelations(relations);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await fs.unlink(testFilePath);
|
||||
} catch {
|
||||
// the file is gone already
|
||||
}
|
||||
});
|
||||
|
||||
describe('deleteEntities', () => {
|
||||
it('reports which names matched and which did not', async () => {
|
||||
const result = await manager.deleteEntities(['Alice', 'Alise']);
|
||||
expect(result).toEqual({ deleted: ['Alice'], notFound: ['Alise'] });
|
||||
});
|
||||
|
||||
it('reports nothing deleted when no name matches', async () => {
|
||||
const result = await manager.deleteEntities(['Nobody']);
|
||||
expect(result).toEqual({ deleted: [], notFound: ['Nobody'] });
|
||||
|
||||
const graph = await manager.readGraph();
|
||||
expect(graph.entities).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('still deletes the entity and its relations', async () => {
|
||||
await manager.deleteEntities(['Alice']);
|
||||
|
||||
const graph = await manager.readGraph();
|
||||
expect(graph.entities.map(e => e.name)).toEqual(['Bob']);
|
||||
expect(graph.relations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteObservations', () => {
|
||||
it('counts only the observations that were present', async () => {
|
||||
const result = await manager.deleteObservations([
|
||||
{ entityName: 'Alice', observations: ['likes tea', 'never said this'] },
|
||||
]);
|
||||
expect(result).toEqual({ deletedCount: 1, missingEntities: [] });
|
||||
});
|
||||
|
||||
it('names an entity that does not exist', async () => {
|
||||
const result = await manager.deleteObservations([
|
||||
{ entityName: 'Carol', observations: ['anything'] },
|
||||
]);
|
||||
expect(result).toEqual({ deletedCount: 0, missingEntities: ['Carol'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteRelations', () => {
|
||||
it('counts only the relations that matched', async () => {
|
||||
const result = await manager.deleteRelations([
|
||||
{ from: 'Alice', to: 'Bob', relationType: 'works_with' },
|
||||
{ from: 'Alice', to: 'Bob', relationType: 'never_existed' },
|
||||
]);
|
||||
expect(result).toEqual({ deletedCount: 1 });
|
||||
});
|
||||
|
||||
it('reports nothing deleted when the relation type is wrong', async () => {
|
||||
const result = await manager.deleteRelations([
|
||||
{ from: 'Alice', to: 'Bob', relationType: 'manages' },
|
||||
]);
|
||||
expect(result).toEqual({ deletedCount: 0 });
|
||||
|
||||
const graph = await manager.readGraph();
|
||||
expect(graph.relations).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { ensureMemoryFilePath, defaultMemoryPath } from '../index.js';
|
||||
import { ensureMemoryFilePath, defaultMemoryPath, expandHome } from '../index.js';
|
||||
|
||||
describe('ensureMemoryFilePath', () => {
|
||||
const testDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
|
@ -72,6 +73,15 @@ describe('ensureMemoryFilePath', () => {
|
|||
expect(path.isAbsolute(result)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should expand a leading "~/" to the home directory', async () => {
|
||||
process.env.MEMORY_FILE_PATH = '~/custom-memory.jsonl';
|
||||
|
||||
const result = await ensureMemoryFilePath();
|
||||
|
||||
expect(result).toBe(path.join(os.homedir(), 'custom-memory.jsonl'));
|
||||
expect(path.isAbsolute(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('without MEMORY_FILE_PATH environment variable', () => {
|
||||
|
|
@ -154,3 +164,29 @@ describe('ensureMemoryFilePath', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandHome', () => {
|
||||
it('expands a bare "~" to the home directory', () => {
|
||||
expect(expandHome('~')).toBe(os.homedir());
|
||||
});
|
||||
|
||||
it('expands a leading "~/" to the home directory', () => {
|
||||
expect(expandHome('~/notes/memory.jsonl')).toBe(
|
||||
path.join(os.homedir(), 'notes/memory.jsonl')
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a "~" not followed by a separator unchanged', () => {
|
||||
expect(expandHome('~backup.jsonl')).toBe('~backup.jsonl');
|
||||
});
|
||||
|
||||
it('leaves absolute paths unchanged', () => {
|
||||
expect(expandHome('/var/data/memory.jsonl')).toBe('/var/data/memory.jsonl');
|
||||
});
|
||||
|
||||
it('leaves relative paths unchanged', () => {
|
||||
expect(expandHome(path.join('data', 'memory.jsonl'))).toBe(
|
||||
path.join('data', 'memory.jsonl')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -59,6 +59,20 @@ describe('KnowledgeGraphManager', () => {
|
|||
const newEntities = await manager.createEntities([]);
|
||||
expect(newEntities).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should ignore duplicate entity names within a single batch', async () => {
|
||||
const entities: Entity[] = [
|
||||
{ name: 'Alice', entityType: 'person', observations: ['first'] },
|
||||
{ name: 'Alice', entityType: 'person', observations: ['second'] },
|
||||
];
|
||||
|
||||
const newEntities = await manager.createEntities(entities);
|
||||
expect(newEntities).toHaveLength(1);
|
||||
|
||||
const graph = await manager.readGraph();
|
||||
expect(graph.entities).toHaveLength(1);
|
||||
expect(graph.entities[0].name).toBe('Alice');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRelations', () => {
|
||||
|
|
@ -99,10 +113,60 @@ describe('KnowledgeGraphManager', () => {
|
|||
expect(graph.relations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should reject relations from non-existent entities', async () => {
|
||||
await manager.createEntities([
|
||||
{ name: 'Alice', entityType: 'person', observations: [] },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
manager.createRelations([
|
||||
{ from: 'Ghost', to: 'Alice', relationType: 'knows' },
|
||||
])
|
||||
).rejects.toThrow('Entity with name Ghost not found');
|
||||
|
||||
const graph = await manager.readGraph();
|
||||
expect(graph.relations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject relation batches that reference non-existent target entities', async () => {
|
||||
await manager.createEntities([
|
||||
{ name: 'Alice', entityType: 'person', observations: [] },
|
||||
{ name: 'Bob', entityType: 'person', observations: [] },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
manager.createRelations([
|
||||
{ from: 'Alice', to: 'Bob', relationType: 'knows' },
|
||||
{ from: 'Alice', to: 'Ghost', relationType: 'knows' },
|
||||
])
|
||||
).rejects.toThrow('Entity with name Ghost not found');
|
||||
|
||||
const graph = await manager.readGraph();
|
||||
expect(graph.relations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle empty relation arrays', async () => {
|
||||
const newRelations = await manager.createRelations([]);
|
||||
expect(newRelations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should skip duplicate relations within a single batch', async () => {
|
||||
await manager.createEntities([
|
||||
{ name: 'Alice', entityType: 'person', observations: [] },
|
||||
{ name: 'Bob', entityType: 'person', observations: [] },
|
||||
]);
|
||||
|
||||
const relations: Relation[] = [
|
||||
{ from: 'Alice', to: 'Bob', relationType: 'knows' },
|
||||
{ from: 'Alice', to: 'Bob', relationType: 'knows' },
|
||||
];
|
||||
|
||||
const newRelations = await manager.createRelations(relations);
|
||||
expect(newRelations).toHaveLength(1);
|
||||
|
||||
const graph = await manager.readGraph();
|
||||
expect(graph.relations).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addObservations', () => {
|
||||
|
|
@ -146,11 +210,11 @@ describe('KnowledgeGraphManager', () => {
|
|||
});
|
||||
|
||||
it('should throw error for non-existent entity', async () => {
|
||||
await expect(
|
||||
manager.addObservations([
|
||||
await expect(async () => {
|
||||
await manager.addObservations([
|
||||
{ entityName: 'NonExistent', contents: ['some observation'] },
|
||||
])
|
||||
).rejects.toThrow('Entity with name NonExistent not found');
|
||||
]);
|
||||
}).rejects.toThrow('Entity with name NonExistent not found');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -426,6 +490,76 @@ describe('KnowledgeGraphManager', () => {
|
|||
expect(JSON.parse(lines[1])).toHaveProperty('type', 'relation');
|
||||
});
|
||||
|
||||
it('should write a trailing newline to produce valid JSONL', async () => {
|
||||
await manager.createEntities([
|
||||
{ name: 'Alice', entityType: 'person', observations: ['test'] },
|
||||
]);
|
||||
|
||||
const fileContent = await fs.readFile(testFilePath, 'utf-8');
|
||||
expect(fileContent.endsWith('\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('should produce a file where every line is individually valid JSON', async () => {
|
||||
// This test catches the bug where saveGraph wrote lines.join("\n")
|
||||
// without a trailing newline. When the file was later appended to
|
||||
// (e.g. by a concurrent process or external tool), the last JSON
|
||||
// object and the new first JSON object ended up on the same line,
|
||||
// producing invalid JSONL like:
|
||||
// {"type":"entity","name":"Alice"}{"type":"relation","from":"Alice",...}
|
||||
// which fails with: "Unexpected non-whitespace character after JSON
|
||||
// at position N"
|
||||
await manager.createEntities([
|
||||
{ name: 'Alice', entityType: 'person', observations: ['test'] },
|
||||
{ name: 'Bob', entityType: 'person', observations: [] },
|
||||
]);
|
||||
await manager.createRelations([
|
||||
{ from: 'Alice', to: 'Bob', relationType: 'knows' },
|
||||
]);
|
||||
|
||||
const fileContent = await fs.readFile(testFilePath, 'utf-8');
|
||||
const allLines = fileContent.split('\n');
|
||||
|
||||
// Every non-empty line must be valid JSON on its own
|
||||
for (const line of allLines) {
|
||||
if (line.trim() === '') continue;
|
||||
expect(() => JSON.parse(line)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('should not corrupt JSONL when content is appended to the file externally', async () => {
|
||||
// Simulate the real-world corruption scenario:
|
||||
// 1. saveGraph writes entities to the file
|
||||
// 2. An external process appends a new JSON line to the file
|
||||
// 3. loadGraph must still parse the file without errors
|
||||
//
|
||||
// Without a trailing newline on step 1, the appended content in
|
||||
// step 2 lands on the same line as the last entity, producing
|
||||
// invalid JSONL that breaks loadGraph.
|
||||
await manager.createEntities([
|
||||
{ name: 'Alice', entityType: 'person', observations: ['original'] },
|
||||
]);
|
||||
|
||||
// Simulate an external append (e.g. another process, a script, or
|
||||
// a crash-recovery replay). This is what triggers the bug: without
|
||||
// a trailing newline, this JSON object concatenates onto line 1.
|
||||
const externalLine = JSON.stringify({
|
||||
type: 'entity',
|
||||
name: 'External',
|
||||
entityType: 'person',
|
||||
observations: ['appended externally'],
|
||||
});
|
||||
await fs.appendFile(testFilePath, externalLine + '\n');
|
||||
|
||||
// A new manager instance forces a fresh loadGraph from disk
|
||||
const manager2 = new KnowledgeGraphManager(testFilePath);
|
||||
const graph = await manager2.readGraph();
|
||||
|
||||
// Both entities must load without a JSON parse error
|
||||
expect(graph.entities).toHaveLength(2);
|
||||
expect(graph.entities.map(e => e.name)).toContain('Alice');
|
||||
expect(graph.entities.map(e => e.name)).toContain('External');
|
||||
});
|
||||
|
||||
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([
|
||||
|
|
@ -515,4 +649,126 @@ describe('KnowledgeGraphManager', () => {
|
|||
expect(result.relations[0]).not.toHaveProperty('type');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadGraph validation', () => {
|
||||
it('skips corrupt entities instead of crashing search', async () => {
|
||||
const lines = [
|
||||
JSON.stringify({ type: 'entity', name: 'Alice', entityType: 'person', observations: ['works at Acme Corp'] }),
|
||||
JSON.stringify({ type: 'entity', name: 'Broken', observations: ['missing entityType'] }),
|
||||
JSON.stringify({ type: 'entity', name: 'BadObs', entityType: 'person', observations: ['ok', null] }),
|
||||
];
|
||||
await fs.writeFile(testFilePath, lines.join('\n') + '\n');
|
||||
|
||||
const graph = await manager.readGraph();
|
||||
expect(graph.entities).toHaveLength(1);
|
||||
expect(graph.entities[0].name).toBe('Alice');
|
||||
|
||||
// searchNodes must not throw even though the file contains corrupt entries
|
||||
const result = await manager.searchNodes('Acme');
|
||||
expect(result.entities).toHaveLength(1);
|
||||
expect(result.entities[0].name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('skips corrupt relations', async () => {
|
||||
const lines = [
|
||||
JSON.stringify({ type: 'entity', name: 'Alice', entityType: 'person', observations: [] }),
|
||||
JSON.stringify({ type: 'relation', from: 'Alice', to: 'Bob' }), // missing relationType
|
||||
JSON.stringify({ type: 'relation', from: 'Alice', to: 'Bob', relationType: 'knows' }),
|
||||
];
|
||||
await fs.writeFile(testFilePath, lines.join('\n') + '\n');
|
||||
|
||||
const graph = await manager.readGraph();
|
||||
expect(graph.entities).toHaveLength(1);
|
||||
expect(graph.relations).toHaveLength(1);
|
||||
expect(graph.relations[0].relationType).toBe('knows');
|
||||
});
|
||||
|
||||
it('skips malformed JSON lines', async () => {
|
||||
const lines = [
|
||||
JSON.stringify({ type: 'entity', name: 'Alice', entityType: 'person', observations: [] }),
|
||||
'{this is not valid json',
|
||||
JSON.stringify({ type: 'entity', name: 'Bob', entityType: 'person', observations: [] }),
|
||||
];
|
||||
await fs.writeFile(testFilePath, lines.join('\n') + '\n');
|
||||
|
||||
const graph = await manager.readGraph();
|
||||
expect(graph.entities).toHaveLength(2);
|
||||
expect(graph.entities.map(e => e.name)).toEqual(['Alice', 'Bob']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('concurrent mutations', () => {
|
||||
// Regression test for #1819: concurrent tool calls each independently
|
||||
// load the graph, mutate their own copy, and write it back. Without
|
||||
// serialization, whichever write lands last silently discards the
|
||||
// other's changes. All mutations below are fired without awaiting each
|
||||
// other first, simulating multiple tool calls landing close together.
|
||||
|
||||
it('should not lose entities created concurrently', async () => {
|
||||
const batch1: Entity[] = Array.from({ length: 10 }, (_, i) => ({
|
||||
name: `batch1-entity-${i}`,
|
||||
entityType: 'test',
|
||||
observations: [],
|
||||
}));
|
||||
const batch2: Entity[] = Array.from({ length: 10 }, (_, i) => ({
|
||||
name: `batch2-entity-${i}`,
|
||||
entityType: 'test',
|
||||
observations: [],
|
||||
}));
|
||||
|
||||
// Fire both concurrently instead of awaiting sequentially.
|
||||
await Promise.all([
|
||||
manager.createEntities(batch1),
|
||||
manager.createEntities(batch2),
|
||||
]);
|
||||
|
||||
const graph = await manager.readGraph();
|
||||
expect(graph.entities).toHaveLength(20);
|
||||
expect(graph.entities.map(e => e.name).sort()).toEqual(
|
||||
[...batch1, ...batch2].map(e => e.name).sort()
|
||||
);
|
||||
});
|
||||
|
||||
it('should not lose relations created concurrently with entity creation', async () => {
|
||||
await manager.createEntities([
|
||||
{ name: 'Alice', entityType: 'person', observations: [] },
|
||||
{ name: 'Bob', entityType: 'person', observations: [] },
|
||||
{ name: 'Carol', entityType: 'person', observations: [] },
|
||||
]);
|
||||
|
||||
await Promise.all([
|
||||
manager.createRelations([{ from: 'Alice', to: 'Bob', relationType: 'knows' }]),
|
||||
manager.createRelations([{ from: 'Bob', to: 'Carol', relationType: 'knows' }]),
|
||||
manager.addObservations([
|
||||
{ entityName: 'Alice', contents: ['likes coffee'] },
|
||||
]),
|
||||
]);
|
||||
|
||||
const graph = await manager.readGraph();
|
||||
expect(graph.relations).toHaveLength(2);
|
||||
expect(graph.entities.find(e => e.name === 'Alice')?.observations).toContain('likes coffee');
|
||||
});
|
||||
|
||||
it('should keep the file valid JSONL after many concurrent mutations', async () => {
|
||||
const operations = Array.from({ length: 25 }, (_, i) =>
|
||||
manager.createEntities([
|
||||
{ name: `stress-entity-${i}`, entityType: 'test', observations: [] },
|
||||
])
|
||||
);
|
||||
|
||||
await Promise.all(operations);
|
||||
|
||||
const raw = await fs.readFile(testFilePath, 'utf-8');
|
||||
const lines = raw.split('\n').filter(line => line.trim() !== '');
|
||||
|
||||
// Every line must parse as valid JSON; a corrupted interleaved write
|
||||
// would produce a truncated or malformed line here.
|
||||
for (const line of lines) {
|
||||
expect(() => JSON.parse(line)).not.toThrow();
|
||||
}
|
||||
|
||||
const graph = await manager.readGraph();
|
||||
expect(graph.entities).toHaveLength(25);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
25
src/memory/__tests__/search-nodes-schema.test.ts
Normal file
25
src/memory/__tests__/search-nodes-schema.test.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { SearchNodesQuerySchema, SEARCH_QUERY_MAX_LENGTH } from '../index.js';
|
||||
|
||||
describe('search_nodes input schema', () => {
|
||||
it('should accept a normal query', () => {
|
||||
expect(SearchNodesQuerySchema.safeParse('Alice').success).toBe(true);
|
||||
expect(SearchNodesQuerySchema.safeParse('works at Acme Corp').success).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept a query at exactly the max length', () => {
|
||||
const atLimit = 'a'.repeat(SEARCH_QUERY_MAX_LENGTH);
|
||||
expect(SearchNodesQuerySchema.safeParse(atLimit).success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject a query longer than the max length', () => {
|
||||
const oversized = 'a'.repeat(SEARCH_QUERY_MAX_LENGTH + 1);
|
||||
const result = SearchNodesQuerySchema.safeParse(oversized);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should still reject non-string input', () => {
|
||||
expect(SearchNodesQuerySchema.safeParse(42).success).toBe(false);
|
||||
expect(SearchNodesQuerySchema.safeParse(null).success).toBe(false);
|
||||
});
|
||||
});
|
||||
25
src/memory/__tests__/server-version.test.ts
Normal file
25
src/memory/__tests__/server-version.test.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { resolvePackageVersion, SERVER_VERSION } from '../version.js';
|
||||
|
||||
const packageJson = createRequire(import.meta.url)('../package.json') as { version: string };
|
||||
|
||||
describe('server version', () => {
|
||||
it('uses package.json version for serverInfo', () => {
|
||||
expect(SERVER_VERSION).toBe(packageJson.version);
|
||||
expect(resolvePackageVersion()).toBe(packageJson.version);
|
||||
});
|
||||
|
||||
it('resolves package.json from the dist layout', () => {
|
||||
const distDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist');
|
||||
const distVersionPath = path.join(distDir, 'version.js');
|
||||
|
||||
expect(() => createRequire(distVersionPath)('./version.js')).not.toThrow();
|
||||
const distModule = createRequire(distVersionPath)('./version.js') as {
|
||||
SERVER_VERSION: string;
|
||||
};
|
||||
expect(distModule.SERVER_VERSION).toBe(packageJson.version);
|
||||
});
|
||||
});
|
||||
|
|
@ -6,19 +6,35 @@ import { SubscribeRequestSchema, UnsubscribeRequestSchema } from "@modelcontextp
|
|||
import { z } from "zod";
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { SERVER_VERSION } from './version.js';
|
||||
|
||||
// Define memory file path using environment variable with fallback
|
||||
export const defaultMemoryPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'memory.jsonl');
|
||||
|
||||
// Expand a leading "~" to the user's home directory. MCP clients pass
|
||||
// MEMORY_FILE_PATH from JSON config, where no shell performs tilde expansion,
|
||||
// so an unexpanded "~" would otherwise be treated as a relative path and
|
||||
// joined onto the package directory. Mirrors the helper of the same name in
|
||||
// the filesystem server (src/filesystem/path-utils.ts).
|
||||
export function expandHome(filepath: string): string {
|
||||
if (filepath.startsWith('~/') || filepath === '~') {
|
||||
return path.join(os.homedir(), filepath.slice(1));
|
||||
}
|
||||
return filepath;
|
||||
}
|
||||
|
||||
// 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);
|
||||
// Custom path provided. Expand a leading "~" first, then resolve relative
|
||||
// paths against the package directory (absolute paths are used as-is).
|
||||
const customPath = expandHome(process.env.MEMORY_FILE_PATH);
|
||||
return path.isAbsolute(customPath)
|
||||
? customPath
|
||||
: path.join(path.dirname(fileURLToPath(import.meta.url)), customPath);
|
||||
}
|
||||
|
||||
// No custom path set, check for backward compatibility migration
|
||||
|
|
@ -70,28 +86,71 @@ export interface KnowledgeGraph {
|
|||
export class KnowledgeGraphManager {
|
||||
constructor(private memoryFilePath: string) {}
|
||||
|
||||
// Serializes all read-modify-write graph mutations behind a single queue.
|
||||
// Without this, concurrent tool calls (e.g. multiple mutations dispatched
|
||||
// from one LLM turn) each independently load the graph, mutate their own
|
||||
// copy, and write it back — so whichever write lands last silently
|
||||
// overwrites the other's changes, and interleaved writes to the same file
|
||||
// can corrupt it outright. See #1819.
|
||||
private mutationQueue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
private async withLock<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = this.mutationQueue.then(operation, operation);
|
||||
// Always resolve the queue itself, even if this operation failed, so a
|
||||
// single failed mutation doesn't permanently wedge every call after it.
|
||||
// The failure still propagates normally to whoever awaited `result`.
|
||||
this.mutationQueue = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
const graph: KnowledgeGraph = { entities: [], relations: [] };
|
||||
|
||||
for (const line of lines) {
|
||||
let item: unknown;
|
||||
try {
|
||||
item = JSON.parse(line);
|
||||
} catch {
|
||||
console.error("Skipping malformed line in memory file");
|
||||
continue;
|
||||
}
|
||||
if (item.type === "relation") {
|
||||
graph.relations.push({
|
||||
from: item.from,
|
||||
to: item.to,
|
||||
relationType: item.relationType
|
||||
});
|
||||
|
||||
if (typeof item !== "object" || item === null) {
|
||||
console.error("Skipping non-object line in memory file");
|
||||
continue;
|
||||
}
|
||||
return graph;
|
||||
}, { entities: [], relations: [] });
|
||||
|
||||
const record = item as Record<string, unknown>;
|
||||
if (record.type === "entity") {
|
||||
const parsed = EntitySchema.safeParse(item);
|
||||
if (parsed.success) {
|
||||
graph.entities.push(parsed.data);
|
||||
} else {
|
||||
console.error(
|
||||
"Skipping invalid entity in memory file:",
|
||||
parsed.error.issues.map(issue => `${issue.path.join(".")}: ${issue.message}`).join(", ")
|
||||
);
|
||||
}
|
||||
} else if (record.type === "relation") {
|
||||
const parsed = RelationSchema.safeParse(item);
|
||||
if (parsed.success) {
|
||||
graph.relations.push(parsed.data);
|
||||
} else {
|
||||
console.error(
|
||||
"Skipping invalid relation in memory file:",
|
||||
parsed.error.issues.map(issue => `${issue.path.join(".")}: ${issue.message}`).join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return graph;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && (error as any).code === "ENOENT") {
|
||||
return { entities: [], relations: [] };
|
||||
|
|
@ -131,7 +190,7 @@ export class KnowledgeGraphManager {
|
|||
);
|
||||
|
||||
try {
|
||||
await fs.writeFile(tempFilePath, lines.join("\n"));
|
||||
await fs.writeFile(tempFilePath, lines.join("\n") + "\n");
|
||||
await fs.rename(tempFilePath, this.memoryFilePath);
|
||||
} catch (error) {
|
||||
// Never leave a stray temp file behind on failure.
|
||||
|
|
@ -141,66 +200,110 @@ export class KnowledgeGraphManager {
|
|||
}
|
||||
|
||||
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;
|
||||
return this.withLock(async () => {
|
||||
const graph = await this.loadGraph();
|
||||
const newEntities = entities.filter((e, index) =>
|
||||
!graph.entities.some(existingEntity => existingEntity.name === e.name) &&
|
||||
// Also skip duplicates appearing earlier in this same batch
|
||||
!entities.slice(0, index).some(earlier => earlier.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;
|
||||
return this.withLock(async () => {
|
||||
const graph = await this.loadGraph();
|
||||
const entityNames = new Set(graph.entities.map(e => e.name));
|
||||
|
||||
relations.forEach(r => {
|
||||
if (!entityNames.has(r.from)) {
|
||||
throw new Error(`Entity with name ${r.from} not found`);
|
||||
}
|
||||
if (!entityNames.has(r.to)) {
|
||||
throw new Error(`Entity with name ${r.to} not found`);
|
||||
}
|
||||
});
|
||||
|
||||
const isSameRelation = (a: Relation, b: Relation) =>
|
||||
a.from === b.from &&
|
||||
a.to === b.to &&
|
||||
a.relationType === b.relationType;
|
||||
const newRelations = relations.filter((r, index) =>
|
||||
!graph.relations.some(existingRelation => isSameRelation(existingRelation, r)) &&
|
||||
// Also skip duplicates appearing earlier in this same batch
|
||||
!relations.slice(0, index).some(earlier => isSameRelation(earlier, r))
|
||||
);
|
||||
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 };
|
||||
return this.withLock(async () => {
|
||||
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;
|
||||
});
|
||||
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));
|
||||
}
|
||||
async deleteEntities(entityNames: string[]): Promise<{ deleted: string[]; notFound: string[] }> {
|
||||
return this.withLock(async () => {
|
||||
const graph = await this.loadGraph();
|
||||
const present = new Set(graph.entities.map(e => e.name));
|
||||
const deleted = entityNames.filter(name => present.has(name));
|
||||
const notFound = entityNames.filter(name => !present.has(name));
|
||||
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);
|
||||
return { deleted, notFound };
|
||||
});
|
||||
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 deleteObservations(deletions: { entityName: string; observations: string[] }[]): Promise<{ deletedCount: number; missingEntities: string[] }> {
|
||||
return this.withLock(async () => {
|
||||
const graph = await this.loadGraph();
|
||||
let deletedCount = 0;
|
||||
const missingEntities: string[] = [];
|
||||
deletions.forEach(d => {
|
||||
const entity = graph.entities.find(e => e.name === d.entityName);
|
||||
if (entity) {
|
||||
const before = entity.observations.length;
|
||||
entity.observations = entity.observations.filter(o => !d.observations.includes(o));
|
||||
deletedCount += before - entity.observations.length;
|
||||
} else {
|
||||
missingEntities.push(d.entityName);
|
||||
}
|
||||
});
|
||||
await this.saveGraph(graph);
|
||||
return { deletedCount, missingEntities };
|
||||
});
|
||||
}
|
||||
|
||||
async deleteRelations(relations: Relation[]): Promise<{ deletedCount: number }> {
|
||||
return this.withLock(async () => {
|
||||
const graph = await this.loadGraph();
|
||||
const before = graph.relations.length;
|
||||
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);
|
||||
return { deletedCount: before - graph.relations.length };
|
||||
});
|
||||
}
|
||||
|
||||
async readGraph(): Promise<KnowledgeGraph> {
|
||||
|
|
@ -276,10 +379,9 @@ const RelationSchema = z.object({
|
|||
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",
|
||||
version: SERVER_VERSION,
|
||||
});
|
||||
|
||||
const RESOURCE_URI = "memory://knowledge-graph";
|
||||
|
|
@ -410,11 +512,14 @@ server.registerTool(
|
|||
}
|
||||
},
|
||||
async ({ entityNames }) => {
|
||||
await knowledgeGraphManager.deleteEntities(entityNames);
|
||||
const { deleted, notFound } = await knowledgeGraphManager.deleteEntities(entityNames);
|
||||
notifyGraphUpdated();
|
||||
const message = notFound.length === 0
|
||||
? "Entities deleted successfully"
|
||||
: `Deleted ${deleted.length} of ${entityNames.length} entities. Not found: ${notFound.join(", ")}`;
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "Entities deleted successfully" }],
|
||||
structuredContent: { success: true, message: "Entities deleted successfully" }
|
||||
content: [{ type: "text" as const, text: message }],
|
||||
structuredContent: { success: true, message }
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
@ -443,11 +548,16 @@ server.registerTool(
|
|||
}
|
||||
},
|
||||
async ({ deletions }) => {
|
||||
await knowledgeGraphManager.deleteObservations(deletions);
|
||||
const { deletedCount, missingEntities } = await knowledgeGraphManager.deleteObservations(deletions);
|
||||
notifyGraphUpdated();
|
||||
const requested = deletions.reduce((total, d) => total + d.observations.length, 0);
|
||||
const message = deletedCount === requested
|
||||
? "Observations deleted successfully"
|
||||
: `Deleted ${deletedCount} of ${requested} observations.` +
|
||||
(missingEntities.length ? ` Entities not found: ${missingEntities.join(", ")}` : "");
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "Observations deleted successfully" }],
|
||||
structuredContent: { success: true, message: "Observations deleted successfully" }
|
||||
content: [{ type: "text" as const, text: message }],
|
||||
structuredContent: { success: true, message }
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
@ -473,11 +583,14 @@ server.registerTool(
|
|||
}
|
||||
},
|
||||
async ({ relations }) => {
|
||||
await knowledgeGraphManager.deleteRelations(relations);
|
||||
const { deletedCount } = await knowledgeGraphManager.deleteRelations(relations);
|
||||
notifyGraphUpdated();
|
||||
const message = deletedCount === relations.length
|
||||
? "Relations deleted successfully"
|
||||
: `Deleted ${deletedCount} of ${relations.length} relations. The rest matched nothing.`;
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "Relations deleted successfully" }],
|
||||
structuredContent: { success: true, message: "Relations deleted successfully" }
|
||||
content: [{ type: "text" as const, text: message }],
|
||||
structuredContent: { success: true, message }
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
@ -509,6 +622,13 @@ server.registerTool(
|
|||
}
|
||||
);
|
||||
|
||||
export const SEARCH_QUERY_MAX_LENGTH = 2048;
|
||||
|
||||
export const SearchNodesQuerySchema = z
|
||||
.string()
|
||||
.max(SEARCH_QUERY_MAX_LENGTH)
|
||||
.describe("The search query to match against entity names, types, and observation content");
|
||||
|
||||
// Register search_nodes tool
|
||||
server.registerTool(
|
||||
"search_nodes",
|
||||
|
|
@ -516,7 +636,7 @@ server.registerTool(
|
|||
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")
|
||||
query: SearchNodesQuerySchema
|
||||
},
|
||||
outputSchema: {
|
||||
entities: z.array(EntitySchema),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/server-memory",
|
||||
"version": "2026.8.31",
|
||||
"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",
|
||||
|
|
@ -25,7 +25,8 @@
|
|||
"test": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.30.0"
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22",
|
||||
|
|
|
|||
27
src/memory/version.ts
Normal file
27
src/memory/version.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { createRequire } from 'node:module';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
export function resolvePackageVersion(): string {
|
||||
const require = createRequire(import.meta.url);
|
||||
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const candidates = [
|
||||
path.join(moduleDir, 'package.json'),
|
||||
path.join(moduleDir, '..', 'package.json'),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const pkg = require(candidate) as { version?: string };
|
||||
if (pkg.version) {
|
||||
return pkg.version;
|
||||
}
|
||||
} catch {
|
||||
// Try the next candidate when running from dist/ or source.
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Could not locate package.json for server version');
|
||||
}
|
||||
|
||||
export const SERVER_VERSION = resolvePackageVersion();
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/server-sequential-thinking",
|
||||
"version": "2026.8.31",
|
||||
"version": "0.6.2",
|
||||
"description": "MCP server for sequential thinking and problem solving",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"mcpName": "io.github.modelcontextprotocol/server-sequential-thinking",
|
||||
|
|
@ -27,7 +27,8 @@
|
|||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"chalk": "^5.3.0",
|
||||
"yargs": "^17.7.2"
|
||||
"yargs": "^17.7.2",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22",
|
||||
|
|
@ -37,4 +38,4 @@
|
|||
"typescript": "^5.3.3",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue