Compare commits

..
Author SHA1 Message Date
GitHub Actions
a40bc270fb Automatic update of packages
Some checks failed
Python / detect-packages (push) Has been cancelled
TypeScript / detect-packages (push) Has been cancelled
Python / Test (push) Has been cancelled
Python / Build (push) Has been cancelled
TypeScript / Test (push) Has been cancelled
TypeScript / Build (push) Has been cancelled
2026-08-31 18:37:07 +00:00
16 changed files with 143 additions and 804 deletions

View file

@ -1,10 +1,7 @@
name: README PR Check
on:
# 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:
pull_request:
types: [opened]
paths:
- 'README.md'
@ -13,7 +10,7 @@ on:
jobs:
check-readme-only:
if: github.event_name == 'pull_request_target'
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read

9
package-lock.json generated
View file

@ -3866,8 +3866,7 @@
"@modelcontextprotocol/sdk": "^1.30.0",
"diff": "^8.0.3",
"glob": "^13.0.6",
"minimatch": "^10.0.1",
"zod": "^4.0.0"
"minimatch": "^10.0.1"
},
"bin": {
"mcp-server-filesystem": "dist/index.js"
@ -3887,8 +3886,7 @@
"version": "0.6.3",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
"zod": "^4.0.0"
"@modelcontextprotocol/sdk": "^1.30.0"
},
"bin": {
"mcp-server-memory": "dist/index.js"
@ -3908,8 +3906,7 @@
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
"chalk": "^5.3.0",
"yargs": "^17.7.2",
"zod": "^4.0.0"
"yargs": "^17.7.2"
},
"bin": {
"mcp-server-sequential-thinking": "dist/index.js"

View file

@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/server-everything",
"version": "2.0.0",
"version": "2026.8.31",
"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"
}
}
}

View file

@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/server-filesystem",
"version": "0.6.3",
"version": "2026.8.31",
"description": "MCP server for filesystem access",
"license": "SEE LICENSE IN LICENSE",
"mcpName": "io.github.modelcontextprotocol/server-filesystem",
@ -28,8 +28,7 @@
"@modelcontextprotocol/sdk": "^1.30.0",
"diff": "^8.0.3",
"glob": "^13.0.6",
"minimatch": "^10.0.1",
"zod": "^4.0.0"
"minimatch": "^10.0.1"
},
"devDependencies": {
"@types/diff": "^5.0.9",
@ -40,4 +39,4 @@
"typescript": "^5.8.2",
"vitest": "^4.1.8"
}
}
}

View file

@ -1,6 +1,6 @@
import logging
from pathlib import Path
from typing import Any, Optional, Sequence
from typing import Sequence, Optional
from mcp.server import Server
from mcp.server.session import ServerSession
from mcp.server.stdio import stdio_server
@ -157,28 +157,45 @@ 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]:
# 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 '-'")
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'])
kwargs: dict[str, Any] = {"max_count": max_count}
if start_timestamp:
kwargs["since"] = start_timestamp
if end_timestamp:
kwargs["until"] = end_timestamp
log_output = repo.git.log(*args).split('\n')
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
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
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
@ -582,4 +599,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)
await server.run(read_stream, write_stream, options, raise_exceptions=True)

View file

@ -16,10 +16,8 @@ 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):
@ -510,81 +508,3 @@ 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)

View file

@ -72,7 +72,6 @@ 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
@ -87,7 +86,7 @@ Example:
- Remove entities and their relations
- Input: `entityNames` (string[])
- Cascading deletion of associated relations
- No error if an entity doesn't exist; the response reports which names were not found
- Silent operation if entity doesn't exist
- **delete_observations**
- Remove specific observations from entities
@ -95,7 +94,7 @@ Example:
- Each object contains:
- `entityName` (string): Target entity
- `observations` (string[]): Observations to remove
- No error if an observation doesn't exist; the response reports how many were deleted
- Silent operation if observation doesn't exist
- **delete_relations**
- Remove specific relations from the graph
@ -104,7 +103,7 @@ Example:
- `from` (string): Source entity name
- `to` (string): Target entity name
- `relationType` (string): Relationship type
- No error if a relation doesn't exist; the response reports how many were deleted
- Silent operation if relation doesn't exist
- **read_graph**
- Read the entire knowledge graph

View file

@ -1,99 +0,0 @@
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);
});
});
});

View file

@ -1,9 +1,8 @@
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, expandHome } from '../index.js';
import { ensureMemoryFilePath, defaultMemoryPath } from '../index.js';
describe('ensureMemoryFilePath', () => {
const testDir = path.dirname(fileURLToPath(import.meta.url));
@ -73,15 +72,6 @@ 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', () => {
@ -164,29 +154,3 @@ 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')
);
});
});

View file

