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

9
package-lock.json generated
View file

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

View file

@ -1,6 +1,6 @@
{ {
"name": "@modelcontextprotocol/server-everything", "name": "@modelcontextprotocol/server-everything",
"version": "2.0.0", "version": "2026.8.31",
"description": "MCP server that exercises all the features of the MCP protocol", "description": "MCP server that exercises all the features of the MCP protocol",
"license": "SEE LICENSE IN LICENSE", "license": "SEE LICENSE IN LICENSE",
"mcpName": "io.github.modelcontextprotocol/server-everything", "mcpName": "io.github.modelcontextprotocol/server-everything",

View file

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

View file

@ -1,6 +1,6 @@
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Any, Optional, Sequence from typing import Sequence, Optional
from mcp.server import Server from mcp.server import Server
from mcp.server.session import ServerSession from mcp.server.session import ServerSession
from mcp.server.stdio import stdio_server from mcp.server.stdio import stdio_server
@ -157,28 +157,45 @@ def git_reset(repo: git.Repo) -> str:
return "All staged changes reset" 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]: 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 or end_timestamp:
if start_timestamp and start_timestamp.startswith("-"): # Defense in depth: reject timestamps starting with '-' to prevent flag injection
raise ValueError(f"Invalid start_timestamp: '{start_timestamp}' - cannot start with '-'") if start_timestamp and start_timestamp.startswith("-"):
if end_timestamp and end_timestamp.startswith("-"): raise ValueError(f"Invalid start_timestamp: '{start_timestamp}' - cannot start with '-'")
raise ValueError(f"Invalid end_timestamp: '{end_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} log_output = repo.git.log(*args).split('\n')
if start_timestamp:
kwargs["since"] = start_timestamp
if end_timestamp:
kwargs["until"] = end_timestamp
commits = list(repo.iter_commits(**kwargs)) log = []
log = [] # Process commits in groups of 4 (hash, author, date, message)
for commit in commits: for i in range(0, len(log_output), 4):
log.append( if i + 3 < len(log_output) and len(log) < max_count:
f"Commit: {commit.hexsha}\n" log.append(
f"Author: {commit.author}\n" f"Commit: {log_output[i]}\n"
f"Date: {commit.authored_datetime}\n" f"Author: {log_output[i+1]}\n"
f"Message: {commit.message}\n" f"Date: {log_output[i+2]}\n"
) f"Message: {log_output[i+3]}\n"
return log )
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: 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 # 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() options = server.create_initialization_options()
async with stdio_server() as (read_stream, write_stream): 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_create_branch,
git_show, git_show,
validate_repo_path, validate_repo_path,
serve,
) )
import shutil import shutil
import unittest.mock as mock
@pytest.fixture @pytest.fixture
def test_repository(tmp_path: Path): def test_repository(tmp_path: Path):
@ -510,81 +508,3 @@ def test_git_branch_rejects_contains_flag_injection(test_repository):
with pytest.raises(BadName): with pytest.raises(BadName):
git_branch(test_repository, "local", not_contains="--exec=evil") 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 - `to` (string): Target entity name
- `relationType` (string): Relationship type in active voice - `relationType` (string): Relationship type in active voice
- Skips duplicate relations - Skips duplicate relations
- Fails if either the source or target entity doesn't exist
- **add_observations** - **add_observations**
- Add new observations to existing entities - Add new observations to existing entities
@ -87,7 +86,7 @@ Example:
- Remove entities and their relations - Remove entities and their relations
- Input: `entityNames` (string[]) - Input: `entityNames` (string[])
- Cascading deletion of associated relations - 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** - **delete_observations**
- Remove specific observations from entities - Remove specific observations from entities
@ -95,7 +94,7 @@ Example:
- Each object contains: - Each object contains:
- `entityName` (string): Target entity - `entityName` (string): Target entity
- `observations` (string[]): Observations to remove - `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** - **delete_relations**
- Remove specific relations from the graph - Remove specific relations from the graph
@ -104,7 +103,7 @@ Example:
- `from` (string): Source entity name - `from` (string): Source entity name
- `to` (string): Target entity name - `to` (string): Target entity name
- `relationType` (string): Relationship type - `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_graph**
- Read the entire knowledge 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 { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import path from 'path'; import path from 'path';
import os from 'os';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { ensureMemoryFilePath, defaultMemoryPath, expandHome } from '../index.js'; import { ensureMemoryFilePath, defaultMemoryPath } from '../index.js';
describe('ensureMemoryFilePath', () => { describe('ensureMemoryFilePath', () => {
const testDir = path.dirname(fileURLToPath(import.meta.url)); const testDir = path.dirname(fileURLToPath(import.meta.url));
@ -73,15 +72,6 @@ describe('ensureMemoryFilePath', () => {
expect(path.isAbsolute(result)).toBe(true); 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', () => { 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([]); const newEntities = await manager.createEntities([]);
expect(newEntities).toHaveLength(0); 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', () => { describe('createRelations', () => {
@ -113,60 +99,10 @@ describe('KnowledgeGraphManager', () => {
expect(graph.relations).toHaveLength(1); 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 () => { it('should handle empty relation arrays', async () => {
const newRelations = await manager.createRelations([]); const newRelations = await manager.createRelations([]);
expect(newRelations).toHaveLength(0); 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', () => { describe('addObservations', () => {
@ -210,11 +146,11 @@ describe('KnowledgeGraphManager', () => {
}); });
it('should throw error for non-existent entity', async () => { it('should throw error for non-existent entity', async () => {
await expect(async () => { await expect(
await manager.addObservations([ manager.addObservations([
{ entityName: 'NonExistent', contents: ['some observation'] }, { 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'); 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 () => { it('should strip type field from entities when loading from file', async () => {
// Create entities and relations (these get saved with type field) // Create entities and relations (these get saved with type field)
await manager.createEntities([ await manager.createEntities([
@ -649,126 +515,4 @@ describe('KnowledgeGraphManager', () => {
expect(result.relations[0]).not.toHaveProperty('type'); 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 { z } from "zod";
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import path from 'path'; import path from 'path';
import os from 'os';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { SERVER_VERSION } from './version.js';
// Define memory file path using environment variable with fallback // Define memory file path using environment variable with fallback
export const defaultMemoryPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'memory.jsonl'); 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 // Handle backward compatibility: migrate memory.json to memory.jsonl if needed
export async function ensureMemoryFilePath(): Promise<string> { export async function ensureMemoryFilePath(): Promise<string> {
if (process.env.MEMORY_FILE_PATH) { if (process.env.MEMORY_FILE_PATH) {
// Custom path provided. Expand a leading "~" first, then resolve relative // Custom path provided, use it as-is (with absolute path resolution)
// paths against the package directory (absolute paths are used as-is). return path.isAbsolute(process.env.MEMORY_FILE_PATH)
const customPath = expandHome(process.env.MEMORY_FILE_PATH); ? process.env.MEMORY_FILE_PATH
return path.isAbsolute(customPath) : path.join(path.dirname(fileURLToPath(import.meta.url)), process.env.MEMORY_FILE_PATH);
? customPath
: path.join(path.dirname(fileURLToPath(import.meta.url)), customPath);
} }
// No custom path set, check for backward compatibility migration // No custom path set, check for backward compatibility migration
@ -86,71 +70,28 @@ export interface KnowledgeGraph {
export class KnowledgeGraphManager { export class KnowledgeGraphManager {
constructor(private memoryFilePath: string) {} 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> { private async loadGraph(): Promise<KnowledgeGraph> {
try { try {
const data = await fs.readFile(this.memoryFilePath, "utf-8"); const data = await fs.readFile(this.memoryFilePath, "utf-8");
const lines = data.split("\n").filter(line => line.trim() !== ""); const lines = data.split("\n").filter(line => line.trim() !== "");
const graph: KnowledgeGraph = { entities: [], relations: [] }; return lines.reduce((graph: KnowledgeGraph, line) => {
const item = JSON.parse(line);
for (const line of lines) { if (item.type === "entity") {
let item: unknown; graph.entities.push({
try { name: item.name,
item = JSON.parse(line); entityType: item.entityType,
} catch { observations: item.observations
console.error("Skipping malformed line in memory file"); });
continue;
} }
if (item.type === "relation") {
if (typeof item !== "object" || item === null) { graph.relations.push({
console.error("Skipping non-object line in memory file"); from: item.from,
continue; to: item.to,
relationType: item.relationType
});
} }
return graph;
const record = item as Record<string, unknown>; }, { entities: [], relations: [] });
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) { } catch (error) {
if (error instanceof Error && 'code' in error && (error as any).code === "ENOENT") { if (error instanceof Error && 'code' in error && (error as any).code === "ENOENT") {
return { entities: [], relations: [] }; return { entities: [], relations: [] };
@ -190,7 +131,7 @@ export class KnowledgeGraphManager {
); );
try { try {
await fs.writeFile(tempFilePath, lines.join("\n") + "\n"); await fs.writeFile(tempFilePath, lines.join("\n"));
await fs.rename(tempFilePath, this.memoryFilePath); await fs.rename(tempFilePath, this.memoryFilePath);
} catch (error) { } catch (error) {
// Never leave a stray temp file behind on failure. // Never leave a stray temp file behind on failure.
@ -200,110 +141,66 @@ export class KnowledgeGraphManager {
} }
async createEntities(entities: Entity[]): Promise<Entity[]> { async createEntities(entities: Entity[]): Promise<Entity[]> {
return this.withLock(async () => { const graph = await this.loadGraph();
const graph = await this.loadGraph(); const newEntities = entities.filter(e => !graph.entities.some(existingEntity => existingEntity.name === e.name));
const newEntities = entities.filter((e, index) => graph.entities.push(...newEntities);
!graph.entities.some(existingEntity => existingEntity.name === e.name) && await this.saveGraph(graph);
// Also skip duplicates appearing earlier in this same batch return newEntities;
!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[]> { async createRelations(relations: Relation[]): Promise<Relation[]> {
return this.withLock(async () => { const graph = await this.loadGraph();
const graph = await this.loadGraph(); const newRelations = relations.filter(r => !graph.relations.some(existingRelation =>
const entityNames = new Set(graph.entities.map(e => e.name)); existingRelation.from === r.from &&
existingRelation.to === r.to &&
relations.forEach(r => { existingRelation.relationType === r.relationType
if (!entityNames.has(r.from)) { ));
throw new Error(`Entity with name ${r.from} not found`); graph.relations.push(...newRelations);
} await this.saveGraph(graph);
if (!entityNames.has(r.to)) { return newRelations;
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[] }[]> { async addObservations(observations: { entityName: string; contents: string[] }[]): Promise<{ entityName: string; addedObservations: string[] }[]> {
return this.withLock(async () => { const graph = await this.loadGraph();
const graph = await this.loadGraph(); const results = observations.map(o => {
const results = observations.map(o => { const entity = graph.entities.find(e => e.name === o.entityName);
const entity = graph.entities.find(e => e.name === o.entityName); if (!entity) {
if (!entity) { throw new Error(`Entity with name ${o.entityName} not found`);
throw new Error(`Entity with name ${o.entityName} not found`); }
} const newObservations = o.contents.filter(content => !entity.observations.includes(content));
const newObservations = o.contents.filter(content => !entity.observations.includes(content)); entity.observations.push(...newObservations);
entity.observations.push(...newObservations); return { entityName: o.entityName, addedObservations: newObservations };
return { entityName: o.entityName, addedObservations: newObservations };
});
await this.saveGraph(graph);
return results;
}); });
await this.saveGraph(graph);
return results;
} }
async deleteEntities(entityNames: string[]): Promise<{ deleted: string[]; notFound: string[] }> { async deleteEntities(entityNames: string[]): Promise<void> {
return this.withLock(async () => { const graph = await this.loadGraph();
const graph = await this.loadGraph(); graph.entities = graph.entities.filter(e => !entityNames.includes(e.name));
const present = new Set(graph.entities.map(e => e.name)); graph.relations = graph.relations.filter(r => !entityNames.includes(r.from) && !entityNames.includes(r.to));
const deleted = entityNames.filter(name => present.has(name)); await this.saveGraph(graph);
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 deleteObservations(deletions: { entityName: string; observations: string[] }[]): Promise<{ deletedCount: number; missingEntities: string[] }> { async deleteObservations(deletions: { entityName: string; observations: string[] }[]): Promise<void> {
return this.withLock(async () => { const graph = await this.loadGraph();
const graph = await this.loadGraph(); deletions.forEach(d => {
let deletedCount = 0; const entity = graph.entities.find(e => e.name === d.entityName);
const missingEntities: string[] = []; if (entity) {
deletions.forEach(d => { entity.observations = entity.observations.filter(o => !d.observations.includes(o));
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 };
}); });
await this.saveGraph(graph);
} }
async deleteRelations(relations: Relation[]): Promise<{ deletedCount: number }> { async deleteRelations(relations: Relation[]): Promise<void> {
return this.withLock(async () => { const graph = await this.loadGraph();
const graph = await this.loadGraph(); graph.relations = graph.relations.filter(r => !relations.some(delRelation =>
const before = graph.relations.length; r.from === delRelation.from &&
graph.relations = graph.relations.filter(r => !relations.some(delRelation => r.to === delRelation.to &&
r.from === delRelation.from && r.relationType === delRelation.relationType
r.to === delRelation.to && ));
r.relationType === delRelation.relationType await this.saveGraph(graph);
));
await this.saveGraph(graph);
return { deletedCount: before - graph.relations.length };
});
} }
async readGraph(): Promise<KnowledgeGraph> { async readGraph(): Promise<KnowledgeGraph> {
@ -379,9 +276,10 @@ const RelationSchema = z.object({
relationType: z.string().describe("The type of the relation") relationType: z.string().describe("The type of the relation")
}); });
// The server instance and tools exposed to Claude
const server = new McpServer({ const server = new McpServer({
name: "memory-server", name: "memory-server",
version: SERVER_VERSION, version: "0.6.3",
}); });
const RESOURCE_URI = "memory://knowledge-graph"; const RESOURCE_URI = "memory://knowledge-graph";
@ -512,14 +410,11 @@ server.registerTool(
} }
}, },
async ({ entityNames }) => { async ({ entityNames }) => {
const { deleted, notFound } = await knowledgeGraphManager.deleteEntities(entityNames); await knowledgeGraphManager.deleteEntities(entityNames);
notifyGraphUpdated(); notifyGraphUpdated();
const message = notFound.length === 0
? "Entities deleted successfully"
: `Deleted ${deleted.length} of ${entityNames.length} entities. Not found: ${notFound.join(", ")}`;
return { return {
content: [{ type: "text" as const, text: message }], content: [{ type: "text" as const, text: "Entities deleted successfully" }],
structuredContent: { success: true, message } structuredContent: { success: true, message: "Entities deleted successfully" }
}; };
} }
); );
@ -548,16 +443,11 @@ server.registerTool(
} }
}, },
async ({ deletions }) => { async ({ deletions }) => {
const { deletedCount, missingEntities } = await knowledgeGraphManager.deleteObservations(deletions); await knowledgeGraphManager.deleteObservations(deletions);
notifyGraphUpdated(); 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 { return {
content: [{ type: "text" as const, text: message }], content: [{ type: "text" as const, text: "Observations deleted successfully" }],
structuredContent: { success: true, message } structuredContent: { success: true, message: "Observations deleted successfully" }
}; };
} }
); );
@ -583,14 +473,11 @@ server.registerTool(
} }
}, },
async ({ relations }) => { async ({ relations }) => {
const { deletedCount } = await knowledgeGraphManager.deleteRelations(relations); await knowledgeGraphManager.deleteRelations(relations);
notifyGraphUpdated(); notifyGraphUpdated();
const message = deletedCount === relations.length
? "Relations deleted successfully"
: `Deleted ${deletedCount} of ${relations.length} relations. The rest matched nothing.`;
return { return {
content: [{ type: "text" as const, text: message }], content: [{ type: "text" as const, text: "Relations deleted successfully" }],
structuredContent: { success: true, message } 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 // Register search_nodes tool
server.registerTool( server.registerTool(
"search_nodes", "search_nodes",
@ -636,7 +516,7 @@ server.registerTool(
title: "Search Nodes", title: "Search Nodes",
description: "Search for nodes in the knowledge graph based on a query", description: "Search for nodes in the knowledge graph based on a query",
inputSchema: { inputSchema: {
query: SearchNodesQuerySchema query: z.string().describe("The search query to match against entity names, types, and observation content")
}, },
outputSchema: { outputSchema: {
entities: z.array(EntitySchema), entities: z.array(EntitySchema),

View file

@ -1,6 +1,6 @@
{ {
"name": "@modelcontextprotocol/server-memory", "name": "@modelcontextprotocol/server-memory",
"version": "0.6.3", "version": "2026.8.31",
"description": "MCP server for enabling memory for Claude through a knowledge graph", "description": "MCP server for enabling memory for Claude through a knowledge graph",
"license": "SEE LICENSE IN LICENSE", "license": "SEE LICENSE IN LICENSE",
"mcpName": "io.github.modelcontextprotocol/server-memory", "mcpName": "io.github.modelcontextprotocol/server-memory",
@ -25,8 +25,7 @@
"test": "vitest run --coverage" "test": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0", "@modelcontextprotocol/sdk": "^1.30.0"
"zod": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22", "@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", "name": "@modelcontextprotocol/server-sequential-thinking",
"version": "0.6.2", "version": "2026.8.31",
"description": "MCP server for sequential thinking and problem solving", "description": "MCP server for sequential thinking and problem solving",
"license": "SEE LICENSE IN LICENSE", "license": "SEE LICENSE IN LICENSE",
"mcpName": "io.github.modelcontextprotocol/server-sequential-thinking", "mcpName": "io.github.modelcontextprotocol/server-sequential-thinking",
@ -27,8 +27,7 @@
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0", "@modelcontextprotocol/sdk": "^1.30.0",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"yargs": "^17.7.2", "yargs": "^17.7.2"
"zod": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22", "@types/node": "^22",