Compare commits

..
Author SHA1 Message Date
Nandini-Inuguru
d73f99efbf
fix(memory): serialize graph mutations to prevent concurrent write race (#4555)
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
createEntities, createRelations, addObservations, deleteEntities,
deleteObservations, and deleteRelations each independently did
load -> mutate -> save with no synchronization. Concurrent tool
calls (e.g. multiple mutations dispatched from one LLM turn) could
race: both read the same starting state, both write back their own
copy, and whichever write landed last silently discarded the other's
changes. Interleaved writes could also corrupt the file outright.

Adds an in-process async mutex (KnowledgeGraphManager.withLock) that
serializes all six mutation methods through a single queue. Read-only
methods (readGraph, searchNodes, openNodes) are unaffected.

Verified: reverting the fix and re-running the new concurrency tests
reproduces the bug exactly (lost entities, lost relations, malformed
JSONL lines). With the fix, all 39 tests pass.

Fixes #1819

Co-authored-by: olaservo <olahungerford@gmail.com>
2026-09-02 18:42:26 -07:00
JSap0914
1ec570c27d
fix(memory): skip duplicate entities and relations within a single batch (#4383)
create_entities and create_relations only de-duplicated against the existing
graph, so passing the same entity name (or identical relation) twice in one
call persisted duplicate records. This contradicts the documented behavior
("Ignores entities with existing names" / "Skips duplicate relations") and
breaks the implicit name-uniqueness invariant the rest of the manager relies
on (e.g. addObservations/deleteEntities key on name).

De-duplicate within the input batch as well, keeping the first occurrence.

Co-authored-by: JSap0914 <JSap0914@users.noreply.github.com>
Co-authored-by: olaservo <olahungerford@gmail.com>
2026-09-02 18:38:22 -07:00
fei
f41b666edf
fix(memory): constrain search_nodes query length (#4662)
The search_nodes tool accepted an unbounded query string (per
modelcontextprotocol/servers#3537, official servers should constrain
string parameters). An oversized query costs an O(graph) scan per
call with no value. Cap it at 2048 chars via an exported
SearchNodesQuerySchema, and add vitest coverage for the boundary
(at-limit accepted, over-limit rejected, non-string still rejected).

Signed-off-by: fei <204683769+feiiiiii5@users.noreply.github.com>
2026-09-02 18:35:03 -07:00
Yiheng Zhao
c3d8e43d1f
fix(memory): validate knowledge graph entries when loading from disk (#4717)
loadGraph() trusted the persisted memory file and pushed entities and
relations without validating their fields. A corrupted or legacy entry
(e.g. an entity missing entityType, or an observation that is not a string)
would reach searchNodes and crash with "Cannot read properties of undefined
(reading 'toLowerCase')".

Validate each line against the existing EntitySchema/RelationSchema and skip
malformed entries with a warning, so the in-memory graph only ever contains
well-formed data. Malformed JSON lines are skipped as well.

Fixes #2044
2026-09-02 18:34:58 -07:00
Connor Moss
abbbddb2e3
fix(memory): stop reporting deletions that did not happen (#4738)
delete_entities, delete_observations and delete_relations returned
success: true with a hardcoded "deleted successfully" message regardless
of what matched. An agent that mistypes an entity name is told its memory
is clean while the data is still on disk, and nothing in the response
contradicts that. addObservations throws for the same condition ten lines
above, so the file already disagreed with itself.

Staying quiet is deliberate and documented, so nothing throws and the
output schema is unchanged. The three manager methods now return what
they matched, and the handlers say so. A delete where everything is found
returns the same message it always did.

README updated: the three "Silent operation" bullets described the
absence of an error, which is still true, but read as if the response
said nothing either.
2026-09-02 18:34:53 -07:00
Amey Pawar
649af5856e
fix(memory): expand leading ~ in MEMORY_FILE_PATH to the home directory (#4447)
* fix(memory): expand leading ~ in MEMORY_FILE_PATH to the home directory

MCP clients pass MEMORY_FILE_PATH from JSON config, where no shell
expands a leading "~". Because path.isAbsolute("~/memory.jsonl") is
false, the value was joined onto the package directory, so the server
persisted to a literal "~" folder inside the install instead of the
user's intended location.

Add an expandHome() helper that mirrors the one already used by the
filesystem server (src/filesystem/path-utils.ts) and apply it in
ensureMemoryFilePath() before the existing absolute/relative resolution.
Absolute paths, relative paths, and a "~" not at the start are
unaffected. Adds unit tests for expandHome plus an end-to-end test
through ensureMemoryFilePath.

Addresses #1600

* test(memory): use a literal ~/ path in the tilde expansion test

path.join('~', ...) produces a backslash on Windows, which expandHome does
not match, so the test failed there.

---------

Co-authored-by: olaservo <olahungerford@gmail.com>
2026-09-02 18:15:44 -07:00
Nick Veenhof
242751e8ee
fix(memory): add trailing newline to JSONL output (#3653)
Co-authored-by: Nick Veenhof <nveenhof@gitlab.com>
Co-authored-by: olaservo <olahungerford@gmail.com>
2026-09-02 18:15:35 -07:00
Yufeng He
ba167cef5b
fix: declare zod runtime dependencies (#4289)
Co-authored-by: olaservo <olahungerford@gmail.com>
2026-09-02 18:15:26 -07:00
King Star
48723c95dc
fix(memory): reject dangling relations (#4477)
Signed-off-by: King Star <mcxin.y@gmail.com>
2026-09-02 08:04:34 -07:00
石岳峰
96c49c7364
fix(memory): read serverInfo.version from package.json (#4407)
Fixes #4406. The memory server previously hardcoded serverInfo.version as
"0.6.3", which no longer matched published calendar versions. Resolve
package.json from both source and dist layouts so Docker and npm installs
report the installed package version.

Co-authored-by: syf2211 <syf2211@users.noreply.github.com>
2026-09-02 08:04:28 -07:00
Yusufhan Sacak
cce08de417
fix(memory): resolve Vitest false positive for expected rejection (#3893)
Wrap the addObservations call in an async closure before asserting with
.rejects.toThrow(). This prevents Vitest's global unhandled rejection
handler from capturing the expected error before the assertion can
intercept it, eliminating the false-positive "Unhandled Errors" warning
that was failing CI.

Fixes #3073
2026-09-02 08:03:51 -07:00
Ola Hungerford
2e3e4c7ab7
ci: run readme-pr-check on pull_request_target so fork PRs can be labeled (#4739)
Observed on #4714 and #4726: addLabels returned 403 "Resource not
accessible by integration" because pull_request gives fork PRs a
read-only token. The job only calls the GitHub API and never checks
out PR code, so pull_request_target is safe here.


Claude-Session: https://claude.ai/code/session_01CK8vvB229h6orvkokG8YxU

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 20:01:29 -07:00
Abhinav Prakash
3aa19f5574
fix(git): unify git_log output schema and remove raise_exceptions from server.run (#4658) 2026-09-01 19:25:31 -07:00
16 changed files with 804 additions and 143 deletions

View file

@ -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
View file

@ -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"

View file

@ -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"
}
}
}

View file

@ -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"
}
}
}

View file

@ -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)

View file

@ -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)

View file

@ -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

View 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);
});
});
});

View file

@ -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')
);
});
});

View file

@ -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);
});
});
});

View 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);
});
});

View 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);
});
});

View file

@ -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),

View file

@ -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
View 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();

View file

@ -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"
}
}
}