@ -59,20 +59,6 @@ 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', () => {
@ -113,60 +99,10 @@ 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', () => {
@ -210,11 +146,11 @@ describe('KnowledgeGraphManager', () => {
});
it('should throw error for non-existent entity', async () => {
await expect(async () => {
await manager.addObservations([
await expect(
manager.addObservations([
{ entityName: 'NonExistent', contents: ['some observation'] },
]);
}).rejects.toThrow('Entity with name NonExistent not found');
])
).rejects.toThrow('Entity with name NonExistent not found');
});
});
@ -490,76 +426,6 @@ 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([
@ -649,126 +515,4 @@ 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);
});
});
});

View file

@ -1,25 +0,0 @@
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);
});
});

View file

@ -1,25 +0,0 @@
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);
});
});

View file

@ -6,35 +6,19 @@ 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. 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);
// 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
@ -86,71 +70,28 @@ 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() !== "");
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;
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 (typeof item !== "object" || item === null) {
console.error("Skipping non-object line in memory file");
continue;
if (item.type === "relation") {
graph.relations.push({
from: item.from,
to: item.to,
relationType: item.relationType
});
}
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;
return graph;
}, { entities: [], relations: [] });
} catch (error) {
if (error instanceof Error && 'code' in error && (error as any).code === "ENOENT") {
return { entities: [], relations: [] };
@ -190,7 +131,7 @@ export class KnowledgeGraphManager {
);
try {
await fs.writeFile(tempFilePath, lines.join("\n") + "\n");
await fs.writeFile(tempFilePath, lines.join("\n"));
await fs.rename(tempFilePath, this.memoryFilePath);
} catch (error) {
// Never leave a stray temp file behind on failure.
@ -200,110 +141,66 @@ export class KnowledgeGraphManager {
}
async createEntities(entities: Entity[]): Promise<Entity[]> {
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;
});
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[]> {
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;
});
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[] }[]> {
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;
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<{ 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 };
});
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<{ 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 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<{ 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 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> {
@ -379,9 +276,10 @@ 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: SERVER_VERSION,
version: "0.6.3",
});
const RESOURCE_URI = "memory://knowledge-graph";
@ -512,14 +410,11 @@ server.registerTool(
}
},
async ({ entityNames }) => {
const { deleted, notFound } = await knowledgeGraphManager.deleteEntities(entityNames);
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: message }],
structuredContent: { success: true, message }
content: [{ type: "text" as const, text: "Entities deleted successfully" }],
structuredContent: { success: true, message: "Entities deleted successfully" }
};
}
);
@ -548,16 +443,11 @@ server.registerTool(
}
},
async ({ deletions }) => {
const { deletedCount, missingEntities } = await knowledgeGraphManager.deleteObservations(deletions);
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: message }],
structuredContent: { success: true, message }
content: [{ type: "text" as const, text: "Observations deleted successfully" }],
structuredContent: { success: true, message: "Observations deleted successfully" }
};
}
);
@ -583,14 +473,11 @@ server.registerTool(
}
},
async ({ relations }) => {
const { deletedCount } = await knowledgeGraphManager.deleteRelations(relations);
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: message }],
structuredContent: { success: true, message }
content: [{ type: "text" as const, text: "Relations deleted successfully" }],
structuredContent: { success: true, message: "Relations deleted successfully" }
};
}
);
@ -622,13 +509,6 @@ 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",
@ -636,7 +516,7 @@ server.registerTool(
title: "Search Nodes",
description: "Search for nodes in the knowledge graph based on a query",
inputSchema: {
query: SearchNodesQuerySchema
query: z.string().describe("The search query to match against entity names, types, and observation content")
},
outputSchema: {
entities: z.array(EntitySchema),

View file

@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/server-memory",
"version": "0.6.3",
"version": "2026.8.31",
"description": "MCP server for enabling memory for Claude through a knowledge graph",
"license": "SEE LICENSE IN LICENSE",
"mcpName": "io.github.modelcontextprotocol/server-memory",
@ -25,8 +25,7 @@
"test": "vitest run --coverage"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
"zod": "^4.0.0"
"@modelcontextprotocol/sdk": "^1.30.0"
},
"devDependencies": {
"@types/node": "^22",

View file

@ -1,27 +0,0 @@
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();

View file

@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/server-sequential-thinking",
"version": "0.6.2",
"version": "2026.8.31",
"description": "MCP server for sequential thinking and problem solving",
"license": "SEE LICENSE IN LICENSE",
"mcpName": "io.github.modelcontextprotocol/server-sequential-thinking",
@ -27,8 +27,7 @@
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
"chalk": "^5.3.0",
"yargs": "^17.7.2",
"zod": "^4.0.0"
"yargs": "^17.7.2"
},
"devDependencies": {
"@types/node": "^22",
@ -38,4 +37,4 @@
"typescript": "^5.3.3",
"vitest": "^4.1.8"
}
}
}